Part 4. Spring Batch 핵심: Chunk, 트랜잭션 경계, Restart 가능한 Job 설계
Spring Batch의 Reader/Processor/Writer 구조를 운영 관점에서 해부하고, 재시작 가능한 배치를 설계하는 기준을 제시한다.
Series: Spring Boot 배치 전략 완전 정복
총 12편 구성. 현재 4편을 보고 있습니다.
- 01Part 1. 배치의 본질과 분류: 스케줄, 이벤트, 수동, 대량, Near-real-time
- 02Part 2. @Scheduled 실전 운영: 단순함의 대가와 멀티 인스턴스 함정
- 03Part 3. Quartz 클러스터 아키텍처: JobStore, Misfire, 대규모 스케줄 관리
- 04Part 4. Spring Batch 핵심: Chunk, 트랜잭션 경계, Restart 가능한 Job 설계CURRENT
- 05Part 5. Spring Batch 확장: Partition과 Multi-threaded Step의 트레이드오프
- 06Part 6. 수동 배치 전략: REST 트리거, Admin UI, 파라미터 재처리, 롤백
- 07Part 7. DB 대량 조회 전략: OFFSET/LIMIT 한계와 Keyset, ID Range, Covering Index
- 08Part 8. OpenSearch/Elasticsearch 배치 전략: Scroll, Search After, PIT, Bulk, Rollover
- 09Part 9. 분산 환경 배치: Leader Election, Kubernetes CronJob, 락 전략 비교
- 10Part 10. 성능 최적화: 배치 사이즈, 커밋 간격, JVM 메모리, Backpressure
- 11Part 11. 장애 대응 아키텍처: Partial Failure, Poison Data, DLQ, 재시도, 멱등성
- 12Part 12. 통합 레퍼런스 아키텍처와 최종 선택 가이드

출처: Pexels - Black Server Racks
버전 기준
- Java 21
- Spring Boot 3.3.x
- Spring Batch 5.2.x
- Quartz 2.3.x
- PostgreSQL 15
- OpenSearch 2.x
1) 문제 제기
Spring Batch를 도입하는 이유는 "대량 처리" 자체보다 "실패 후 다시 돌릴 수 있는 구조" 때문이다. 실제 운영에서 큰 장애는 성능 부족보다 다음 두 가지에서 발생한다.
- 어디까지 커밋됐는지 모른다.
- 재시작하면 중복 처리하거나 누락 처리한다.
즉, 핵심은 속도가 아니라 경계다. Reader, Processor, Writer를 나누는 이유도 관심사 분리가 아니라 트랜잭션 경계를 명확히 하기 위해서다.
2) 핵심 개념 정리
Chunk 처리 모델
Spring Batch의 기본 단위는 "chunk"다.
- Reader가 N건 읽는다.
- Processor가 변환/검증한다.
- Writer가 한 번에 기록한다.
- chunk 단위로 커밋한다.
chunk 크기가 커질수록 DB round-trip은 줄지만, 실패 시 롤백 범위와 메모리 사용량이 증가한다. 반대로 chunk가 너무 작으면 커밋 오버헤드가 커진다.
트랜잭션 경계
- 기본적으로 Step에서 chunk 단위 트랜잭션이 열린다.
- Reader가 DB cursor 방식이면 같은 트랜잭션에서 오래 잡히지 않도록 주의해야 한다.
- 격리 수준은 보통
READ COMMITTED로 시작하고, 동일 데이터 반복 조회가 필요한 재무 계산은REPEATABLE READ를 검토한다.
재시작 전략
ExecutionContext에 마지막 처리 키(예: lastId)를 저장한다.- Writer는 멱등 업서트(
ON CONFLICT DO UPDATE)로 구성한다. - skip/retry 정책은 "재시작 시 재현 가능"하도록 고정한다.
처리 구조 다이어그램

출처: Pexels - Security control room team
3) 코드 예시
예시 A: Chunk Step 구성
@Bean
public Step billingStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager,
ItemReader<BillingTarget> reader,
ItemProcessor<BillingTarget, BillingResult> processor,
ItemWriter<BillingResult> writer) {
return new StepBuilder("billingStep", jobRepository)
.<BillingTarget, BillingResult>chunk(1000, transactionManager)
.reader(reader)
.processor(processor)
.writer(writer)
.faultTolerant()
.skip(InvalidBillingDataException.class)
.skipLimit(100)
.retry(DeadlockLoserDataAccessException.class)
.retryLimit(3)
.build();
}
예시 B: Keyset Reader SQL
SELECT id, account_id, amount, due_date
FROM billing_target
WHERE status = 'PENDING'
AND id > :last_id
ORDER BY id ASC
LIMIT :chunk_size;
예시 C: 멱등 Writer (PostgreSQL)
INSERT INTO billing_result (target_id, billed_amount, billed_at)
VALUES (:target_id, :billed_amount, NOW())
ON CONFLICT (target_id)
DO UPDATE SET
billed_amount = EXCLUDED.billed_amount,
billed_at = EXCLUDED.billed_at;
예시 D: 인덱스 설계
CREATE INDEX idx_billing_target_status_id ON billing_target (status, id);
CREATE UNIQUE INDEX uk_billing_result_target_id ON billing_result (target_id);
4) 실제 장애/운영 시나리오
상황: chunk size를 5,000으로 높여 처리량을 늘렸더니, 2시간 후 OOM과 긴 GC pause가 발생했다. 실패 직전 chunk가 롤백되며 5,000건이 재처리됐고, 외부 결제 API가 중복 호출되었다.
원인:
- Processor가 외부 API 응답을 객체에 누적 저장해 chunk당 메모리 사용량이 과도했다.
- Writer가 멱등 upsert가 아닌 insert-only였다.
- 재시도 정책이 외부 API 예외까지 포함되어 중복 호출을 확대했다.
개선:
- chunk를 1,000으로 축소하고, Processor 메모리 객체를 즉시 해제하도록 변경.
- 외부 API 호출은 outbox 패턴으로 분리하고, 배치는 요청 생성까지만 담당.
- Writer를 멱등 upsert로 교체.
5) 설계 체크리스트
- chunk 크기를 추정 메모리 사용량(건당 객체 크기 x chunk x 스레드)으로 계산했는가?
- Reader가 keyset 또는 range 기반으로 재시작 가능한가?
- Writer가 멱등성을 보장하는가?
- 스킵/재시도 정책이 도메인 규칙과 일치하는가?
- 트랜잭션 격리 수준을 의도적으로 선택했는가?
-
ExecutionContext에 재시작 핵심 포인터가 저장되는가?
6) 요약
Spring Batch의 진짜 가치은 "대량 처리"가 아니라 "통제 가능한 실패"다. Chunk 경계를 명확히 하고 멱등 Writer, 재시작 포인터를 설계해야 운영에서 살아남는다.
7) 다음 편 예고
다음 편에서는 Spring Batch의 병렬 처리 전략을 다룬다. Partition, Multi-threaded Step, 원격 청크를 어떤 기준으로 선택해야 하는지와 실제 처리량/정합성 트레이드오프를 정리한다.
참고 링크
- Spring Batch Reference
- Quartz Scheduler Documentation
- PostgreSQL Transaction Isolation
- 블로그: Idempotency Key API 설계