Spring Batch 입문 15편 — Retry Logic (일시적 실패 자동 재시도)

2026-05-17Spring Batch 입문에서 운영까지

Spring Batch 입문 15편. Retry Logic — 일시적 실패 자동 재시도. faultTolerant·retryLimit·retry·noRetry·RetryPolicy·BackOffPolicy·RetryListener + Skip 과의 정확한 차이·v6 의 Spring Retry 제거까지 풀어쓴 학습 노트.

📚 Spring Batch 입문에서 운영까지 · 15편 — Retry Logic (일시적 실패 자동 재시도)

이 글은 Spring Batch 입문에서 운영까지 시리즈 48편 중 15편이에요. 14편 에서 부분 실패 무시(Skip)를 잡았다면, 이번 15편은 반대로 Retry예요. 일시적 실패는 다시 시도해 보는 쪽.

Skip vs Retry — 정확한 차이

Skip  = "이 record 는 포기, 다음 record 로"
Retry = "이 record 다시 시도, 일시적 문제일 수 있으니"

자주 쓰는 자리

Exception Skip 권장 Retry 권장
FlatFileParseException ✓ (잘못된 데이터) X
ValidationException X
DataIntegrityViolationException X
DeadlockLoserDataAccessException X ✓ (일시적)
OptimisticLockingFailureException X
TransientDataAccessException X
RestClientException (network) X
TimeoutException X

핵심은 이렇게 정리돼요 — 일시적이고 재시도 시 성공할 수 있으면 Retry, 본질적으로 데이터가 잘못된 거면 Skip.

기본 설정

@Bean
public Step retryStep(JobRepository repo, PlatformTransactionManager tx) {
    return new StepBuilder("retryStep", repo)
        .<Input, Output>chunk(100, tx)
        .reader(reader)
        .writer(writer)
        .faultTolerant()                                      // ★ 필수
        .retryLimit(3)                                         // 최대 3번
        .retry(DeadlockLoserDataAccessException.class)         // 어떤 exception
        .retry(TransientDataAccessException.class)
        .build();
}

faultTolerant() — 필수 (14편과 동일)

retryLimit(N) — 최대 재시도 횟수

원래 시도 1번 + retry 최대 N-1번 = 총 N번 실행

retryLimit(3) 은 원래 시도 1번 + 추가 2번을 더해 총 3번 돌린다는 뜻이에요. 3번째에도 실패하면 Step FAILED.

retry(ExceptionClass) — 어떤 exception 을 retry

.retry(DeadlockLoserDataAccessException.class)
.retry(OptimisticLockingFailureException.class)
.retry(RestClientException.class)

Skip 과 동일한 패턴이에요. 여러 번 호출하면 여러 exception 을 등록하는 셈.

Retry 의 동작

chunk 처리 중...
  Reader.read() x N
  Processor·Writer 처리
  ↓
  WriterException 발생
  ↓
  Retry 1회: chunk 전체 다시 (Reader 부터)
    ↓ 또 실패
  Retry 2회: 또 다시
    ↓ 또 실패
  Retry 3회 (마지막): 또 다시
    ↓ 또 실패
  → Step FAILED

여기서 시험 함정이 하나 있어요 — Retry 는 chunk 전체를 다시 돌립니다.

chunk(100)
  - record 50 에서 DeadlockException
  - retry → chunk 의 record 1~100 모두 다시

개별 record 만 retry 하는 item-level retry 도 있지만, Spring Batch 의 기본은 chunk 전체예요.

BackOffPolicy — 재시도 사이 대기

재시도 사이에 얼마나 기다릴지 정하는 정책이에요.

import org.springframework.batch.infrastructure.retry.backoff.*;

@Bean
public Step retryStep(JobRepository repo, PlatformTransactionManager tx) {
    BackOffPolicy backOff = new ExponentialBackOffPolicy();
    ((ExponentialBackOffPolicy) backOff).setInitialInterval(100);    // 100ms
    ((ExponentialBackOffPolicy) backOff).setMultiplier(2.0);          // x2
    ((ExponentialBackOffPolicy) backOff).setMaxInterval(10000);       // 최대 10s

    return new StepBuilder("retryStep", repo)
        .<X, Y>chunk(100, tx)
        .reader(...).writer(...)
        .faultTolerant()
        .retryLimit(5)
        .retry(TransientDataAccessException.class)
        .backOffPolicy(backOff)
        .build();
}

종류

  • FixedBackOffPolicy — 항상 N ms 대기 (예: 1000ms 마다)
  • ExponentialBackOffPolicy — 100·200·400·800·... (점진적 증가)
  • UniformRandomBackOffPolicy — 일정 범위 안 random
  • NoBackOffPolicy — 즉시 재시도 (기본)

권장

ExponentialBackOffPolicy 가 보통은 무난해요. 일시적 문제(network·DB lock·외부 API)에 대부분 들어맞고, 점진적으로 대기를 늘리면서 외부 시스템 부하도 줄여줍니다.

Custom RetryPolicy

public class BusinessRetryPolicy implements RetryPolicy {

    @Override
    public boolean canRetry(RetryContext context) {
        Throwable t = context.getLastThrowable();
        if (t instanceof RestClientException) {
            return context.getRetryCount() < 5;
        }
        if (t instanceof DeadlockLoserDataAccessException) {
            return context.getRetryCount() < 3;
        }
        return false;
    }

    @Override
    public RetryContext open(RetryContext parent) {
        return new SimpleRetryContext(parent);
    }

    @Override
    public void close(RetryContext context) { }

    @Override
    public void registerThrowable(RetryContext context, Throwable t) {
        // 통계 수집
    }
}
.retryPolicy(new BusinessRetryPolicy())

exception 마다 다른 limit 을 두고 싶을 때처럼, 복잡한 retry 조건이 필요할 때 씁니다.

RetryListener

@Component
public class MyRetryListener implements RetryListener {

    @Override
    public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
        log.info("Retry starting");
        return true;     // false 면 retry 중단
    }

    @Override
    public <T, E extends Throwable> void onError(RetryContext context,
                                                  RetryCallback<T, E> callback,
                                                  Throwable t) {
        log.warn("Retry attempt {} failed", context.getRetryCount(), t);
    }

    @Override
    public <T, E extends Throwable> void close(RetryContext context,
                                                RetryCallback<T, E> callback,
                                                Throwable t) {
        log.info("Retry ended after {} attempts", context.getRetryCount());
    }
}
.faultTolerant()
.retryLimit(3)
.retry(...)
.listener(retryListener)

자주 쓰는 자리:

  • 매 retry 시도 로그·메트릭 수집
  • 너무 많은 retry 시 알람
  • 운영 dashboard 의 retry 통계

Retry + Skip 조합

.faultTolerant()
.retryLimit(3)
.retry(TransientDataAccessException.class)
.skipLimit(10)
.skip(FlatFileParseException.class)
.skip(ValidationException.class)

단, 같은 exception 을 retry 와 skip 둘 다 등록할 수는 없어요. Spring Batch 가 충돌로 인식합니다.

흐름은 이런 식으로 흘러갑니다:

exception 발생
  ↓
retry policy 검사
  ↓ retryable + 한계 미달
  → retry (chunk 재시도)
  ↓ retry 끝났는데 또 실패
  → skip policy 검사
  ↓ skippable + 한계 미달
  → skip
  ↓
  → Step FAILED

Retry 가 먼저, 그 다음 Skip 이에요. 둘 다 안 되면 FAILED.

noRetry(ExceptionClass) — 명시적 차단

.retry(Exception.class)                                    // 광범위
.noRetry(CriticalException.class)                          // 단 이건 X
.noRetry(IllegalArgumentException.class)                   // 코드 버그 = retry X

광범위하게 retry 를 걸어둔 다음, 거기서 특정 exception 만 명시로 빼는 식이에요. 14편 noSkip 과 동일 패턴.

v6 의 Spring Retry 제거

4편 What's New 에서 언급했던 변경이에요.

  • v5 까지는 org.springframework.retry 별도 라이브러리
  • v6 부터는 Spring Framework 7 core retry (org.springframework.core.retry.*)

코드 변경:

// v5 (옛)
import org.springframework.retry.RetryPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;

// v6 (새)
import org.springframework.core.retry.RetryPolicy;
// 또는 Spring Batch 의 자체 패키지
import org.springframework.batch.infrastructure.retry.policy.SimpleRetryPolicy;

대부분 API 는 호환돼서, Spring Retry 의존성을 빼고 import 만 손보면 끝납니다.

자주 쓰는 패턴

패턴 1: 외부 API 호출

@Bean
public Step enrichStep(...) {
    BackOffPolicy backOff = new ExponentialBackOffPolicy();
    ((ExponentialBackOffPolicy) backOff).setInitialInterval(500);
    ((ExponentialBackOffPolicy) backOff).setMultiplier(2.0);
    ((ExponentialBackOffPolicy) backOff).setMaxInterval(30000);

    return new StepBuilder("enrichStep", repo)
        .<User, EnrichedUser>chunk(50, tx)
        .reader(userReader)
        .processor(externalApiProcessor)
        .writer(writer)
        .faultTolerant()
        .retryLimit(5)
        .retry(RestClientException.class)
        .retry(SocketTimeoutException.class)
        .backOffPolicy(backOff)
        .build();
}

외부 API 의 일시적 timeout 이나 5xx 응답에 대비하는 자리. Exponential backoff 가 자연스러워요.

패턴 2: DB Deadlock

.faultTolerant()
.retryLimit(3)
.retry(DeadlockLoserDataAccessException.class)
.retry(CannotAcquireLockException.class)
.backOffPolicy(new UniformRandomBackOffPolicy())   // jitter 로 동시성 분산

DB deadlock 은 random backoff 가 잘 맞아요(jitter 는 재시도 시점을 무작위로 흩어주는 기법). 다 같은 시점에 재시도하면 또 deadlock 이 나거든요.

패턴 3: 메시지 큐 (Kafka) 호출

.faultTolerant()
.retryLimit(3)
.retry(KafkaException.class)
.retry(TimeoutException.class)
.backOffPolicy(exponentialBackOff)

시리즈 2 Kafka producer 의 retry 와 같은 사상이에요. 81편 Producer config.

패턴 4: Retry + Skip 결합

.faultTolerant()

// 일시적 = retry
.retryLimit(3)
.retry(TransientDataAccessException.class)
.retry(RestClientException.class)

// 본질적 잘못 = skip
.skipLimit(50)
.skip(FlatFileParseException.class)
.skip(ValidationException.class)

.backOffPolicy(exponentialBackOff)
.listener(retryListener)
.listener(skipListener)

운영 환경에서 가장 흔하게 쓰는, 가장 두꺼운 fault tolerance 구성이에요.

자주 헷갈리는 자리

Retry 의 단위 = chunk

위에서 강조했죠. record 1건이 실패해도 chunk 전체가 retry 돼요. 비용은 크지만 transaction 일관성은 그 덕에 보장됩니다.

Stateful Retry — 같은 record 추적

SimpleRetryPolicy 는 현재 attempt 만 추적해요. 같은 record 가 여러 chunk 에 걸쳐 retry 되는 시나리오라면 별도의 stateful 정책이 필요한데, 대부분 환경에서는 stateless 로 충분합니다.

retryLimit = 총 시도 횟수

retryLimit(3) = 총 3번 시도 (1 원본 + 2 retry)

문서나 코드마다 의미가 살짝 달라서 헷갈리기 쉬워요. Spring Batch 의 builder 기준으로는 총 시도 횟수예요.

Retry 와 Transaction

chunk write → exception
  ↓ chunk rollback
retry → 새 chunk transaction
  ↓ Reader 부터 다시

Retry 는 새 transaction 으로 다시 시작해요(idempotency 는 같은 호출을 여러 번 해도 결과가 같은 성질). 이전 transaction 의 부분 변경은 모두 rollback 됩니다.

한계·실무 함정

1. 너무 많은 retry = 처리 시간 폭증

retryLimit(10) + ExponentialBackOff (10s max) = 한 chunk 가 *수십 초~수 분*

전체 Job 시간을 예측하기 어려워져요. retryLimit 은 3~5 정도가 무난합니다.

2. BackOff 누락 = 즉시 재시도 폭증

backOffPolicy 를 안 박으면 NoBackOffPolicy 가 기본이에요. 연속 실패가 이어지면서 외부 시스템에는 DoS(Denial of Service, 과도한 요청으로 서비스를 마비시키는 공격)와 비슷한 형태가 됩니다.

3. Retry 가 자체 버그 숨김

IllegalArgumentException 이나 NullPointerException 같은 코드 버그를 retry 하면 무의미한 반복이 됩니다. noRetry 로 차단해 두세요.

4. Retry + Skip 동일 exception

같은 class 를 retry()skip() 양쪽에 걸면 exception 충돌이 납니다.

5. 외부 시스템의 idempotency

Retry 는 같은 호출을 N번 하는 거예요. 외부 시스템이 idempotent 하지 않은 경우(예: 결제 API)에는 중복 처리 위험이 있어요. 그땐 다른 패턴이 필요해요 — idempotency key, DLQ(Dead Letter Queue, 처리 실패한 메시지를 별도 보관하는 큐) 같은.

6. Connection pool 고갈

DB deadlock 을 자주 retry 하면 connection pool 점유 시간이 길어지면서 pool 자체가 고갈될 수 있어요. pool size 모니터링이 필요합니다.

시험 직전 한 번 더 — Retry Logic 함정 압축 노트

  • Skip vs Retry — 본질 잘못 = Skip / 일시적 = Retry
  • 자주 retry — DeadlockLoserDataAccessException·OptimisticLockingFailureException·TransientDataAccessException·RestClientException·TimeoutException
  • 자주 skip — FlatFileParseException·ValidationException·DataIntegrityViolation
  • faultTolerant() = skip·retry 활성 gateway (필수)
  • retryLimit(N) = 총 N번 시도 (1 원본 + N-1 retry)
  • retry(ExceptionClass) = 어떤 exception 을 retry
  • noRetry(ExceptionClass) = 명시 차단
  • Retry 단위 = chunk (record 1건 실패 → chunk 전체 재시도)
  • BackOffPolicyFixedBackOff·ExponentialBackOff (권장)·UniformRandomBackOff·NoBackOff
  • 외부 API = ExponentialBackOff 가 자연스러움
  • DB deadlock = UniformRandomBackOff (jitter)
  • Custom RetryPolicy = canRetry 으로 동적·복잡 조건
  • RetryListener = open·onError·close (메트릭·알람)
  • Retry → Skip 흐름 = retry 우선, retry 끝난 후도 실패 = skip 검사
  • 같은 exception 을 retry + skip 둘 다 = 충돌
  • v6 의 Spring Retry 제거 = org.springframework.retryorg.springframework.batch.infrastructure.retry.*
  • 대부분 API 호환, import 만 수정
  • 패턴 — 외부 API (Exponential) · DB Deadlock (UniformRandom) · Kafka · Retry+Skip 결합
  • 함정 — retryLimit 너무 큼 = 시간 폭증
  • 함정 — BackOff 누락 = 즉시 재시도 DoS
  • 함정 — 코드 버그를 retry (NPE 등) = 무의미한 반복 → noRetry 차단
  • 함정 — 같은 exception retry+skip 충돌
  • 함정 — 외부 idempotency X = 중복 처리 위험
  • 함정 — Connection pool 고갈

공식 문서: Configuring Retry Logic 에서 원문을 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!