Spring Retry란? 왜 재시도가 필요한가
분산 시스템에서 일시적 장애(transient failure)는 피할 수 없다. 네트워크 타임아웃, DB 커넥션 풀 고갈, 외부 API 일시 장애 — 이런 문제는 한 번 더 시도하면 성공하는 경우가 많다. Spring Retry는 이러한 재시도 로직을 선언적으로 처리하는 프레임워크다. @Retryable 어노테이션 하나로 복잡한 재시도 로직을 깔끔하게 분리할 수 있다.
이 글에서는 Spring Retry의 핵심 메커니즘부터 BackOff 전략, Recovery 처리, RetryTemplate 커스터마이징, Resilience4j와의 비교까지 실전 수준으로 다룬다.
의존성 추가와 기본 설정
<!-- Maven -->
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>
Configuration 클래스에 @EnableRetry를 추가하면 AOP 기반 재시도가 활성화된다.
@Configuration
@EnableRetry
public class RetryConfig {
}
@Retryable 선언적 재시도
가장 간단한 사용법. 메서드에 @Retryable을 붙이면 예외 발생 시 자동으로 재시도한다.
@Service
@Slf4j
public class PaymentGatewayService {
@Retryable(
retryFor = {PaymentTimeoutException.class, ConnectionException.class},
noRetryFor = {PaymentRejectedException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2.0, maxDelay = 10000)
)
public PaymentResult processPayment(PaymentRequest request) {
log.info("결제 시도: orderId={}", request.getOrderId());
return gateway.charge(request);
}
@Recover
public PaymentResult recoverPayment(
PaymentTimeoutException ex, PaymentRequest request) {
log.error("결제 최종 실패: orderId={}", request.getOrderId(), ex);
// 폴백: 대체 결제 수단 또는 수동 처리 큐에 등록
return PaymentResult.pendingManualReview(request.getOrderId());
}
}
핵심 속성을 정리하면:
| 속성 | 설명 | 기본값 |
|---|---|---|
retryFor |
재시도할 예외 타입 | 모든 Exception |
noRetryFor |
재시도하지 않을 예외 | 없음 |
maxAttempts |
최대 시도 횟수 (첫 시도 포함) | 3 |
backoff.delay |
재시도 간 대기 시간 (ms) | 1000 |
backoff.multiplier |
지수 백오프 승수 | 0 (고정 간격) |
backoff.maxDelay |
최대 대기 시간 상한 | 0 (제한 없음) |
BackOff 전략 심화
재시도 간격 전략은 시스템 안정성에 직접적인 영향을 미친다. 고정 간격은 thundering herd 문제를 일으킬 수 있으므로, 실무에서는 지수 백오프에 랜덤 지터(jitter)를 추가하는 것이 권장된다.
@Retryable(
retryFor = ExternalApiException.class,
maxAttempts = 5,
backoff = @Backoff(
delay = 500,
multiplier = 2.0,
maxDelay = 30000,
random = true // 지터 추가 — 동시 재시도 분산
)
)
public ApiResponse callExternalApi(String endpoint) {
return restClient.get()
.uri(endpoint)
.retrieve()
.body(ApiResponse.class);
}
random = true는 각 재시도 간격에 0~delay 범위의 랜덤 지터를 추가한다. 수백 개의 클라이언트가 동시에 재시도할 때 부하를 균등하게 분산시킨다.
RetryTemplate: 프로그래밍 방식 제어
어노테이션이 아닌 세밀한 제어가 필요할 때는 RetryTemplate을 직접 구성한다.
@Configuration
public class RetryTemplateConfig {
@Bean
public RetryTemplate retryTemplate() {
// 재시도 정책: 특정 예외만, 최대 4회
Map<Class<? extends Throwable>, Boolean> retryableExceptions = Map.of(
SocketTimeoutException.class, true,
ConnectTimeoutException.class, true,
HttpServerErrorException.class, true
);
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(
4, retryableExceptions, true
);
// 백오프 정책: 지수 + 지터
ExponentialRandomBackOffPolicy backOff = new ExponentialRandomBackOffPolicy();
backOff.setInitialInterval(1000);
backOff.setMultiplier(2.0);
backOff.setMaxInterval(15000);
return RetryTemplate.builder()
.customPolicy(retryPolicy)
.customBackoff(backOff)
.withListener(new RetryListenerSupport() {
@Override
public <T, E extends Throwable> void onError(
RetryContext context, RetryCallback<T, E> callback,
Throwable throwable) {
log.warn("재시도 #{}: {}",
context.getRetryCount(), throwable.getMessage());
}
})
.build();
}
}
사용 시:
@Service
@RequiredArgsConstructor
public class InventoryService {
private final RetryTemplate retryTemplate;
private final InventoryClient client;
public StockInfo checkStock(String sku) {
return retryTemplate.execute(
ctx -> client.getStock(sku), // 재시도 콜백
ctx -> StockInfo.unknown(sku) // Recovery 콜백
);
}
}
@Recover: 최종 실패 처리
모든 재시도가 소진되면 @Recover 메서드가 호출된다. 반드시 같은 클래스에 위치해야 하며, 첫 번째 파라미터는 예외 타입, 나머지는 원본 메서드와 동일한 파라미터를 받는다.
@Service
public class NotificationService {
@Retryable(retryFor = SmtpException.class, maxAttempts = 3)
public void sendEmail(String to, String subject, String body) {
mailSender.send(to, subject, body);
}
@Recover
public void recoverSendEmail(SmtpException ex, String to,
String subject, String body) {
// 폴백 1: 메시지 큐에 적재하여 비동기 재발송
deadLetterQueue.enqueue(new FailedEmail(to, subject, body, ex));
// 폴백 2: 대체 채널(SMS)로 발송
smsService.sendFallback(to, "이메일 발송 실패, SMS로 대체 알림");
}
}
Stateful Retry: 트랜잭션과 함께
기본 재시도는 stateless다. 같은 스레드에서 루프를 돌며 재시도한다. 하지만 트랜잭션 롤백 후 재시도가 필요하면 stateful retry를 사용해야 한다.
@Retryable(
retryFor = OptimisticLockingFailureException.class,
maxAttempts = 3,
stateful = true // 트랜잭션 경계 밖에서 재시도
)
@Transactional
public void updateAccountBalance(Long accountId, BigDecimal amount) {
Account account = accountRepository.findById(accountId)
.orElseThrow();
account.addBalance(amount);
accountRepository.save(account);
}
Stateful retry는 예외를 호출자에게 던지고, 다음 호출 시 재시도 카운트를 기억한다. JPA의 낙관적 잠금(@Version)과 함께 사용할 때 필수적이다.
RetryListener: 모니터링과 메트릭
@Component
@Slf4j
public class MetricsRetryListener implements RetryListener {
private final MeterRegistry meterRegistry;
public MetricsRetryListener(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
@Override
public <T, E extends Throwable> boolean open(
RetryContext context, RetryCallback<T, E> callback) {
return true; // true면 재시도 진행, false면 중단
}
@Override
public <T, E extends Throwable> void onSuccess(
RetryContext context, RetryCallback<T, E> callback, T result) {
if (context.getRetryCount() > 0) {
meterRegistry.counter("retry.success",
"attempts", String.valueOf(context.getRetryCount())
).increment();
}
}
@Override
public <T, E extends Throwable> void onError(
RetryContext context, RetryCallback<T, E> callback,
Throwable throwable) {
meterRegistry.counter("retry.error",
"exception", throwable.getClass().getSimpleName()
).increment();
}
}
Spring Retry vs Resilience4j 비교
| 항목 | Spring Retry | Resilience4j Retry |
|---|---|---|
| 접근 방식 | AOP (@Retryable) | 함수형 데코레이터 |
| Circuit Breaker | 별도 구현 필요 | 내장 지원 |
| Rate Limiter | 미지원 | 내장 지원 |
| Bulkhead | 미지원 | 내장 지원 |
| 리액티브 지원 | 제한적 | Reactor/RxJava 네이티브 |
| 설정 방식 | 어노테이션 중심 | application.yml 중심 |
| 적합한 상황 | 단순 재시도, Spring 생태계 | 복합 장애 대응, 마이크로서비스 |
실무 권장: 단순 재시도만 필요하면 Spring Retry, Circuit Breaker + Rate Limiter + Retry를 조합해야 하면 Resilience4j를 선택한다. 둘을 함께 사용할 수도 있다.
실전 안티패턴과 주의점
| 안티패턴 | 문제점 | 해결책 |
|---|---|---|
| 모든 예외 재시도 | 비즈니스 예외도 재시도하여 부작용 발생 | retryFor로 일시적 장애만 지정 |
| 고정 간격 재시도 | Thundering herd, 서버 과부하 | 지수 백오프 + random jitter |
| 멱등성 미보장 | 재시도로 중복 처리 발생 | idempotency key 도입 |
| @Recover 누락 | 최종 실패 시 예외가 그대로 전파 | 반드시 Recovery 전략 구현 |
| 같은 클래스 내부 호출 | AOP 프록시 우회로 재시도 미작동 | 별도 Bean으로 분리 또는 self-injection |
마무리
Spring Retry는 분산 환경의 일시적 장애를 우아하게 처리하는 핵심 도구다. @Retryable의 선언적 접근과 RetryTemplate의 프로그래밍적 접근을 상황에 맞게 선택하고, 반드시 지수 백오프 + 지터 + 멱등성을 함께 고려해야 한다. Micrometer 메트릭과 연동하면 재시도 현황을 실시간으로 모니터링할 수 있고, Actuator 커스텀 엔드포인트로 운영 중 재시도 정책을 동적으로 조회할 수도 있다.