Spring Retry 재시도 전략

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는 반드시 트랜잭션 바깥에 배치하여 매 시도마다 새 트랜잭션이 열리도록 설계해야 합니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux