Spring Batch 입문 15편. Retry Logic — 일시적 실패 자동 재시도. faultTolerant·retryLimit·retry·noRetry·RetryPolicy·BackOffPolicy·RetryListener + Skip 과의 정확한 차이·v6 의 Spring Retry 제거까지 풀어쓴 학습 노트.
이 글은 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— 일정 범위 안 randomNoBackOffPolicy— 즉시 재시도 (기본)
권장
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 을 retrynoRetry(ExceptionClass)= 명시 차단- Retry 단위 = chunk (record 1건 실패 → chunk 전체 재시도)
- BackOffPolicy —
FixedBackOff·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.retry→org.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 에서 원문을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 10편 — Advanced Metadata (JobExplorer · JobRegistry · 운영 대시보드)
- 11편 — Step 종합 + Chunk-oriented vs TaskletStep
- 12편 — Chunk Step 설정 + Commit Interval 튜닝
- 13편 — Step Restart + 부모 Step 상속
- 14편 — Skip Logic (부분 실패 무시 · SkipPolicy · SkipListener)
다음 글: