Spring Batch 처리 흐름 해부: Chunk 지향 처리와 Step Lifecycle

안녕하세요.

지난 시간에 Spring Batch의 4대 천왕(Job, Step, Reader, Writer)에 대해 알아봤습니다. 오늘은 이들이 실제로 어떻게 맞물려 돌아가는지, 그 내부 매커니즘(Lifecycle)을 들여다보겠습니다.

“그냥 읽고 쓰면 되는 거 아냐?”라고 생각하실 수 있습니다. 하지만 데이터가 100만 건, 1000만 건이 된다면 이야기가 달라집니다. 메모리 오버플로우 없이, 그리고 중간에 에러가 나도 안전하게 처리하려면 Chunk 지향 처리(Chunk-oriented Processing)를 반드시 이해해야 합니다.


1. Chunk 지향 처리란? (The ‘Why’)

Chunk란 ‘덩어리’라는 뜻입니다. 대용량 데이터를 한 번에 메모리에 올리는 것은 불가능합니다. 그래서 Spring Batch는 데이터를 조금씩(Chunk Size만큼) 읽어서 처리하고, 일정량이 모이면 그때 DB에 씁니다.

핵심 철학: “읽기는 하나씩, 쓰기는 뭉쳐서”

  • Read: 데이터를 하나씩 읽습니다. (메모리 부담 최소화)
  • Process: 데이터를 하나씩 가공합니다.
  • Write: 가공된 데이터를 모았다가 한 번에 씁니다. (트랜잭션 효율 극대화)

2. Step Lifecycle 상세 흐름도

가장 중요한 부분입니다. ChunkProviderChunkProcessor가 내부적으로 어떻게 움직이는지 단계별로 살펴보겠습니다.

graph TD
    start((Start)) --> read[1. ItemReader.read]
    read -- Item 반환 --> process[2. ItemProcessor.process]
    read -- null 반환 --> finish((End))

    process -- 가공된 Item --> buffer[3. Chunk Buffer에 저장]
    process -- null 반환 --> read

    buffer -- "Chunk Size 도달?" -- No --> read
    buffer -- "Chunk Size 도달?" -- Yes --> write[4. ItemWriter.write]

    write -- "트랜잭션 커밋" --> clear[Buffer 비우기]
    clear --> read

상세 단계 설명

  1. Read (반복):
    • ItemReader가 데이터를 하나 읽어옵니다.
    • 만약 읽은 데이터가 null이면, 더 이상 데이터가 없는 것으로 간주하고 Step을 종료합니다.
  2. Process (반복):
    • 읽은 데이터를 ItemProcessor에게 넘겨줍니다.
    • 가공된 결과를 반환합니다. 만약 null을 반환하면 해당 데이터는 필터링된 것으로 간주하고 버립니다(Writer로 가지 않음).
  3. Accumulate (축적):
    • 가공된 데이터는 바로 DB에 가지 않고, 메모리 상의 임시 리스트(List)에 차곡차곡 쌓입니다.
  4. Check Condition:
    • 쌓인 데이터의 개수가 설정한 chunk-size에 도달했는지 확인합니다.
    • 아직 모자라다면 1번으로 돌아가 계속 읽습니다.
  5. Write (일괄 처리):
    • Chunk Size만큼 데이터가 모이면, ItemWriter에게 List<Item> 형태로 한 번에 넘깁니다.
    • Writer는 이를 받아 saveAll() 등으로 일괄 저장합니다.
  6. Commit:
    • Writer 작업이 성공하면 트랜잭션을 커밋합니다. 비로소 DB에 반영되는 시점입니다.

3. 트랜잭션 범위와 롤백 (Transaction Scope)

Spring Batch의 트랜잭션은 Chunk 단위로 관리됩니다. 이 점이 매우 중요합니다.

시나리오: 1000개를 처리하는데 Chunk Size가 100인 경우

  • 총 10번의 트랜잭션 커밋이 발생합니다. (100개 * 10번)
  • 문제 상황: 950번째 데이터를 처리하다가 에러가 났습니다.
  • 결과:
    • 앞선 1~9번째 Chunk(1~900번 데이터)는 이미 커밋되었으므로 롤백되지 않습니다. (안전함)
    • 현재 진행 중이던 10번째 Chunk(901~1000번)의 작업만 롤백됩니다.
    • 배치 재실행 시, 이미 처리된 900개는 건너뛰고 901번부터 처리하도록 설계할 수 있습니다(Stateful Reader 사용 시).

4. 실전 코드: Chunk 설정과 리스너

단순히 chunk(100)만 설정하는 것이 아니라, 오류 처리를 위한 Skip 로직과 리스너를 추가한 실무형 코드입니다.

@Bean
public Step upgradeLevelStep() {
    return new StepBuilder("upgradeLevelStep", jobRepository)
            .<User, User>chunk(100, transactionManager) // 트랜잭션 단위 100
            .reader(userReader())
            .processor(userProcessor())
            .writer(userWriter())
            .faultTolerant() // 내결함성 기능 활성화
                .skip(IllegalArgumentException.class) // 특정 예외 발생 시 Skip
                .skipLimit(10) // 최대 10개까지는 에러 나도 무시하고 진행
            .listener(new StepExecutionListener() {
                @Override
                public void beforeStep(StepExecution stepExecution) {
                    log.info("Step 시작! 시작 시간: {}", LocalDateTime.now());
                }

                @Override
                public ExitStatus afterStep(StepExecution stepExecution) {
                    log.info("Step 종료! 처리 건수: {}", stepExecution.getWriteCount());
                    return ExitStatus.COMPLETED;
                }
            })
            .build();
}

코드 분석

  1. <User, User>chunk(100, ...):
    • 입력 타입과 출력 타입이 모두 User이며, 100개 단위로 커밋하겠다는 의미입니다.
  2. faultTolerant():
    • 배치 도중 에러가 나도 즉시 멈추지 않고 유연하게 대처하겠다는 선언입니다.
  3. skip(...):
    • 데이터 가공 중 IllegalArgumentException이 발생하면, 배치를 실패시키지 않고 해당 데이터만 건너뛰고 로그를 남긴 후 다음 데이터를 처리합니다. (매우 유용한 기능!)

5. 결론: 왜 Lifecycle을 알아야 하는가?

개발자가 Lifecycle을 모르면 “분명 DB에 넣었는데 왜 없지?” (트랜잭션 미커밋) 또는 “왜 에러 났는데 처음부터 다시 하지?” (재시작 지점 설정 미흡)와 같은 문제에 봉착하게 됩니다.

  • 데이터는 하나씩 흐르지만, 트랜잭션은 덩어리(Chunk)로 흐른다.
  • 에러 처리는 Chunk 단위로 격리된다.

이 두 가지만 기억하시면 Spring Batch의 70%는 이해하신 셈입니다. 다음 시간에는 이제 이론을 넘어, Spring Boot 3.x 환경에서 직접 프로젝트를 세팅하고 Hello World를 찍어보는 실습을 진행하겠습니다.

관련 글 보기