Spring Batch는 실패한 1건을 어떻게 격리하는가
FaultTolerantChunkProcessor의 스캐터-개더 패턴부터 Custom SkipPolicy 구현까지, Spring Batch 오류 처리 전략의 설계 철학을 추적한다.
- 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의 오류 처리는 하나의 질문에서 출발한다 — “Chunk 1000건 중 1건이 실패했을 때, 나머지 999건을 어떻게 살릴 것인가?” 단순히 예외를 잡아서 무시하는 것도 아니고, Chunk 전체를 포기하는 것도 아니다. Spring Batch가 선택한 답은 스캐터-개더(scatter-gather) 다. 왜 이 패턴이어야 했는가?
문제: Write 실패 시 어느 아이템이 문제인지 알 수 없다
JDBC 배치 INSERT 중 ConstraintViolationException이 발생하면, 예외는 배치 단위로 던져진다. 500번째 아이템이 문제였더라도 나머지 999건을 구별할 방법이 없다. 단순하게 Chunk 전체를 FAILED로 처리하면 1건 문제로 999건이 롤백된다.
faultTolerant()를 호출하는 순간 Spring Batch는 SimpleChunkProcessor를 FaultTolerantChunkProcessor로 교체한다. 이 교체가 스캐터-개더를 가능하게 한다.
① Chunk 1000건 Write 시도 → 500번 아이템에서 예외 발생
② Chunk 전체 롤백 (여기까지는 일반 처리와 동일)
③ 스캐터-개더 모드: Chunk를 1건씩 분해, 각각 별도 트랜잭션으로 재처리
④ 500번 아이템: shouldSkip() = true → writeSkipCount++, 해당 1건만 롤백
⑤ 나머지 999건: 정상 COMMIT
Write Skip이 가장 비용이 큰 이유가 여기 있다. 1000건 Chunk에서 1건 Skip이면 최대 1000번의 별도 커밋이 발생한다.
skip(DataIntegrityViolationException.class)는 faultTolerant() 이후에만 효과가 있다. faultTolerant() 없이 설정하면 SimpleChunkProcessor가 그대로 사용되어 Skip 동작이 전혀 없다.
Read / Process / Write — 단계마다 다른 Skip 흐름
Skip은 단계마다 동작이 다르다. Read Skip은 아직 아이템을 읽지 못한 상태이므로 onSkipInRead(e)에 item 파라미터가 없다. Process Skip은 입력 아이템은 있지만 출력을 만들지 않고 inputs에서 제거한다. Write Skip만 스캐터-개더를 유발한다.
세 단계의 Skip 카운트는 BATCH_STEP_EXECUTION의 READ_SKIP_COUNT, PROCESS_SKIP_COUNT, WRITE_SKIP_COUNT 컬럼에 각각 기록된다. skipLimit은 이 세 값의 합산을 기준으로 체크한다. skipLimit을 초과하는 순간 SkipLimitExceededException이 던져지고 Step은 FAILED된다 — Skip이 아니다.
Retry: 일시적 오류를 그 자리에서 복구하다
Skip이 “이 아이템을 포기하라”는 결정이라면, Retry는 “잠시 후 다시 시도하라”는 결정이다. 네트워크 타임아웃, 데드락, 외부 API 503 — 이런 일시적 오류는 재시도하면 성공할 가능성이 있다.
.faultTolerant()
.retry(TransientDataAccessException.class)
.retryLimit(3)
.backOffPolicy(exponentialRandomBackOffPolicy())
// 500ms → 1초 → 2초 (랜덤 지수 백오프)
BackOff 없이 즉시 재시도하면 외부 시스템에 순간적으로 요청이 집중된다. ExponentialRandomBackOffPolicy는 각 클라이언트가 서로 다른 시점에 재시도하도록 분산시켜 Thundering Herd를 방지한다.
Retry와 Skip을 같은 예외에 등록하면 “Retry 소진 후 Skip으로 전환”이 된다. 순서는 명확하다 — Retry가 먼저 소진되고, 그 이후 shouldSkip()이 호출된다.
.retry(ApiTimeoutException.class).retryLimit(3)
.skip(ApiTimeoutException.class).skipLimit(10)
// → 3회 Retry 소진 → shouldSkip() → true → Skip 전환
오류 분류가 전략보다 먼저다
Skip과 Retry 중 무엇을 쓸지 결정하기 전에 오류를 먼저 분류해야 한다.
| 오류 성격 | 전략 | 예시 |
|---|---|---|
| 일시적 (재시도 시 성공 가능) | Retry | DeadlockLoserDataAccessException, SocketTimeoutException |
| 영구적 (재시도 무의미) | Skip | DataIntegrityViolationException, ParseException |
| 치명적 (운영 개입 필요) | noSkip + noRetry | InsufficientBalanceException, AccountFrozenException |
noSkip과 noRetry는 방어선이다. noSkip(CriticalException.class)이 설정된 예외가 발생하면 shouldSkip()은 무조건 false를 반환하고 예외가 전파된다 — Step FAILED. VIP 고객 오류를 조용히 Skip하지 않겠다는 비즈니스 규칙이 코드로 표현되는 방식이다.
예외 계층 구조도 고려해야 한다. skip(TransientDataAccessException.class)는 하위 클래스인 DeadlockLoserDataAccessException에도 적용된다. 더 구체적인 noSkip이 더 일반적인 skip보다 우선한다.
재시작: ExecutionContext가 책갈피다
50만 건 처리 중 실패했을 때 처음부터 재처리하는 것은 낭비다. Spring Batch는 Chunk 커밋마다 현재 읽기 위치를 ExecutionContext에 저장하고 BATCH_STEP_EXECUTION_CONTEXT 테이블에 영구 보존한다.
// Chunk 커밋 직전, 같은 트랜잭션 내에서:
stream.update(stepExecution.getExecutionContext());
// → {"orderReader.read.count": 500000}
jobRepository.updateExecutionContext(stepExecution);
// → DB UPDATE
// ← 비즈니스 데이터 COMMIT과 EC 저장이 원자적
재시작 시 ItemStream.open(ec)이 호출되면 Reader는 ec에서 위치를 복원해 500001번째 아이템부터 재개한다. EC에는 재시작 위치(ID, 페이지 번호)만 저장해야 한다. 처리된 ID 목록 같은 대용량 데이터를 저장하면 Chunk 커밋마다 수십 MB를 DB에 UPDATE하는 성능 저하가 생긴다.
재시작이 가능한 조건은 마지막 JobExecution이 FAILED 또는 STOPPED 상태여야 한다. JVM 강제 종료로 UNKNOWN 상태가 됐다면 자동 재시작이 불가능하다 — 수동으로 FAILED로 변경한 후 재시작해야 한다.
트레이드오프
Write Skip의 비용: 스캐터-개더는 Skip이 빈번할수록 느려진다. Chunk 1000건에서 Skip이 자주 발생하면 최대 1000번의 별도 커밋이 반복된다. Skip 빈도가 높다면 Chunk 크기를 줄이거나 데이터 전처리 단계에서 불량 데이터를 필터링하는 것이 낫다.
skipLimit 설정: 너무 낮으면 조기 Step 실패, 너무 높으면 대규모 데이터 오류를 조용히 무시한다. 비율 기반 Custom SkipPolicy(전체의 5% 이내)가 건수 기반보다 데이터 품질 기준을 더 자연스럽게 표현한다.
processorNonTransactional(): Processor 결과를 캐싱해 스캐터-개더 시 재실행을 생략한다. Processor가 순수 함수(동일 입력 → 동일 출력, side effect 없음)일 때만 사용해야 한다. 외부 API 호출이나 DB 조회가 포함된 Processor에 사용하면 재시도 시 최신 데이터를 반영하지 못한다.
정리
faultTolerant()는SimpleChunkProcessor를FaultTolerantChunkProcessor로 교체한다. 이 교체 없이는 Skip도 Retry도 동작하지 않는다.- Write Skip은 스캐터-개더를 유발한다. 1건 문제를 격리하기 위해 Chunk 전체를 1건씩 재처리하는 것이 그 대가다.
- 오류를 일시적/영구적/치명적으로 분류한 다음 전략을 결정하라. 예외 목록부터 작성하는 것이 아니라 오류 성격 파악이 먼저다.
- EC는 Chunk 커밋과 원자적으로 저장된다. 최소 정보만 저장해야 성능을 유지할 수 있다.
다음 글에서는 Partitioning으로 단일 Step을 병렬 Worker로 분산하는 방법과, 그때 ExecutionContext 관리가 어떻게 달라지는지 추적한다.