안녕하세요.
지난 시간에 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 상세 흐름도

가장 중요한 부분입니다. ChunkProvider와 ChunkProcessor가 내부적으로 어떻게 움직이는지 단계별로 살펴보겠습니다.
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
상세 단계 설명
- Read (반복):
ItemReader가 데이터를 하나 읽어옵니다.- 만약 읽은 데이터가
null이면, 더 이상 데이터가 없는 것으로 간주하고 Step을 종료합니다.
- Process (반복):
- 읽은 데이터를
ItemProcessor에게 넘겨줍니다. - 가공된 결과를 반환합니다. 만약
null을 반환하면 해당 데이터는 필터링된 것으로 간주하고 버립니다(Writer로 가지 않음).
- 읽은 데이터를
- Accumulate (축적):
- 가공된 데이터는 바로 DB에 가지 않고, 메모리 상의 임시 리스트(List)에 차곡차곡 쌓입니다.
- Check Condition:
- 쌓인 데이터의 개수가 설정한
chunk-size에 도달했는지 확인합니다. - 아직 모자라다면 1번으로 돌아가 계속 읽습니다.
- 쌓인 데이터의 개수가 설정한
- Write (일괄 처리):
- Chunk Size만큼 데이터가 모이면,
ItemWriter에게List<Item>형태로 한 번에 넘깁니다. - Writer는 이를 받아
saveAll()등으로 일괄 저장합니다.
- Chunk Size만큼 데이터가 모이면,
- 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();
}
코드 분석
<User, User>chunk(100, ...):- 입력 타입과 출력 타입이 모두
User이며, 100개 단위로 커밋하겠다는 의미입니다.
- 입력 타입과 출력 타입이 모두
faultTolerant():- 배치 도중 에러가 나도 즉시 멈추지 않고 유연하게 대처하겠다는 선언입니다.
skip(...):- 데이터 가공 중
IllegalArgumentException이 발생하면, 배치를 실패시키지 않고 해당 데이터만 건너뛰고 로그를 남긴 후 다음 데이터를 처리합니다. (매우 유용한 기능!)
- 데이터 가공 중
5. 결론: 왜 Lifecycle을 알아야 하는가?
개발자가 Lifecycle을 모르면 “분명 DB에 넣었는데 왜 없지?” (트랜잭션 미커밋) 또는 “왜 에러 났는데 처음부터 다시 하지?” (재시작 지점 설정 미흡)와 같은 문제에 봉착하게 됩니다.
- 데이터는 하나씩 흐르지만, 트랜잭션은 덩어리(Chunk)로 흐른다.
- 에러 처리는 Chunk 단위로 격리된다.
이 두 가지만 기억하시면 Spring Batch의 70%는 이해하신 셈입니다. 다음 시간에는 이제 이론을 넘어, Spring Boot 3.x 환경에서 직접 프로젝트를 세팅하고 Hello World를 찍어보는 실습을 진행하겠습니다.






