Spring Batch 가이드 시리즈

01 배치 기초

- 배치 처리란 무엇인가

- Job과 Step 구조

02 Chunk 처리

- Chunk 기반 처리

- 다양한 Reader와 Writer

03 실행 제어

- 실행 제어와 흐름

- 재시도와 건너뛰기 ← 현재 편

04 운영과 면접

- 스케줄링과 운영

- 면접 대비

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이 실패한다.

전체 흐름을 보면 이렇다.

sequenceDiagram autonumber participant R as Reader participant P as Processor participant W as Writer rect rgb(232, 248, 232) Note over R,W: 정상 처리 R->>P: item #1 P->>W: 변환 결과 end rect rgb(255, 230, 230) Note over R,P: Skip 발생 R->>P: item #2 P--xP: DataFormatException! Note right of P: skipCount + 1
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 커넥션 실패 같은 치명적 오류는 즉시 실패시키는 패턴이다.

Skip은 chunk 롤백 후 재처리를 수반한다

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번 모두 실패하면 해당 아이템이 최종 실패 처리된다.

sequenceDiagram autonumber participant P as Processor participant API as 외부 API rect rgb(255, 230, 230) Note over P,API: 1차 시도 — 실패 P->>API: 요청 API--xP: TimeoutException end rect rgb(255, 243, 224) Note over P,API: 2차 시도 — 실패 P->>API: 재시도 API--xP: TimeoutException end rect rgb(232, 248, 232) Note over P,API: 3차 시도 — 성공 P->>API: 재시도 API-->>P: 응답 end

Skip과 Retry 조합

재시도를 다 소진한 후에도 실패하면 건너뛰는 패턴이다. 실무에서 가장 많이 쓰는 조합이다.

.faultTolerant()
.retry(TimeoutException.class)
.retryLimit(3)
.skip(TimeoutException.class)
.skipLimit(50)

3번 재시도해도 실패하면 해당 아이템을 건너뛴다. 최대 50건까지 건너뛸 수 있다.

flowchart TD E["예외 발생"] --> R{"재시도 가능?
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는 즉시 재시도한다. 재시도 간격을 두고 싶으면 RetryTemplateBackOffPolicy를 설정한다.

@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 알림 발송
        }
    }
}
flowchart TD subgraph Listeners["Listener 호출 시점"] direction TB BJ["@BeforeJob"] --> BS["@BeforeStep"] BS --> BC["@BeforeChunk"] BC --> PROCESS["Reader → Processor → Writer"] PROCESS --> AC["@AfterChunk"] AC --> AS["@AfterStep"] AS --> AJ["@AfterJob"] end SKIP["onSkipInRead
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을 무한대로 설정하기

skipLimit(Integer.MAX_VALUE)로 설정하면 모든 데이터를 건너뛸 수 있다. 극단적인 경우 10만 건을 모두 스킵하고 0건을 처리한 뒤 "성공"으로 기록된다. skipLimit은 전체 데이터의 합리적인 비율로 설정하라. 보통 전체 건수의 1~5% 이내가 적절하다.

[!DANGER] 재시도 가능한 예외와 그렇지 않은 예외를 구분하지 않기

NullPointerException을 재시도해봐야 3번 다 같은 결과다. 일시적 오류(네트워크 타임아웃, DB 락 충돌)만 재시도하고, 영구적 오류(데이터 포맷, NPE)는 건너뛰기로 처리하라.

[!DANGER] Skip된 데이터를 추적하지 않기

Skip된 데이터는 처리되지 않은 채 사라진다. SkipListener로 건너뛴 데이터를 별도 테이블에 기록하고, 나중에 수동으로 보정할 수 있는 체계를 갖춰야 한다. "Skip했으니 끝"이 아니라 "Skip한 이유와 데이터를 남겨서 후속 조치가 가능하게" 해야 한다.