Spring Batch의 병렬 처리는 어떻게 설계되는가
AsyncItemProcessor의 Future 위임부터 Multi-threaded Step의 Thread-safety, @StepScope Late Binding, 이벤트 기반 배치 트리거까지 — 병렬 처리 설계 결정을 추적한다.
- 01 Spring Batch는 왜 Job·Step·Tasklet으로 나누는가
- 02 Spring Batch의 Chunk는 왜 Read→Process→Write인가
- 03 Spring Batch Job Flow는 어떻게 실행을 제어하는가
- 04 Spring Batch는 실패한 1건을 어떻게 격리하는가
- 05 Spring Batch Partitioning은 어떻게 1,000만 건을 나누는가
- 06 Spring Batch의 병렬 처리는 어떻게 설계되는가
Spring Batch는 “대용량 데이터를 안정적으로 처리한다”는 목표를 가진다. 그런데 안정성과 병렬 처리는 자주 충돌한다. 어떻게 둘을 동시에 잡는가? 그리고 왜 같은 TaskExecutor 설정 하나가 어떤 컴포넌트에 붙느냐에 따라 완전히 다른 결과를 낳는가?
병렬화의 두 축: Process와 Data
Spring Batch의 병렬화 전략은 크게 두 가지 질문에서 갈린다.
Process 단계가 병목인가, 데이터 양 자체가 문제인가?
AsyncItemProcessor는 전자를 공략한다. 외부 API 호출처럼 I/O Bound인 처리를 별도 스레드 풀에 위임하고, Future<O>를 즉시 반환한다. 이 Future 목록을 AsyncItemWriter가 받아 future.get()으로 unwrap한 후 실제 Writer에 전달한다. 핵심은 이 둘을 반드시 쌍으로 써야 한다는 것이다. AsyncItemProcessor가 Future<SettledOrder>를 반환하는데 일반 ItemWriter<SettledOrder>를 연결하면 런타임에 ClassCastException이 발생한다.
// Step 타입 선언이 핵심 — I가 Order, O가 Future<SettledOrder>
.<Order, Future<SettledOrder>>chunk(100)
.reader(orderReader())
.processor(asyncEnrichmentProcessor()) // Future<SettledOrder> 반환
.writer(asyncEnrichedOrderWriter()) // Future<SettledOrder>를 unwrap
Multi-threaded Step은 후자를 공략한다. 하나의 Step 안에서 여러 스레드가 각자 독립적인 Chunk를 가져가 처리한다. 데이터 양 자체를 병렬로 소화하는 방식이다.
Multi-threaded Step의 Thread-safety 문제
taskExecutor를 Step에 설정하면 여러 스레드가 동시에 ItemReader.read()를 호출한다. 문제는 JpaPagingItemReader 같은 대부분의 Reader가 내부에 page, current 같은 상태를 가진다는 것이다. 두 스레드가 동시에 이 상태를 수정하면 데이터 중복 또는 누락이 발생한다.
해결책은 SynchronizedItemStreamReader로 감싸는 것이다. read() 메서드에 synchronized를 붙여 한 번에 한 스레드만 접근하게 한다.
SynchronizedItemStreamReader<Order> syncReader = new SynchronizedItemStreamReader<>();
syncReader.setDelegate(jpaPagingReader());
그런데 read()가 순차적이라면 성능 향상은 어디서 오는가? Process와 Write 단계에서 온다. 각 스레드는 자신이 가져간 Chunk를 독립적으로 처리하고 쓴다. read() 자체가 병목이라면 Multi-threaded Step의 효과는 제한적이고, 그 경우엔 Partitioning이 더 적합하다.
Multi-threaded Step에서 saveState=true(기본값)를 그대로 두면, 여러 스레드가 동시에 ExecutionContext의 read.count를 덮어쓰는 경쟁이 발생한다. saveState(false)로 EC 저장을 비활성화해야 한다. 대신 재시작 시 처음부터 처리한다는 트레이드오프를 감수해야 한다.
@StepScope — Late Binding이 필요한 이유
Spring Context는 애플리케이션 시작 시 초기화된다. 그런데 JobParameters는 JobLauncher.run()이 호출되는 시점, 즉 훨씬 나중에 존재한다. @StepScope 없이 @Value("#{jobParameters['date']}")를 쓰면 Context 초기화 시점에 SpEL을 평가하려다 null을 얻는다.
@StepScope는 CGLIB Proxy를 생성해 Bean 생성을 Step 실행 시점으로 미룬다. 정확한 흐름은 다음과 같다.
1. Context 초기화: CGLIB Proxy Bean 등록 (실제 Bean 아님)
2. Step 실행: StepSynchronizationManager.register(stepExecution)
3. Proxy 메서드 호출: StepScope.get() 호출
4. 실제 Bean 생성 + SpEL 평가: jobParameters['date'] → "2024-01-01"
5. 인스턴스를 StepContext에 캐싱
Partitioning에서 Worker마다 다른 minId, maxId를 주입받을 수 있는 것도 이 덕분이다. 각 Worker는 별도 스레드에서 자신의 StepExecution을 등록하고, StepScope.get()은 StepSynchronizationManager.getContext()로 현재 스레드의 StepContext를 조회한다. 4개 Worker가 같은 Bean 이름을 참조해도 각자 독립된 인스턴스를 갖는다.
트레이드오프
AsyncItemProcessor: Process 병목(I/O, CPU)을 병렬화하되 Reader/Writer는 단일 스레드 유지 — Thread-safety 문제 없음. 단점은 AsyncItemWriter에서 future.get()을 순서대로 호출하므로 가장 느린 아이템이 Chunk 전체를 대기시키는 Long Tail 문제가 있다.
Multi-threaded Step: 설정이 단순하고 기존 Step에 taskExecutor만 추가하면 된다. 단점은 saveState=false로 인해 재시작 시 처음부터 처리한다는 것. 재시작 지원이 필요하다면 Partitioning을 선택해야 한다.
@StepScope: CGLIB Proxy이므로 final 클래스에는 적용할 수 없다. Step 외부에서 호출하면 IllegalStateException이 발생한다.
이벤트 기반 배치 트리거
시간 기반 스케줄러(@Scheduled)는 입력 준비 여부와 무관하게 실행된다. 파일이 03:00에 도착하는데 00:00에 배치를 돌리면 실패한다. Spring Batch Integration은 이 문제를 JobLaunchingGateway로 해결한다.
파일 도착 → FileReadingMessageSource 폴링
→ Transformer: File → JobLaunchRequest (Job + JobParameters)
→ JobLaunchingGateway: JobLauncher.run() 호출
→ JobExecution 응답 → 결과 채널로 전달
Kafka 이벤트를 집계해 임계값(건수 또는 시간) 도달 시 배치를 기동하는 패턴도 같은 구조를 따른다. 핵심은 이벤트 소스와 배치 처리 간 결합도를 메시지 채널로 낮춘다는 것이다. 단, 메시지 중복 처리 방지(AcceptOnceFileListFilter, IdempotentReceiverInterceptor)와 Dead Letter Queue 처리는 반드시 설계해야 한다.
정리
AsyncItemProcessor+AsyncItemWriter는 반드시 쌍으로 사용한다. Step 타입을<I, Future<O>>로 선언해야 한다.- Multi-threaded Step에서는
SynchronizedItemStreamReader와saveState(false)가 필수다. 재시작 지원이 필요하면 Partitioning으로 전환한다. @StepScope는 CGLIB Proxy를 통한 Late Binding이다.jobParameters와stepExecutionContext는 Step 실행 시점 이후에야 접근할 수 있다.- 이벤트 기반 트리거는
JobLaunchingGateway로 구현하되, 메시지 중복 처리 방지와 Dead Letter Queue를 함께 설계해야 한다.
다음 글에서는 Partitioning의 Partitioner 구현과 Worker StepExecution 재시작 동작을 추적한다.