Spring Batch 입문 39편. Spring Batch 6 의 큰 변화 — Spring Retry 라이브러리 제거 + Spring Framework 7 core retry 사용. @Retryable · RetryTemplate · BackOff · ExponentialBackoff · 멱등성 원칙 · 15편 Step retry 와의 차이까지 정리한 학습 노트.
이 글은 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 사용
@Retryableannotation =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 + recoveryRetryPolicy= 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 에서 원문을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 34편 — Database Reader · Writer · Cursor vs Paging
- 35편 — ItemProcessor · 변환 · 필터 · 검증
- 36편 — Reusing Services · ItemReaderAdapter · Process Indicator
- 37편 — Scaling · Parallel 6가지 전략 종합
- 38편 — Repeat · RepeatTemplate · CompletionPolicy
다음 글: