Spring Batch 입문 39편 — Retry · Spring Framework 7 Core Retry (v6 변경)

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

Spring Batch 입문 39편. Spring Batch 6 의 큰 변화 — Spring Retry 라이브러리 제거 + Spring Framework 7 core retry 사용. @Retryable · RetryTemplate · BackOff · ExponentialBackoff · 멱등성 원칙 · 15편 Step retry 와의 차이까지 정리한 학습 노트.

📚 Spring Batch 입문에서 운영까지 · 39편 — Retry · Spring Framework 7 Core Retry (v6 변경)

이 글은 Spring Batch 입문에서 운영까지 시리즈 48편 중 39편이에요. 38편반복 추상화 다음 자리 — 재시도 (Retry) 추상화. Spring Batch 6 의 큰 변경 이 담긴 자리.

Retry 가 필요한 이유

transient (일시적) 실패는 재시도 로 해결 가능. — 공식 reference

transient (잠깐 났다 사라지는) 실패는 자주 만나는 부류라 한 번에 fail 처리하면 손해예요. 대표적으로는 원격 API 의 일시 network glitch, DB 의 DeadlockLoserDataAccessException, 외부 시스템에서 잠깐 떨어지는 순간 503/504, 그리고 서버는 정상인데 응답이 느린 timeout 같은 경우가 있어요. 이런 자리는 즉시 fail 보다 몇 번 재시도한 뒤 fail 하는 쪽이 훨씬 안전합니다.

Spring Batch 6 의 큰 변화

As of v6.0, Spring Batch does not use Spring Retry to automate retry operations within the framework, and is now based on the core retry feature provided by Spring Framework 7.0. — 공식 reference

핵심 변경:

  • Before (Spring Batch 5.x) — 별도 라이브러리 spring-retry 의존
  • After (Spring Batch 6.0+) — Spring Framework 7 의 core retry 내장 사용

이 변경은 4편 whatsnew-v6 에서 한 번 봤어요. 운영 마이그레이션을 한다면 기존 @Retryable annotation (어노테이션, 메타데이터 표식) 과 new core retry 가 공존 가능한지 반드시 확인해야 합니다.

Spring Framework 7 Core Retry

@Retryable annotation

@Retryable(
    retryFor = TransientException.class,
    maxAttempts = 5,
    backoff = @Backoff(delay = 1000, multiplier = 2.0)
)
public Result callExternalApi(Request req) {
    return api.call(req);
}

핵심 attribute:

Attribute 의미
retryFor 재시도 대상 예외 type
noRetryFor 제외할 예외 type
maxAttempts 최대 시도 횟수 (첫 시도 + 재시도)
backoff backoff 정책
recover recovery 메서드 이름

@Backoff — 재시도 간 대기

@Backoff(
    delay = 1000,          // 첫 backoff 1초
    multiplier = 2.0,      // 매번 2배 (exponential)
    maxDelay = 60_000,     // 최대 60초
    random = true          // jitter
)

backoff (재시도 사이 대기 간격) 는 그냥 고정값으로 두는 게 아니라 매번 두 배씩 늘려가는 exponential 방식에 약간의 random 흔들림 (jitter) 을 더하는 게 재시도 표준 패턴이에요. 흐름은 이렇게 흘러갑니다.

  • 시도 1 = 즉시
  • 실패 → 1초 대기 → 시도 2
  • 실패 → 2초 대기 → 시도 3
  • 실패 → 4초 대기 → 시도 4
  • 실패 → 8초 대기 → 시도 5

random jitter 를 섞는 이유는 동시에 떨어진 클라이언트들이 같은 타이밍에 한꺼번에 재시도를 보내는 thundering herd (같은 시점에 부하가 몰리는 현상) 를 피하기 위해서예요.

@Recover — Recovery 메서드

@Retryable(retryFor = TransientException.class, maxAttempts = 5)
public Result callExternalApi(Request req) {
    return api.call(req);
}

@Recover
public Result recover(TransientException e, Request req) {
    log.warn("All retries failed, fallback", e);
    return Result.fallback();
}

maxAttempts 까지 다 써버린 뒤 호출되는 메서드가 @Recover 예요. 마지막 fallback (대안 응답) 을 두는 자리죠. 매개변수는 첫 인자가 예외 type 이고 나머지는 원본 메서드의 매개변수와 똑같이 맞춰주면 됩니다.

RetryTemplate — Programmatic

annotation 으로 못 풀고 코드로 직접 제어하고 싶을 때 쓰는 게 RetryTemplate (programmatic = 코드 기반) 이에요.

RetryTemplate template = new RetryTemplate();

ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy();
backOff.setInitialInterval(1000);
backOff.setMultiplier(2.0);
backOff.setMaxInterval(60_000);
template.setBackOffPolicy(backOff);

SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(5,
    Map.of(TransientException.class, true));
template.setRetryPolicy(retryPolicy);

Result result = template.execute(context -> {
    return api.call(req);
}, context -> {
    return Result.fallback();
});

execute(callback, recovery) 한 줄로 retry 와 recovery 가 같이 묶여요. 조건부로 retry policy 를 바꾼다거나 backoff 를 동적으로 결정해야 하는 자리처럼 명시적 control 이 필요할 때 이쪽이 잘 맞습니다.

RetryPolicy — 재시도 결정

Policy 동작
SimpleRetryPolicy(N) 최대 N 번 시도
AlwaysRetryPolicy 무한 재시도 (위험)
NeverRetryPolicy 재시도 없음
TimeoutRetryPolicy(ms) 시간 한도
CompositeRetryPolicy 여러 policy AND/OR
ExceptionClassifierRetryPolicy 예외 type 별 다른 policy

ExceptionClassifierRetryPolicy 예제

Map<Class<? extends Throwable>, RetryPolicy> classifier = Map.of(
    TransientException.class, new SimpleRetryPolicy(5),
    PermanentException.class, new NeverRetryPolicy()
);

ExceptionClassifierRetryPolicy policy = new ExceptionClassifierRetryPolicy();
policy.setPolicyMap(classifier);

예외 type 별로 다른 정책을 매핑해두면 transient 는 재시도하고 permanent 는 곧장 fail 시킬 수 있어요.

BackOffPolicy — 대기 전략

Policy 동작
NoBackOffPolicy 즉시 재시도 (DB busy 권장 X)
FixedBackOffPolicy(ms) 고정 간격
ExponentialBackOffPolicy 지수 증가
UniformRandomBackOffPolicy 균등 random 범위
ExponentialRandomBackOffPolicy exponential + jitter

운영에서는 jitter 까지 포함된 ExponentialRandomBackOffPolicy 를 권장합니다.

가장 중요한 원칙 — Idempotency

여기서 시험에 자주 나오는 함정이 하나 있어요. idempotency (멱등성, 같은 호출을 여러 번 해도 결과가 동일한 성질) 는 retry 와 떼어서 생각할 수가 없거든요.

@Retryable(maxAttempts = 5)
public Order createOrder(OrderRequest req) {
    return orderService.create(req);     // 매 retry 마다 새 Order 생성!
}

위처럼 짜두면 서버가 commit 까지는 성공했는데 응답만 못 받은 케이스에서 재시도가 일어나면서 Order 가 중복으로 만들어집니다. 이걸 막는 길이 세 갈래 있어요.

해결 1: Idempotency Key

@Retryable(maxAttempts = 5)
public Order createOrder(OrderRequest req) {
    return orderService.create(req.getIdempotencyKey(), req);
}

같은 idempotency key 로 호출이 들어오면 최초 한 번만 처리하고 나머지는 기존 결과를 그대로 돌려주는 방식이에요. 서버 측에서 dedup (중복 제거) 을 책임집니다.

해결 2: UPSERT

@Retryable(maxAttempts = 5)
public void updateInventory(Product p, int delta) {
    jdbc.update("""
        INSERT INTO inventory (product_id, quantity)
        VALUES (?, ?)
        ON CONFLICT (product_id) DO UPDATE SET quantity = inventory.quantity + ?
        """, p.getId(), delta, delta);    // ❌ 이건 idempotent X — delta 누적
}

// idempotent 한 final 값:
@Retryable(maxAttempts = 5)
public void setInventory(Product p, int targetQuantity) {
    jdbc.update("""
        INSERT INTO inventory (product_id, quantity) VALUES (?, ?)
        ON CONFLICT (product_id) DO UPDATE SET quantity = EXCLUDED.quantity
        """, p.getId(), targetQuantity);
}

UPSERT (INSERT 와 UPDATE 를 하나로 묶은 SQL) 를 쓰더라도 delta 를 더하는 식이면 재시도마다 값이 누적돼 idempotent 가 깨져요. 반면 최종 값을 그대로 설정하는 식이면 몇 번을 호출해도 결과가 같아 안전합니다.

해결 3: 멱등 메서드 설계

read 는 자연스럽게 멱등이라 손댈 게 없지만, write 는 idempotency key 를 쓰거나 최종 상태를 설정하는 방식, 또는 check-then-act (먼저 상태를 확인하고 행위) 패턴 중 하나를 선택해야 합니다. 외부 시스템을 호출할 때는 idempotency token (호출자가 부여하는 중복 식별자) 을 같이 넘겨주는 게 표준이에요.

15편 Step Retry vs 전역 Retry

항목 Step Retry (15편) 전역 Retry (39편)
적용 위치 Step 의 chunk 처리 임의 메서드
대상 ItemReader/Processor/Writer 모든 Spring Bean 메서드
설정 StepBuilder.faultTolerant().retry() @Retryable 또는 RetryTemplate
영향 chunk transaction rollback + retry 메서드 호출 retry
사용 case 데이터 처리 transient 실패 외부 API · DB 호출
Spring Batch 의존성 Spring Framework 7

둘은 충돌하는 도구가 아니라 layer 가 달라요. 외부 API 호출은 @Retryable 로 메서드 단위에서 잡고, 데이터 처리에서 생기는 실패는 Step retry 로 chunk 단위에서 잡으면 됩니다.

ItemProcessor 안 retry 예제

@Component
public class ApiEnrichmentProcessor implements ItemProcessor<Customer, EnrichedCustomer> {

    @Autowired
    private CustomerApi api;

    @Retryable(
        retryFor = TransientException.class,
        maxAttempts = 5,
        backoff = @Backoff(delay = 1000, multiplier = 2.0, random = true)
    )
    @Override
    public EnrichedCustomer process(Customer customer) {
        CustomerDetails details = api.fetch(customer.getId());
        return new EnrichedCustomer(customer, details);
    }

    @Recover
    public EnrichedCustomer recover(TransientException e, Customer customer) {
        log.warn("API retry exhausted for {}", customer.getId(), e);
        return new EnrichedCustomer(customer, CustomerDetails.empty());
    }
}

Processor 메서드 자체에 retry 를 걸어두면 chunk transaction 을 rollback 하지 않고도 메서드 내부에서 다시 시도할 수 있어요. 회복은 빠르고 chunk 는 보존됩니다.

Step retry 와 결합

@Bean
public Step enrichStep(JobRepository repo, PlatformTransactionManager tx) {
    return new StepBuilder("enrichStep", repo)
        .<Customer, EnrichedCustomer>chunk(100, tx)
        .reader(customerReader())
        .processor(apiEnrichmentProcessor())   // 메서드 retry
        .writer(enrichedWriter())
        .faultTolerant()
        .retry(DeadlockLoserDataAccessException.class)    // chunk retry
        .retryLimit(3)
        .build();
}

retry 가 두 layer 로 쌓여 있는 구조예요. 안쪽 @Retryable 은 API 호출만 골라서 다시 부르고, 바깥쪽 .retry() 는 DB deadlock 이 났을 때 chunk 전체를 다시 돌립니다. 서로 다른 transient 원인을 각자 맡는 셈이에요.

자주 만나는 사고

사고 1: 멱등성 미보장

retry 가 일어나면서 같은 처리가 중복으로 들어가는 경우예요. idempotency key, UPSERT, 최종 상태 설정 중 상황에 맞는 걸 골라 막으면 됩니다.

사고 2: @Retryable 안 비-public 메서드

@Retryable 은 AOP (Aspect-Oriented Programming, 횡단 관심사 분리 기법) 로 동작하기 때문에 메서드가 public 이어야 하고 외부에서 호출돼야 해요. 같은 클래스 안에서 자기 메서드를 부르면 proxy (Spring 이 만든 대리 객체) 를 거치지 않아 retry 가 무효가 됩니다. 보통은 별도 클래스로 분리하고, 어쩔 수 없으면 자기 자신을 inject 하지만 안티패턴이라 권장하지 않습니다.

사고 3: 무한 재시도

AlwaysRetryPolicy 를 쓴 자리에서 영구 fail 이 발생하면 끝없이 재시도가 돕니다. SimpleRetryPolicy(N) 이나 TimeoutRetryPolicy 로 상한을 둬야 해요.

사고 4: NoBackOff 의 thundering herd

backoff 없이 즉시 재시도가 돌면 DB 나 서비스에 부하가 한꺼번에 쏠립니다. 최소한 FixedBackOffPolicy 라도 두고, 운영에서는 ExponentialBackOffPolicy 가 안전해요.

사고 5: @Retryable@Transactional 결합 함정

@Transactional 메서드 안에 @Retryable 을 두면 첫 시도가 실패하는 순간 transaction 이 rollback 되어 같은 transaction 안에서는 재시도가 진행되지 못해요. 순서를 뒤집어 @Retryable 을 outer, @Transactional 을 inner 로 두거나, Propagation.REQUIRES_NEW 로 매 시도마다 새 transaction 을 열어줘야 합니다.

사고 6: Recovery 메서드 매개변수 불일치

@Recover 메서드의 매개변수가 원본 메서드와 type 이 어긋나면 매칭이 안 됩니다. 첫 인자는 예외 type, 나머지는 원본 매개변수와 똑같이 맞춰주세요.

사고 7: Step Retry 와 전역 Retry 동시 적용

같은 예외에 두 layer 가 동시에 걸리면 총 retry 횟수가 곱셈으로 불어나요. 한 layer 만 retry 를 맡기거나, 서로 다른 예외 type 으로 책임을 갈라줘야 합니다.

운영 권장 패턴

Pattern 1: 표준 API 호출 retry

@Service
public class PaymentService {

    @Retryable(
        retryFor = { ConnectException.class, SocketTimeoutException.class, HttpServerErrorException.class },
        noRetryFor = HttpClientErrorException.class,    // 4xx 는 client 잘못 → fail-fast
        maxAttempts = 5,
        backoff = @Backoff(delay = 500, multiplier = 2.0, maxDelay = 30_000, random = true)
    )
    public PaymentResult charge(PaymentRequest req) {
        return paymentApi.charge(req.getIdempotencyKey(), req);
    }

    @Recover
    public PaymentResult recover(Exception e, PaymentRequest req) {
        return PaymentResult.failed(e.getMessage());
    }
}

5xx 는 retry, 4xx 는 fail-fast (즉시 실패 처리) 로 가르고, idempotency key 와 backoff with jitter, 그리고 recovery fallback 까지 갖춘 표준 형태예요.

Pattern 2: DB Deadlock retry (Step retry)

@Bean
public Step writeStep(JobRepository repo, PlatformTransactionManager tx) {
    return new StepBuilder("writeStep", repo)
        .<Order, Order>chunk(500, tx)
        .reader(orderReader())
        .writer(orderWriter())
        .faultTolerant()
        .retry(DeadlockLoserDataAccessException.class)
        .retryLimit(3)
        .build();
}

DB deadlock 한 종류만 골라 chunk 전체를 다시 돌리는 형태로, 15편에서 익힌 그 적용 그대로예요.

Pattern 3: Programmatic RetryTemplate

@Component
public class ResilientCaller {

    private final RetryTemplate template;

    public ResilientCaller() {
        this.template = new RetryTemplate();

        ExponentialRandomBackOffPolicy backOff = new ExponentialRandomBackOffPolicy();
        backOff.setInitialInterval(500);
        backOff.setMultiplier(2.0);
        backOff.setMaxInterval(30_000);
        template.setBackOffPolicy(backOff);

        SimpleRetryPolicy retry = new SimpleRetryPolicy(5,
            Map.of(TransientException.class, true));
        template.setRetryPolicy(retry);
    }

    public <T> T call(Supplier<T> supplier) {
        return template.execute(ctx -> supplier.get());
    }
}

policy 를 동적으로 갈아 끼워야 하는 자리처럼 명시적 control 이 필요한 case 에서 잘 어울려요.

Pattern 4: Exception classifier

Map<Class<? extends Throwable>, RetryPolicy> map = Map.of(
    TransientException.class, new SimpleRetryPolicy(5),
    DeadlockException.class, new SimpleRetryPolicy(3),
    BusinessException.class, new NeverRetryPolicy()
);

ExceptionClassifierRetryPolicy policy = new ExceptionClassifierRetryPolicy();
policy.setPolicyMap(map);

template.setRetryPolicy(policy);

예외 type 별로 policy 를 따로 매핑해 세밀한 retry 전략을 짤 수 있어요.

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

  • Spring Batch 6 변경 — Spring Retry 라이브러리 제거 → Spring Framework 7 core retry 사용
  • @Retryable annotation = retryFor·noRetryFor·maxAttempts·backoff·recover
  • @Backoff = delay · multiplier · maxDelay · random
  • Exponential backoff with jitter = 표준 패턴 (thundering herd 회피)
  • @Recover = maxAttempts 도달 후 fallback 메서드
  • Recover 메서드 = 첫 인자 = 예외 type + 나머지 = 원본 매개변수
  • RetryTemplate = programmatic 방식
  • execute(callback, recovery) = retry + recovery
  • RetryPolicy = SimpleRetryPolicy · AlwaysRetryPolicy · NeverRetryPolicy · TimeoutRetryPolicy · CompositeRetryPolicy · ExceptionClassifierRetryPolicy
  • ExceptionClassifierRetryPolicy = 예외 type 별 다른 policy
  • BackOffPolicy = NoBackOff · FixedBackOff · ExponentialBackOff · UniformRandomBackOff · ExponentialRandomBackOff (권장)
  • Idempotency 원칙 — retry 시 중복 위험 → 멱등성 필수
  • 해결 — Idempotency Key · UPSERT · 최종 상태 설정 · check-then-act
  • delta 변경 = idempotent X, 최종 값 설정 = idempotent ✓
  • Step Retry (15편) vs 전역 Retry (39편):
  • Step = chunk 단위 + ItemReader/Processor/Writer
  • 전역 = 메서드 단위 + 모든 Spring Bean
  • 둘 다 사용 가능 (다른 layer)
  • 함정 — 멱등성 미보장 → 중복 처리
  • 함정 — @Retryable 비-public 메서드 / 같은 클래스 내부 호출 → proxy 무효
  • 함정 — 무한 재시도 (AlwaysRetryPolicy)
  • 함정 — NoBackOff 의 thundering herd
  • 함정 — @Retryable + @Transactional 순서 (outer = Retry, inner = Transactional + REQUIRES_NEW)
  • 함정 — Recovery 메서드 매개변수 불일치
  • 함정 — Step Retry + 전역 Retry 동시 적용 → 총 retry 폭증
  • 패턴 — 표준 API retry (4xx fail-fast, 5xx retry, idempotency key)
  • 패턴 — DB Deadlock chunk retry
  • 패턴 — Programmatic RetryTemplate (동적 policy)
  • 패턴 — Exception classifier (type 별 다른 policy)
  • 운영 권장 — transient = retry, permanent = fail-fast 명확 분리
  • 4편 whatsnew-v6 의 큰 변경 — 마이그레이션 시 기존 spring-retry 의존 확인

공식 문서: Retry · Spring Framework 7 Resilience 에서 원문을 확인할 수 있어요.

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

이전 글:

다음 글:

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

답글 남기기

error: Content is protected !!