| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- input
- string
- 배열
- Eclipse
- SpringBoot
- ArrayList
- 단위테스트
- junit5
- CSS
- java테스트
- IntelliJ
- 스프링부트
- 자바
- 자바스크립트
- 인텔리제이
- Java
- junit
- 자바문법
- html
- 테스트자동화
- js
- vscode
- Array
- math
- 문자열
- Visual Studio Code
- 정규식
- javascript
- list
- HashMap
- Today
- Total
어제 오늘 내일
[Spring Boot] 데이터 100만 건을 한 번에? Spring Batch로 대용량 데이터 처리하기 본문
웹 개발을 하다 보면 사용자의 요청에 실시간으로 응답하는 것(OLTP) 외에, 뒤에서 묵묵히 데이터를 처리하는 작업(Batch)이 반드시 필요합니다.
- 정산: 매일 밤 매출 집계
- 알림: 휴면 회원 전환 안내 메일 발송
- 데이터 마이그레이션: 구형 DB에서 신형 DB로 데이터 이관
이런 작업을 단순히 List에 담아서 for 문을 돌리면, 메모리가 터져버립니다. Spring Batch는 이 문제를 해결하기 위해 태어났습니다.
1. 배치가 일반 로직과 다른 점 (Chunk 지향 처리)
Spring Batch의 핵심은 "데이터를 한 번에 다 읽지 않는다"는 것입니다.
대신 청크(Chunk)라는 덩어리로 잘라서 처리합니다.
- 일반적인 방식: 100만 개 조회(메모리 펑!) -> 100만 개 가공 -> 100만 개 저장
- Spring Batch (Chunk):
- 1,000개 읽기 (Reader)
- 1,000개 가공 (Processor)
- 1,000개 저장 (Writer) -> 트랜잭션 커밋!
- (반복...)
이 방식을 쓰면 데이터가 1억 건이 넘어도 메모리는 딱 1,000개 분량만 쓰기 때문에 절대 죽지 않습니다.
2. Spring Batch의 기본 구조 (Job, Step)
배치는 계층 구조로 되어 있습니다. 용어가 조금 낯설 수 있으니 확실히 잡고 가야 합니다.
- Job (일감): 배치 작업의 전체 단위입니다. (예: "일일 정산 Job")
- Step (단계): Job 안의 세부 단계입니다. (예: "1단계: 데이터 읽기", "2단계: 파일 생성")
- Tasklet / Chunk: Step 안에서 실제로 일을 하는 방식입니다.
- Tasklet: 단순한 작업 (파일 삭제 등)
- Chunk: 대용량 데이터 처리 (읽기-가공-쓰기)
3. 실전 코드: 휴면 회원 전환 배치 만들기
"1년 이상 접속하지 않은 회원을 휴면 상태로 변경하는 배치"를 만들어보겠습니다. (Spring Boot 3.x 버전 기준)
① 설정 클래스 ()
@Configuration
@RequiredArgsConstructor
public class InactiveUserJobConfig {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final UserRepository userRepository;
// 1. Job 정의
@Bean
public Job inactiveUserJob() {
return new JobBuilder("inactiveUserJob", jobRepository)
.start(inactiveUserStep()) // 첫 번째 단계 시작!
.build();
}
// 2. Step 정의 (Chunk 방식)
@Bean
public Step inactiveUserStep() {
return new StepBuilder("inactiveUserStep", jobRepository)
.<User, User>chunk(1000, transactionManager) // 1000개씩 끊어서 처리!
.reader(inactiveUserReader())
.processor(inactiveUserProcessor())
.writer(inactiveUserWriter())
.build();
}
// ... Reader, Processor, Writer 구현은 아래에
}
② Reader, Processor, Writer 구현
// [Reader] DB에서 데이터 읽어오기 (Paging 기법 사용)
@Bean
public RepositoryItemReader<User> inactiveUserReader() {
return new RepositoryItemReaderBuilder<User>()
.name("inactiveUserReader")
.repository(userRepository)
.methodName("findByLastLoginBefore") // 1년 전 로그인한 사람 조회
.pageSize(1000) // 한 번에 가져올 크기
.arguments(LocalDateTime.now().minusYears(1))
.sorts(Collections.singletonMap("id", Sort.Direction.ASC))
.build();
}
// [Processor] 데이터 가공 (상태 변경)
@Bean
public ItemProcessor<User, User> inactiveUserProcessor() {
return user -> {
user.setStatus(UserStatus.INACTIVE); // 상태를 '휴면'으로 변경
return user;
};
}
// [Writer] DB에 저장
@Bean
public ItemWriter<User> inactiveUserWriter() {
return users -> {
userRepository.saveAll(users); // 변경된 1000명을 한방에 저장
};
}
4. 주의사항: JPA N+1 문제 (Fetch Join 불가능?)
배치에서 JPA를 쓸 때 가장 많이 하는 실수가 N+1 문제입니다.RepositoryItemReader나 JpaPagingItemReader를 쓸 때는 일반적인 Fetch Join이 잘 먹히지 않을 때가 많습니다.
- 해결책:
hibernate.default_batch_fetch_size옵션을 켜거나, 쿼리를 직접 작성하여 최적화해야 합니다. 대용량 처리에서는 영속성 컨텍스트 관리가 까다롭기 때문에, 복잡하면 MyBatis나 JdbcTemplate을 섞어 쓰는 것도 좋은 전략입니다.
5. 배치는 어떻게 실행하나요?
스프링 부트 앱을 띄울 때 배치를 같이 띄우면 안 됩니다. (웹 서버가 느려지니까요!)
보통 젠킨스(Jenkins)나 리눅스 크론(Cron)을 이용해 별도의 프로세스로 실행합니다.
# 특정 Job만 실행하는 명령어
java -jar application.jar --job.name=inactiveUserJob date=2024-03-15
이렇게 파라미터(date)를 넘기면, Spring Batch가 알아서 "이 날짜의 배치는 이미 성공했네?" 하고 중복 실행을 막아주기도 합니다. (이게 배치의 매력이죠!)
마치며
오늘의 결론입니다.
- 대용량 데이터 처리는
for문이 아니라 Spring Batch를 써야 한다. - Chunk 지향 처리(Reader -> Processor -> Writer)가 배치의 핵심이다.
- 1,000개 단위(Chunk Size)로 끊어서 트랜잭션을 처리하므로 메모리가 안전하다.
이제 여러분은 "오늘 가입한 사람 100만 명에게 쿠폰 좀 넣어주세요"라는 요청이 와도 당황하지 않고 배치를 돌릴 수 있습니다.
다음 포스팅에서는 "코드는 깔끔해야 유지보수가 쉽다!" 아키텍처 편의 첫 번째 주제, DTO(Data Transfer Object)와 Service 계층의 역할 분리에 대해 알아보겠습니다.
도움이 되셨다면 좋아요와 댓글 부탁드립니다! 😊
