10만 건 중 1건이 잘못된 데이터 때문에 배치 전체가 실패하면 나머지 99,999건도 처리되지 않는다. 이런 상황에서 잘못된 데이터를 건너뛰고 나머지를 계속 처리하거나, 일시적 오류를 재시도해서 복구하는 전략이 필요하다.
Skip — 건너뛰기
특정 예외가 발생한 아이템을 건너뛰고 나머지를 계속 처리한다. 데이터 품질이 완벽하지 않은 현실에서 매우 유용하다.
@Bean
public Step processStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager) {
return new StepBuilder("processStep", jobRepository)
.<Order, Settlement>chunk(1000, transactionManager)
.reader(orderReader())
.processor(settlementProcessor())
.writer(settlementWriter())
.faultTolerant()
.skip(DataFormatException.class)
.skipLimit(100)
.build();
}
faultTolerant()를 호출해야 skip/retry 설정이 활성화된다. skip(DataFormatException.class)는 이 예외가 발생하면 해당 아이템을 건너뛰겠다는 뜻이다. skipLimit(100)은 최대 100건까지 건너뛸 수 있다는 제한이다. 101번째 skip이 발생하면 Step이 실패한다.
전체 흐름을 보면 이렇다.
item #2 건너뜀 end rect rgb(232, 248, 232) Note over R,W: 계속 처리 R->>P: item #3 P->>W: 변환 결과 end
분홍 영역에서 예외가 발생하지만 Step이 멈추지 않고, 다음 아이템부터 계속 처리한다.
noSkip으로 특정 예외 제외
.faultTolerant()
.skip(Exception.class)
.noSkip(FatalException.class)
.skipLimit(100)
모든 예외를 건너뛰되, FatalException은 절대 건너뛰지 않는다. 데이터 포맷 오류는 건너뛰지만, DB 커넥션 실패 같은 치명적 오류는 즉시 실패시키는 패턴이다.
chunk 처리 중 skip이 발생하면, 해당 chunk 전체가 롤백되고 skip 대상 아이템을 제외한 나머지를 다시 처리한다. 1,000건 chunk에서 1건을 skip하면, 999건으로 다시 chunk가 실행된다. 이 동작을 이해하지 못하면 "왜 같은 데이터가 두 번 처리되지?"라는 혼란에 빠진다.
Retry — 재시도
일시적 오류에 대해 동일한 아이템을 다시 처리한다. 네트워크 타임아웃, DB 락 충돌 같은 일시적 문제에 효과적이다.
@Bean
public Step apiStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager) {
return new StepBuilder("apiStep", jobRepository)
.<ExternalData, ProcessedData>chunk(100, transactionManager)
.reader(externalReader())
.processor(apiProcessor())
.writer(resultWriter())
.faultTolerant()
.retry(TimeoutException.class)
.retryLimit(3)
.build();
}
retry(TimeoutException.class)는 타임아웃이 발생하면 재시도하겠다는 뜻이다. retryLimit(3)은 최대 3번까지 재시도한다. 3번 모두 실패하면 해당 아이템이 최종 실패 처리된다.
Skip과 Retry 조합
재시도를 다 소진한 후에도 실패하면 건너뛰는 패턴이다. 실무에서 가장 많이 쓰는 조합이다.
.faultTolerant()
.retry(TimeoutException.class)
.retryLimit(3)
.skip(TimeoutException.class)
.skipLimit(50)
3번 재시도해도 실패하면 해당 아이템을 건너뛴다. 최대 50건까지 건너뛸 수 있다.
retryLimit 미초과"} R -->|예| RETRY["재시도"] RETRY -->|성공| CONTINUE["계속 처리"] RETRY -->|실패| R R -->|아니오| S{"건너뛰기 가능?
skipLimit 미초과"} S -->|예| SKIP["건너뛰고 계속"] S -->|아니오| FAIL["Step 실패"] style RETRY fill:#FFF3E0,stroke:#FF9800 style SKIP fill:#E8F4F8,stroke:#2196F3 style FAIL fill:#FFE6E6,stroke:#F44336 style CONTINUE fill:#E8F8E8,stroke:#4CAF50
RetryTemplate과 BackOff
Spring Batch의 기본 retry는 즉시 재시도한다. 재시도 간격을 두고 싶으면 RetryTemplate에 BackOffPolicy를 설정한다.
@Bean
public Step apiStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager) {
RetryTemplate retryTemplate = RetryTemplate.builder()
.maxAttempts(3)
.exponentialBackoff(1000, 2.0, 10000) // 1초 → 2초 → 4초
.retryOn(TimeoutException.class)
.build();
return new StepBuilder("apiStep", jobRepository)
.<ExternalData, ProcessedData>chunk(100, transactionManager)
.reader(externalReader())
.processor(apiProcessor())
.writer(resultWriter())
.faultTolerant()
.retryPolicy(retryTemplate.getRetryPolicy())
.backOffPolicy(retryTemplate.getBackOffPolicy())
.build();
}
exponentialBackoff(1000, 2.0, 10000)은 1초부터 시작해서 2배씩 늘어나며, 최대 10초까지 대기한다. 외부 API가 일시적으로 과부하 상태일 때, 즉시 재시도보다 대기 후 재시도가 성공 확률이 높다.
Listener — 이벤트 감지
배치 실행의 각 단계에서 이벤트를 감지하고 추가 로직을 실행할 수 있다. 로깅, 알림, 통계 수집에 사용한다.
StepExecutionListener
Step 시작/종료 시 호출된다.
public class LoggingStepListener implements StepExecutionListener {
@Override
public void beforeStep(StepExecution stepExecution) {
log.info("Step 시작: {}", stepExecution.getStepName());
}
@Override
public ExitStatus afterStep(StepExecution stepExecution) {
log.info("Step 완료: {} — 읽기: {}, 쓰기: {}, 스킵: {}",
stepExecution.getStepName(),
stepExecution.getReadCount(),
stepExecution.getWriteCount(),
stepExecution.getSkipCount());
return stepExecution.getExitStatus();
}
}
SkipListener
Skip이 발생할 때마다 호출된다. 건너뛴 데이터를 별도로 기록하는 데 유용하다.
public class SkipLoggingListener implements SkipListener<Order, Settlement> {
@Override
public void onSkipInProcess(Order item, Throwable t) {
log.warn("Skip 발생 — orderId: {}, 원인: {}", item.getId(), t.getMessage());
// 별도 테이블에 기록하거나 Slack 알림 발송
}
}
// Step에 Listener 등록
.listener(new LoggingStepListener())
.listener(new SkipLoggingListener())
어노테이션 기반 Listener
인터페이스 구현 대신 어노테이션으로도 정의할 수 있다.
public class JobNotificationListener {
@BeforeJob
public void beforeJob(JobExecution jobExecution) {
log.info("Job 시작: {}", jobExecution.getJobInstance().getJobName());
}
@AfterJob
public void afterJob(JobExecution jobExecution) {
if (jobExecution.getStatus() == BatchStatus.COMPLETED) {
log.info("Job 성공");
} else {
log.error("Job 실패: {}", jobExecution.getExitStatus());
// Slack 알림 발송
}
}
}
onSkipInProcess
onSkipInWrite"] -.->|"Skip 발생 시"| PROCESS style BJ fill:#E8F4F8,stroke:#2196F3 style AJ fill:#E8F4F8,stroke:#2196F3 style PROCESS fill:#E8F8E8,stroke:#4CAF50 style SKIP fill:#FFE6E6,stroke:#F44336
자주 하는 실수
skipLimit(Integer.MAX_VALUE)로 설정하면 모든 데이터를 건너뛸 수 있다. 극단적인 경우 10만 건을 모두 스킵하고 0건을 처리한 뒤 "성공"으로 기록된다. skipLimit은 전체 데이터의 합리적인 비율로 설정하라. 보통 전체 건수의 1~5% 이내가 적절하다.
[!DANGER] 재시도 가능한 예외와 그렇지 않은 예외를 구분하지 않기
NullPointerException을 재시도해봐야 3번 다 같은 결과다. 일시적 오류(네트워크 타임아웃, DB 락 충돌)만 재시도하고, 영구적 오류(데이터 포맷, NPE)는 건너뛰기로 처리하라.
[!DANGER] Skip된 데이터를 추적하지 않기
Skip된 데이터는 처리되지 않은 채 사라진다. SkipListener로 건너뛴 데이터를 별도 테이블에 기록하고, 나중에 수동으로 보정할 수 있는 체계를 갖춰야 한다. "Skip했으니 끝"이 아니라 "Skip한 이유와 데이터를 남겨서 후속 조치가 가능하게" 해야 한다.