외부 API가 일시적으로 응답하지 않을 때, 한 번 실패했다고 바로 에러를 반환하는 것이 최선일까요?

개념 정의

Spring Retry 는 일시적 장애(Transient Failure)로 실패한 작업을 자동으로 재시도하는 프레임워크입니다. 네트워크 타임아웃, DB 커넥션 실패, 외부 서비스 일시 중단처럼 다시 시도하면 성공할 가능성이 있는 상황에서 사용합니다.

의존성과 설정

JAVA
// build.gradle
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'  // AOP 필요
JAVA
@Configuration
@EnableRetry  // 필수: 재시도 활성화
public class RetryConfig {
}

@Retryable 기본 사용법

JAVA
@Service
@Slf4j
public class PaymentGatewayService {

    @Retryable(
        retryFor = { RestClientException.class, TimeoutException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000)  // 1초 대기
    )
    public PaymentResult processPayment(PaymentRequest request) {
        log.info("결제 처리 시도: {}", request.getOrderId());
        return paymentApi.charge(request);
        // RestClientException 발생 시 1초 후 재시도
    }

이어서 모든 재시도가 실패했을 때의 복구(Recovery) 로직을 정의합니다.

JAVA
    @Recover  // 모든 재시도 실패 후 호출
    public PaymentResult recover(
            RestClientException e, PaymentRequest request) {
        log.error("결제 처리 최종 실패: {}", request.getOrderId(), e);
        // 폴백: 수동 처리 큐에 등록
        manualProcessQueue.add(request);
        throw new PaymentFailedException("결제 처리에 실패했습니다", e);
    }
}

Backoff 전략

Fixed Backoff — 고정 대기

JAVA
@Retryable(
    retryFor = Exception.class,
    backoff = @Backoff(delay = 2000)  // 항상 2초 대기
)

Exponential Backoff — 지수 증가 대기

JAVA
@Retryable(
    retryFor = Exception.class,
    maxAttempts = 4,
    backoff = @Backoff(
        delay = 1000,       // 초기 대기: 1초
        multiplier = 2,     // 배수: 2
        maxDelay = 10000    // 최대 대기: 10초
    )
)
// 1초 → 2초 → 4초 → (최대 10초)

Random Backoff — 랜덤 지터

여러 클라이언트가 동시에 재시도할 때 요청이 몰리는 "thundering herd" 문제를 방지합니다.

JAVA
@Retryable(
    retryFor = Exception.class,
    backoff = @Backoff(
        delay = 1000,
        multiplier = 2,
        random = true       // 대기 시간에 랜덤 지터 추가
    )
)

재시도 대상 예외 제어

모든 예외를 재시도하면 안 됩니다. 비즈니스 로직 오류는 재시도해도 같은 결과가 나옵니다.

JAVA
@Retryable(
    retryFor = {
        RestClientException.class,  // 네트워크 오류 → 재시도
        TimeoutException.class      // 타임아웃 → 재시도
    },
    noRetryFor = {
        IllegalArgumentException.class,  // 잘못된 파라미터 → 즉시 실패
        AuthenticationException.class    // 인증 오류 → 즉시 실패
    },
    maxAttempts = 3
)
public ApiResponse callExternalApi(ApiRequest request) {
    return externalApiClient.call(request);
}

@Recover — 최종 폴백

@Recover 메서드는 몇 가지 규칙이 있습니다.

JAVA
@Service
public class NotificationService {

    @Retryable(retryFor = MessagingException.class, maxAttempts = 3)
    public void sendEmail(String to, String subject, String body) {
        emailClient.send(to, subject, body);
    }

    // 규칙 1: 반환 타입이 @Retryable 메서드와 같아야 함
    // 규칙 2: 첫 번째 파라미터가 예외 타입
    // 규칙 3: 나머지 파라미터는 @Retryable 메서드와 동일
    @Recover
    public void recoverEmail(
            MessagingException e, String to, String subject, String body) {
        log.error("이메일 발송 실패 [{}]: {}", to, e.getMessage());
        // 대안: SMS로 발송
        smsService.sendFallback(to, "이메일 발송에 실패했습니다");
    }
}

예외 타입별 분리

JAVA
@Recover
public void recoverFromTimeout(
        TimeoutException e, String to, String subject, String body) {
    // 타임아웃 전용 복구 로직
}

@Recover
public void recoverFromMessaging(
        MessagingException e, String to, String subject, String body) {
    // 메시징 오류 전용 복구 로직
}

RetryTemplate — 프로그래밍 방식

어노테이션 대신 코드로 직접 재시도 로직을 제어할 수 있습니다.

JAVA
@Configuration
public class RetryTemplateConfig {

    @Bean
    public RetryTemplate retryTemplate() {
        RetryTemplate template = new RetryTemplate();

        // 재시도 정책: 최대 3회, 특정 예외만
        Map<Class<? extends Throwable>, Boolean> retryableExceptions =
            new HashMap<>();
        retryableExceptions.put(RestClientException.class, true);
        retryableExceptions.put(TimeoutException.class, true);

        SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(
            3, retryableExceptions);
        template.setRetryPolicy(retryPolicy);

이어서 이벤트를 구독하는 리스너를 정의합니다.

JAVA
        // Backoff 정책: Exponential
        ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy();
        backOff.setInitialInterval(1000);
        backOff.setMultiplier(2.0);
        backOff.setMaxInterval(10000);
        template.setBackOffPolicy(backOff);

이어서 나머지 구현 부분입니다.

JAVA
        // 리스너: 재시도 로깅
        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;
    }
}
JAVA
@Service
@RequiredArgsConstructor
public class ResilientApiService {
    private final RetryTemplate retryTemplate;

    public ApiResponse callWithRetry(ApiRequest request) {
        return retryTemplate.execute(
            // 재시도 콜백
            context -> {
                log.info("API 호출 시도 #{}", context.getRetryCount() + 1);
                return apiClient.call(request);
            },
            // 복구 콜백 (모든 재시도 실패 시)
            context -> {
                log.error("API 호출 최종 실패");
                return ApiResponse.fallback();
            }
        );
    }
}

상태 유지 재시도 (Stateful Retry)

트랜잭션과 함께 사용할 때는 상태 유지 재시도가 필요합니다. 기본 @Retryable은 같은 메서드 내에서 반복하지만, stateful retry는 예외를 밖으로 던지고 재호출될 때 재시도로 판단합니다.

JAVA
@Retryable(
    stateful = true,                   // 상태 유지 재시도
    retryFor = OptimisticLockException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 100)
)
@Transactional
public void updateWithOptimisticLock(Long id, String newValue) {
    Entity entity = repository.findById(id).orElseThrow();
    entity.update(newValue);
    // OptimisticLockException 발생 시 트랜잭션 롤백 후 재시도
}

실무 패턴

외부 API 호출 + Circuit Breaker 조합

재시도만으로는 부족할 때 Circuit Breaker와 함께 사용합니다.

JAVA
@Service
public class ResilientExternalService {

    // 1차: 재시도로 일시적 오류 대응
    @Retryable(
        retryFor = RestClientException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 500, multiplier = 2)
    )
    // 2차: Circuit Breaker로 연쇄 장애 방지
    @CircuitBreaker(name = "externalApi", fallbackMethod = "fallback")
    public ExternalData fetchData(String id) {
        return externalApiClient.getData(id);
    }

    public ExternalData fallback(String id, Exception e) {
        return ExternalData.cached(id);  // 캐시된 데이터 반환
    }
}

멱등성 보장

재시도 시 같은 작업이 여러 번 실행될 수 있으므로, 멱등성을 보장해야 합니다.

JAVA
@Retryable(retryFor = TimeoutException.class, maxAttempts = 3)
public void processOrder(String orderId) {
    // 멱등성 키로 중복 실행 방지
    if (orderProcessLog.isProcessed(orderId)) {
        log.info("이미 처리된 주문: {}", orderId);
        return;
    }

    orderService.process(orderId);
    orderProcessLog.markAsProcessed(orderId);
}

재시도하면 안 되는 경우

  • ** 비즈니스 로직 오류 **: 잘못된 입력, 유효성 검증 실패
  • ** 인증/인가 오류 **: 토큰 만료, 권한 없음
  • ** 리소스 없음 **: 404 Not Found
  • ** 요청 크기 초과 **: 413 Payload Too Large

이런 경우에는 재시도해도 결과가 같으므로, noRetryFor로 명시적으로 제외합니다.

주의할 점

1. 멱등성을 보장하지 않으면 재시도가 중복 처리를 일으킨다

결제 API를 재시도할 때 첫 번째 요청이 실제로는 성공했지만 타임아웃으로 실패로 간주되면, 재시도로 결제가 이중으로 처리됩니다. 재시도 대상 작업은 반드시 멱등성 키(Idempotency Key)를 사용하여 같은 요청이 여러 번 실행되어도 결과가 동일하도록 보장해야 합니다.

2. 비즈니스 로직 오류에 재시도를 적용하면 불필요한 지연만 발생한다

IllegalArgumentException(잘못된 파라미터)이나 AuthenticationException(인증 실패)은 재시도해도 같은 결과가 나옵니다. 모든 예외에 재시도를 걸면 요청 처리 시간만 3배로 늘어나고, 의미 없는 재시도가 외부 서비스에 부하를 줍니다. retryFornoRetryFor로 재시도할 예외를 명확히 구분해야 합니다.

3. @Retryable도 AOP 기반이라 self-invocation에서 동작하지 않는다

@Retryable은 프록시 기반 AOP로 동작합니다. 같은 클래스 내부에서 @Retryable 메서드를 호출하면 프록시를 거치지 않아 재시도가 적용되지 않습니다. 첫 시도에서 실패하면 그대로 예외가 전파됩니다. @Retryable 메서드는 반드시 외부 빈에서 호출되어야 합니다.

정리

항목설명
@Retryable일시적 장애에 선언적 자동 재시도 (기본 3회)
Backoff 전략Fixed(고정), Exponential(지수), Random(지터)
@Recover모든 재시도 실패 후 최종 폴백
예외 구분retryFor(재시도 대상) / noRetryFor(즉시 실패) 분리 필수
멱등성재시도 시 중복 처리 방지를 위한 Idempotency Key 필수
self-invocationAOP 기반이므로 내부 호출 시 재시도 미적용
댓글 로딩 중...