본문 바로가기

Programming/Spring

Spring Framework 7 @Retryable 실전 설정: 지수 백오프·지터·재시도 예외 구분

추천캐릭터 2026. 8. 3. 08:00
728x90

Spring Framework 7 @Retryable 실전 설정: 지수 백오프·지터·재시도 예외 구분

메타 설명: Spring Framework 7에 추가된 코어 @Retryable을 Spring Boot 4에서 활성화하고, 외부 API의 일시적 실패만 지수 백오프와 지터로 재시도하는 방법을 코드와 테스트로 설명합니다.

외부 API가 한 번 타임아웃됐다는 이유로 요청을 즉시 실패시키면 복구 가능한 장애까지 사용자 오류가 됩니다. 반대로 모든 예외를 무조건 재시도하면 잘못된 요청을 반복하고, 장애 중인 서버에 트래픽을 더 보내며, 결제 같은 작업을 중복 실행할 수 있습니다.

Spring Framework 7은 공통 재시도 기능을 코어에 포함했습니다. 기존 Spring Retry의 애노테이션과 이름은 같지만 패키지와 설정 방식이 다릅니다. Spring Boot 4·Spring Framework 7 프로젝트라면 현재 코어 API를 기준으로 작성해야 합니다.

먼저 재시도 가능한 실패를 구분한다

실패 기본 판단 이유
연결 타임아웃, 일시적 I/O 오류 재시도 후보 다음 시도에서 복구될 수 있음
HTTP 429 조건부 재시도 Retry-After와 호출 한도 확인 필요
HTTP 502·503·504 재시도 후보 일시적 게이트웨이·서버 장애 가능
HTTP 400·401·403 보통 재시도 금지 요청이나 인증을 바꾸지 않으면 같은 결과
DB 유니크 제약 위반 재시도 금지 같은 입력으로 성공하지 않음
결제 POST 멱등성 확보 후 판단 중복 승인 위험

재시도 횟수보다 먼저 정할 것은 “어떤 실패를 다시 실행해도 안전한가”입니다.

Spring Framework 7 재시도 활성화

코어 애노테이션 처리를 켜려면 설정 클래스에 @EnableResilientMethods를 붙입니다.

import org.springframework.context.annotation.Configuration;
import org.springframework.resilience.annotation.EnableResilientMethods;

@Configuration
@EnableResilientMethods
class ResilienceConfig {
}

재시도 대상은 반드시 Spring Bean이어야 하며 프록시를 거쳐 호출되어야 합니다. 같은 클래스 안에서 this.call()로 호출하면 우회될 수 있으므로 외부 연동을 별도 Bean으로 분리합니다.

지수 백오프와 지터 적용

다음 예제는 일시적 배송사 API 오류만 재시도합니다.

import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Component;

@Component
class DeliveryClient {

    private final DeliveryGateway gateway;

    DeliveryClient(DeliveryGateway gateway) {
        this.gateway = gateway;
    }

    @Retryable(
            includes = TemporaryDeliveryException.class,
            maxRetries = 3,
            delay = 200,
            multiplier = 2,
            jitter = 50,
            maxDelay = 2_000,
            timeout = 5_000)
    DeliveryStatus findStatus(String trackingNumber) {
        return gateway.findStatus(trackingNumber);
    }
}

record DeliveryStatus(String code) {}

class TemporaryDeliveryException extends RuntimeException {
    TemporaryDeliveryException() {}
    TemporaryDeliveryException(Throwable cause) { super(cause); }
}

class InvalidDeliveryRequestException extends RuntimeException {
    InvalidDeliveryRequestException(Throwable cause) { super(cause); }
}

maxRetries = 3은 총 3회 실행이 아니라 최초 1회와 재시도 최대 3회, 즉 총 4회입니다. 지연은 200ms에서 시작해 배수만큼 증가하고 maxDelay를 넘지 않습니다. jitter는 여러 인스턴스가 동시에 실패한 뒤 같은 순간에 재호출하는 현상을 줄입니다. timeout은 최초 실행과 대기 시간을 포함한 전체 재시도 구간의 상한입니다.

실제 HTTP 클라이언트의 연결·응답 타임아웃도 별도로 설정해야 합니다. 애노테이션의 timeout만 두고 한 번의 네트워크 호출이 무한히 대기한다면 전체 제한을 의도대로 지키기 어렵습니다.

예외를 의미 있는 타입으로 변환하기

외부 연동 계층에서 모든 오류를 하나의 RuntimeException으로 감싸면 재시도 정책이 흐려집니다.

DeliveryStatus findStatus(String trackingNumber) {
    try {
        return restClient.get()
                .uri("/deliveries/{id}", trackingNumber)
                .retrieve()
                .body(DeliveryStatus.class);
    } catch (HttpServerErrorException ex) {
        throw new TemporaryDeliveryException(ex);
    } catch (HttpClientErrorException ex) {
        throw new InvalidDeliveryRequestException(ex);
    }
}

5xx는 TemporaryDeliveryException, 4xx는 InvalidDeliveryRequestException으로 나누면 includes에 일시적 오류만 넣을 수 있습니다. 공식 API는 중첩 원인도 검사하므로 의미 있는 cause 체인을 유지합니다.

재시도가 위험한 경우

재시도는 트랜잭션 롤백이나 메시지 유실을 자동으로 해결하지 않습니다. 특히 다음 작업은 추가 설계가 필요합니다.

  • 주문·결제 생성처럼 부수 효과가 있는 요청은 멱등성 키를 사용합니다.
  • DB 트랜잭션 안에서 긴 백오프를 수행하면 커넥션과 락을 오래 점유할 수 있습니다. 외부 호출과 DB 트랜잭션 경계를 분리합니다.
  • 모든 인스턴스가 장애 대상에 재시도하면 부하가 증폭됩니다. 횟수와 전체 시간을 작게 제한하고, 필요하면 circuit breaker를 함께 사용합니다.
  • @Async와 조합하면 호출자에게 예외가 전달되는 방식이 달라집니다. 비동기 완료와 실패 관측 방식을 별도로 정합니다.

테스트로 시도 횟수 확인

프록시 적용까지 확인하려면 Spring 컨텍스트에서 Bean을 주입받아 호출합니다.

@SpringBootTest
class DeliveryClientRetryTest {

    @Autowired
    DeliveryClient deliveryClient;

    @MockitoBean
    DeliveryGateway gateway;

    @Test
    void 일시적_실패_두_번_후_성공한다() {
        given(gateway.findStatus("A-1"))
                .willThrow(new TemporaryDeliveryException())
                .willThrow(new TemporaryDeliveryException())
                .willReturn(new DeliveryStatus("DELIVERED"));

        DeliveryStatus result = deliveryClient.findStatus("A-1");

        assertThat(result.code()).isEqualTo("DELIVERED");
        then(gateway).should(times(3)).findStatus("A-1");
    }
}

단위 테스트에서 객체를 new DeliveryClient(...)로 직접 만들면 Spring 프록시가 없으므로 애노테이션 재시도는 실행되지 않습니다. 테스트 속도가 중요하면 지연값을 프로퍼티 문자열 속성으로 분리해 테스트 환경에서 짧게 조정하거나, 프로그램 방식의 RetryTemplate과 가짜 시간 전략을 검토합니다.

흔한 실수

  1. includes를 비워 모든 예외를 재시도합니다. 기본값은 모든 예외 대상이므로 운영 코드에서는 범위를 좁힙니다.
  2. maxRetries를 총 시도 횟수로 오해합니다.
  3. 지터 없이 여러 Pod에 같은 고정 지연을 적용합니다.
  4. 자기 호출로 프록시를 우회합니다.
  5. 외부 호출을 DB 트랜잭션 안에서 오래 재시도합니다.
  6. Spring Retry의 org.springframework.retry.annotation.Retryable과 Spring Framework 7의 org.springframework.resilience.annotation.Retryable을 섞습니다.

핵심 요약

  • Spring Framework 7은 @Retryable과 프로그램 방식 RetryTemplate을 코어에서 제공합니다.
  • @EnableResilientMethods로 애노테이션 처리를 활성화하고 Spring 프록시를 통해 호출합니다.
  • 재시도 예외를 좁히고 지수 백오프, 지터, 최대 지연, 전체 제한 시간을 함께 둡니다.
  • 부수 효과가 있는 요청은 멱등성 없이 재시도하지 않습니다.
  • 시도 횟수와 최종 실패를 로그·메트릭으로 관찰하고 장애 증폭 여부를 확인합니다.

함께 읽기:

최종 확인: 2026-08-02

공식 출처:

728x90