Spring Retry란?
분산 시스템에서 일시적 장애(네트워크 타임아웃, DB 커넥션 풀 소진, 외부 API 503)는 불가피합니다. Spring Retry는 실패한 작업을 자동으로 재시도하는 프레임워크로, @Retryable 어노테이션 하나로 선언적 재시도 로직을 구현합니다. 이 글에서는 Spring Retry의 핵심 어노테이션, 백오프 전략, 상태 기반 재시도, RetryTemplate 프로그래밍 방식, Circuit Breaker 연동까지 심화 패턴을 다룹니다.
기본 설정
// build.gradle
dependencies {
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'
}
// 활성화
@Configuration
@EnableRetry
public class RetryConfig {
}
@Retryable 핵심 속성
@Retryable의 속성을 정밀하게 설정해야 의도한 대로 재시도가 동작합니다.
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentGatewayClient gatewayClient;
// 기본 재시도: 최대 3회, 1초 간격
@Retryable(
retryFor = {PaymentTimeoutException.class, ConnectionException.class},
noRetryFor = {PaymentRejectedException.class}, // 재시도하지 않을 예외
maxAttempts = 3,
backoff = @Backoff(delay = 1000)
)
public PaymentResult processPayment(PaymentRequest request) {
return gatewayClient.charge(request);
}
// 모든 재시도 실패 후 호출되는 폴백
@Recover
public PaymentResult recoverPayment(
PaymentTimeoutException e, // 마지막 예외
PaymentRequest request // 원본 파라미터
) {
log.warn("결제 재시도 모두 실패: {}", request.getOrderId(), e);
// 대기열에 넣거나 수동 처리 요청
return PaymentResult.pending(request.getOrderId());
}
}
| 속성 | 설명 | 기본값 |
|---|---|---|
retryFor |
재시도할 예외 타입 | 모든 Exception |
noRetryFor |
재시도하지 않을 예외 | 없음 |
maxAttempts |
최대 시도 횟수 (첫 시도 포함) | 3 |
backoff.delay |
재시도 간격 (ms) | 1000 |
backoff.multiplier |
간격 증가 배수 | 0 (고정) |
backoff.maxDelay |
최대 대기 시간 | 0 (무제한) |
backoff.random |
지터(Jitter) 적용 | false |
백오프 전략 비교
재시도 간격 전략은 시스템 부하와 직결됩니다. 지수 백오프 + 지터 조합이 가장 권장됩니다.
// 1. 고정 간격: 항상 2초 대기
@Retryable(
maxAttempts = 5,
backoff = @Backoff(delay = 2000)
)
public String fixedBackoff() { ... }
// 시도: 0s → 2s → 2s → 2s → 2s
// 2. 지수 백오프: 간격이 점점 증가
@Retryable(
maxAttempts = 5,
backoff = @Backoff(delay = 1000, multiplier = 2.0, maxDelay = 30000)
)
public String exponentialBackoff() { ... }
// 시도: 0s → 1s → 2s → 4s → 8s (최대 30s)
// 3. 지수 백오프 + 지터: Thundering Herd 방지
@Retryable(
maxAttempts = 5,
backoff = @Backoff(delay = 1000, multiplier = 2.0, maxDelay = 30000, random = true)
)
public String exponentialWithJitter() { ... }
// 시도: 0s → 0.5~1s → 1~2s → 2~4s → 4~8s (랜덤 범위)
// 4. 커스텀 백오프 정책 (RetryTemplate에서)
ExponentialRandomBackOffPolicy backOff = new ExponentialRandomBackOffPolicy();
backOff.setInitialInterval(500);
backOff.setMultiplier(1.5);
backOff.setMaxInterval(10000);
@Recover 폴백 규칙
@Recover 메서드는 엄격한 시그니처 규칙을 따라야 합니다. 이를 어기면 폴백이 호출되지 않습니다.
@Service
public class NotificationService {
@Retryable(retryFor = SmtpException.class, maxAttempts = 3)
public boolean sendEmail(String to, String subject, String body) {
// 이메일 발송 시도
return smtpClient.send(to, subject, body);
}
// ✅ 올바른 @Recover: 반환 타입 일치 + 첫 파라미터가 예외 + 나머지 원본 파라미터
@Recover
public boolean recoverSendEmail(SmtpException e, String to, String subject, String body) {
log.error("이메일 발송 실패 [to={}]: {}", to, e.getMessage());
// 대체 채널로 알림
slackNotifier.send("이메일 발송 실패: " + to);
return false;
}
// ❌ 잘못된 예: 반환 타입 불일치 → 호출 안 됨
// @Recover
// public void recoverSendEmail(SmtpException e, String to) { ... }
// ❌ 잘못된 예: 파라미터 순서 불일치 → 호출 안 됨
// @Recover
// public boolean recoverSendEmail(String to, SmtpException e) { ... }
// 예외 타입별 다른 폴백
@Recover
public boolean recoverFromTimeout(ConnectionTimeoutException e, String to, String subject, String body) {
return fallbackSmtpClient.send(to, subject, body);
}
}
RetryTemplate 프로그래밍 방식
어노테이션보다 세밀한 제어가 필요할 때 RetryTemplate을 직접 사용합니다. Spring WebClient와 결합하면 HTTP 호출 재시도를 유연하게 구성할 수 있습니다.
@Configuration
public class RetryTemplateConfig {
@Bean
public RetryTemplate retryTemplate() {
// 재시도 정책: 특정 예외만 재시도
Map<Class<? extends Throwable>, Boolean> retryableExceptions = Map.of(
RestClientException.class, true,
TimeoutException.class, true,
HttpServerErrorException.class, true
);
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(4, retryableExceptions);
// 백오프 정책: 지수 + 지터
ExponentialRandomBackOffPolicy backOffPolicy = new ExponentialRandomBackOffPolicy();
backOffPolicy.setInitialInterval(500);
backOffPolicy.setMultiplier(2.0);
backOffPolicy.setMaxInterval(15000);
RetryTemplate template = new RetryTemplate();
template.setRetryPolicy(retryPolicy);
template.setBackOffPolicy(backOffPolicy);
// 리스너: 재시도 모니터링
template.registerListener(new RetryListenerSupport() {
@Override
public <T, E extends Throwable> void onError(
RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
log.warn("재시도 #{}: {}",
context.getRetryCount(), throwable.getMessage());
}
});
return template;
}
}
// 서비스에서 사용
@Service
@RequiredArgsConstructor
public class ExternalApiService {
private final RetryTemplate retryTemplate;
private final RestClient restClient;
public ApiResponse callExternalApi(ApiRequest request) {
return retryTemplate.execute(
// 재시도 콜백
context -> {
log.info("API 호출 시도 #{}", context.getRetryCount() + 1);
return restClient.post()
.uri("/api/process")
.body(request)
.retrieve()
.body(ApiResponse.class);
},
// 모든 재시도 실패 시 폴백
context -> {
log.error("API 호출 모두 실패 ({}회)", context.getRetryCount());
return ApiResponse.fallback();
}
);
}
}
조건부 재시도: RetryPolicy 커스터마이징
HTTP 상태 코드나 응답 내용에 따라 재시도 여부를 결정해야 할 때 커스텀 RetryPolicy를 구현합니다.
// HTTP 상태 코드 기반 재시도 정책
public class HttpStatusRetryPolicy implements RetryPolicy {
private static final Set<Integer> RETRYABLE_STATUSES =
Set.of(408, 429, 500, 502, 503, 504);
private static final int MAX_ATTEMPTS = 4;
@Override
public boolean canRetry(RetryContext context) {
if (context.getRetryCount() >= MAX_ATTEMPTS) return false;
Throwable lastException = context.getLastThrowable();
if (lastException instanceof HttpStatusCodeException httpEx) {
return RETRYABLE_STATUSES.contains(httpEx.getStatusCode().value());
}
return lastException instanceof ResourceAccessException; // 네트워크 오류
}
@Override
public RetryContext open(RetryContext parent) {
return new RetryContextSupport(parent);
}
@Override
public void close(RetryContext context) {}
@Override
public void registerThrowable(RetryContext context, Throwable throwable) {
((RetryContextSupport) context).registerThrowable(throwable);
}
}
// 429 Too Many Requests: Retry-After 헤더 존경
public class RateLimitAwareBackOffPolicy implements BackOffPolicy {
@Override
public BackOffContext start(RetryContext context) {
return new BackOffContext() {};
}
@Override
public void backOff(BackOffContext context) throws BackOffInterruptedException {
RetryContext retryContext = RetrySynchronizationManager.getContext();
Throwable lastEx = retryContext.getLastThrowable();
long waitMs = 1000; // 기본 1초
if (lastEx instanceof HttpClientErrorException.TooManyRequests tmr) {
String retryAfter = tmr.getResponseHeaders().getFirst("Retry-After");
if (retryAfter != null) {
waitMs = Long.parseLong(retryAfter) * 1000;
}
}
try {
Thread.sleep(Math.min(waitMs, 60000));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BackOffInterruptedException("Interrupted", e);
}
}
}
재시도와 트랜잭션 주의사항
@Retryable과 @Transactional을 함께 쓸 때는 프록시 순서에 주의해야 합니다. Spring Transaction 전파 전략을 이해한 상태에서 설계해야 합니다.
// ❌ 잘못된 패턴: @Retryable이 @Transactional 안에 있으면
// 트랜잭션이 이미 롤백 마크되어 재시도가 무의미
@Service
public class OrderService {
// ❌ 같은 메서드에 둘 다 붙이면 문제
@Retryable(retryFor = OptimisticLockException.class)
@Transactional
public void placeOrder(OrderRequest request) {
// OptimisticLockException 발생 시
// 트랜잭션이 이미 롤백 마크 → 재시도해도 실패
}
}
// ✅ 올바른 패턴: Retry를 트랜잭션 바깥에 배치
@Service
@RequiredArgsConstructor
public class OrderFacade {
private final OrderService orderService;
@Retryable(retryFor = OptimisticLockException.class, maxAttempts = 3)
public void placeOrder(OrderRequest request) {
orderService.placeOrderInternal(request); // 별도 빈 호출
}
@Recover
public void recoverPlaceOrder(OptimisticLockException e, OrderRequest request) {
log.error("주문 처리 실패 (동시성 충돌): {}", request.getOrderId());
throw new OrderConflictException(request.getOrderId());
}
}
@Service
public class OrderService {
@Transactional // 트랜잭션은 여기서만
public void placeOrderInternal(OrderRequest request) {
// 비즈니스 로직
}
}
재시도 메트릭 모니터링
Micrometer로 재시도 횟수와 성공/실패를 모니터링합니다.
@Component
@RequiredArgsConstructor
public class RetryMetricsListener extends RetryListenerSupport {
private final MeterRegistry meterRegistry;
@Override
public <T, E extends Throwable> void onError(
RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
meterRegistry.counter("retry.attempts",
"method", context.getAttribute("context.name").toString(),
"exception", throwable.getClass().getSimpleName()
).increment();
}
@Override
public <T, E extends Throwable> void close(
RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {
String status = throwable == null ? "success" : "exhausted";
meterRegistry.counter("retry.outcome",
"method", context.getAttribute("context.name").toString(),
"status", status,
"attempts", String.valueOf(context.getRetryCount())
).increment();
}
}
정리
Spring Retry는 일시적 장애를 우아하게 처리하는 핵심 인프라입니다. @Retryable로 선언적 재시도를, RetryTemplate로 프로그래밍 방식 재시도를 구현합니다. 지수 백오프 + 지터로 Thundering Herd를 방지하고, @Recover로 최종 폴백을 처리하세요. 가장 중요한 것은 @Transactional과의 프록시 순서입니다. Retry는 반드시 트랜잭션 바깥에 배치하여 매 시도마다 새 트랜잭션이 열리도록 설계해야 합니다.