어제 오늘 내일

[Spring Boot] 데이터 100만 건을 한 번에? Spring Batch로 대용량 데이터 처리하기 본문

IT/SpringBoot

[Spring Boot] 데이터 100만 건을 한 번에? Spring Batch로 대용량 데이터 처리하기

hi.anna 2026. 3. 21. 10:46

 
웹 개발을 하다 보면 사용자의 요청에 실시간으로 응답하는 것(OLTP) 외에, 뒤에서 묵묵히 데이터를 처리하는 작업(Batch)이 반드시 필요합니다.

  • 정산: 매일 밤 매출 집계
  • 알림: 휴면 회원 전환 안내 메일 발송
  • 데이터 마이그레이션: 구형 DB에서 신형 DB로 데이터 이관

이런 작업을 단순히 List에 담아서 for 문을 돌리면, 메모리가 터져버립니다. Spring Batch는 이 문제를 해결하기 위해 태어났습니다.


1. 배치가 일반 로직과 다른 점 (Chunk 지향 처리)

Spring Batch의 핵심은 "데이터를 한 번에 다 읽지 않는다"는 것입니다.
대신 청크(Chunk)라는 덩어리로 잘라서 처리합니다.

  • 일반적인 방식: 100만 개 조회(메모리 펑!) -> 100만 개 가공 -> 100만 개 저장
  • Spring Batch (Chunk):
  1. 1,000개 읽기 (Reader)
  2. 1,000개 가공 (Processor)
  3. 1,000개 저장 (Writer) -> 트랜잭션 커밋!
  4. (반복...)

이 방식을 쓰면 데이터가 1억 건이 넘어도 메모리는 딱 1,000개 분량만 쓰기 때문에 절대 죽지 않습니다.


2. Spring Batch의 기본 구조 (Job, Step)

배치는 계층 구조로 되어 있습니다. 용어가 조금 낯설 수 있으니 확실히 잡고 가야 합니다.

  1. Job (일감): 배치 작업의 전체 단위입니다. (예: "일일 정산 Job")
  2. Step (단계): Job 안의 세부 단계입니다. (예: "1단계: 데이터 읽기", "2단계: 파일 생성")
  3. 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 문제입니다.
RepositoryItemReaderJpaPagingItemReader를 쓸 때는 일반적인 Fetch Join이 잘 먹히지 않을 때가 많습니다.

  • 해결책: hibernate.default_batch_fetch_size 옵션을 켜거나, 쿼리를 직접 작성하여 최적화해야 합니다. 대용량 처리에서는 영속성 컨텍스트 관리가 까다롭기 때문에, 복잡하면 MyBatisJdbcTemplate을 섞어 쓰는 것도 좋은 전략입니다.

5. 배치는 어떻게 실행하나요?

스프링 부트 앱을 띄울 때 배치를 같이 띄우면 안 됩니다. (웹 서버가 느려지니까요!)
보통 젠킨스(Jenkins)나 리눅스 크론(Cron)을 이용해 별도의 프로세스로 실행합니다.

# 특정 Job만 실행하는 명령어
java -jar application.jar --job.name=inactiveUserJob date=2024-03-15

이렇게 파라미터(date)를 넘기면, Spring Batch가 알아서 "이 날짜의 배치는 이미 성공했네?" 하고 중복 실행을 막아주기도 합니다. (이게 배치의 매력이죠!)


마치며

오늘의 결론입니다.

  1. 대용량 데이터 처리는 for문이 아니라 Spring Batch를 써야 한다.
  2. Chunk 지향 처리(Reader -> Processor -> Writer)가 배치의 핵심이다.
  3. 1,000개 단위(Chunk Size)로 끊어서 트랜잭션을 처리하므로 메모리가 안전하다.

이제 여러분은 "오늘 가입한 사람 100만 명에게 쿠폰 좀 넣어주세요"라는 요청이 와도 당황하지 않고 배치를 돌릴 수 있습니다.
다음 포스팅에서는 "코드는 깔끔해야 유지보수가 쉽다!" 아키텍처 편의 첫 번째 주제, DTO(Data Transfer Object)와 Service 계층의 역할 분리에 대해 알아보겠습니다.
도움이 되셨다면 좋아요와 댓글 부탁드립니다! 😊

반응형
Comments