Spring RetryTemplate 커스텀 전략

Spring RetryTemplate이란?

Spring Retry의 @Retryable 어노테이션은 간편하지만, 런타임에 재시도 정책을 변경하거나 복합 조건을 적용하기 어렵습니다. RetryTemplate프로그래밍 방식으로 재시도 정책을 세밀하게 제어할 수 있는 저수준 API입니다. 커스텀 RetryPolicy, BackOffPolicy, RetryListener를 조합하여 프로덕션 환경에 맞는 탄력적 재시도 전략을 설계할 수 있습니다.

1. RetryTemplate 기본 구조

RetryTemplate은 세 가지 컴포넌트로 구성됩니다.

컴포넌트 역할 기본 구현
RetryPolicy 재시도 여부 판단 (횟수, 예외 타입) SimpleRetryPolicy (3회)
BackOffPolicy 재시도 간 대기 시간 계산 FixedBackOffPolicy (1초)
RecoveryCallback 모든 재시도 실패 후 폴백 처리 없음 (예외 전파)
@Configuration
public class RetryConfig {

    @Bean
    public RetryTemplate retryTemplate() {
        return RetryTemplate.builder()
            .maxAttempts(3)
            .exponentialBackoff(1000, 2.0, 10000)  // 1s → 2s → 4s (max 10s)
            .retryOn(TransientException.class)
            .traversingCauses()  // 래핑된 예외 내부까지 검사
            .build();
    }
}

2. 커스텀 RetryPolicy: 조건부 재시도

HTTP 상태 코드, 에러 메시지, 비즈니스 조건에 따라 재시도 여부를 결정하는 커스텀 정책입니다.

public class HttpStatusRetryPolicy implements RetryPolicy {

    private static final Set<Integer> RETRYABLE_STATUSES = 
        Set.of(408, 429, 500, 502, 503, 504);
    private final int maxAttempts;

    public HttpStatusRetryPolicy(int maxAttempts) {
        this.maxAttempts = maxAttempts;
    }

    @Override
    public boolean canRetry(RetryContext context) {
        Throwable t = context.getLastThrowable();
        if (t == null) return true;  // 첫 시도

        int count = context.getRetryCount();
        if (count >= maxAttempts) return false;

        // HTTP 상태 코드 기반 판단
        if (t instanceof HttpClientErrorException ex) {
            return RETRYABLE_STATUSES.contains(ex.getStatusCode().value());
        }
        if (t instanceof HttpServerErrorException ex) {
            return RETRYABLE_STATUSES.contains(ex.getStatusCode().value());
        }
        // 네트워크 에러는 항상 재시도
        if (t instanceof ResourceAccessException) {
            return true;
        }
        return false;
    }

    @Override
    public RetryContext open(RetryContext parent) {
        return new RetryContextSupport(parent);
    }

    @Override
    public void close(RetryContext context) { }

    @Override
    public void registerThrowable(RetryContext context, Throwable t) {
        ((RetryContextSupport) context).registerThrowable(t);
    }
}

3. 복합 RetryPolicy: CompositeRetryPolicy

여러 정책을 AND/OR로 조합할 수 있습니다.

@Bean
public RetryTemplate compositeRetryTemplate() {
    // 정책 1: 최대 5회
    SimpleRetryPolicy maxAttempts = new SimpleRetryPolicy(5);

    // 정책 2: 특정 예외만
    Map<Class<? extends Throwable>, Boolean> retryableExceptions = Map.of(
        HttpServerErrorException.class, true,
        ResourceAccessException.class, true,
        HttpClientErrorException.class, false  // 4xx는 재시도 안 함
    );
    SimpleRetryPolicy exceptionPolicy = 
        new SimpleRetryPolicy(5, retryableExceptions, true);

    // 정책 3: 시간 제한 (총 30초 이내만 재시도)
    TimeoutRetryPolicy timeoutPolicy = new TimeoutRetryPolicy();
    timeoutPolicy.setTimeout(30000L);

    // AND 조합: 모든 조건을 동시에 만족해야 재시도
    CompositeRetryPolicy composite = new CompositeRetryPolicy();
    composite.setPolicies(new RetryPolicy[]{
        maxAttempts, exceptionPolicy, timeoutPolicy
    });
    // optimistic=true로 설정하면 OR 조합

    RetryTemplate template = new RetryTemplate();
    template.setRetryPolicy(composite);
    template.setBackOffPolicy(exponentialBackOff());

    return template;
}

4. 커스텀 BackOffPolicy: Rate Limit 대응

429 Too Many Requests 응답의 Retry-After 헤더를 존중하는 동적 백오프 정책입니다.

public class RetryAfterBackOffPolicy implements BackOffPolicy {

    private final long defaultBackOff;
    private final long maxBackOff;

    public RetryAfterBackOffPolicy(long defaultBackOff, long maxBackOff) {
        this.defaultBackOff = defaultBackOff;
        this.maxBackOff = maxBackOff;
    }

    @Override
    public BackOffContext start(RetryContext context) {
        return new BackOffContext() {};
    }

    @Override
    public void backOff(BackOffContext context) throws BackOffInterruptedException {
        long sleepTime = defaultBackOff;

        RetryContext retryContext = RetrySynchronizationManager.getContext();
        Throwable lastError = retryContext != null 
            ? retryContext.getLastThrowable() : null;

        if (lastError instanceof HttpClientErrorException.TooManyRequests ex) {
            HttpHeaders headers = ex.getResponseHeaders();
            if (headers != null) {
                String retryAfter = headers.getFirst("Retry-After");
                if (retryAfter != null) {
                    try {
                        sleepTime = Long.parseLong(retryAfter) * 1000;
                    } catch (NumberFormatException e) {
                        // HTTP-date 형식 파싱
                        sleepTime = defaultBackOff;
                    }
                }
            }
        } else {
            // 지수 백오프 (jitter 추가)
            int retryCount = retryContext != null ? retryContext.getRetryCount() : 0;
            sleepTime = (long) (defaultBackOff * Math.pow(2, retryCount));
            // ±25% jitter
            double jitter = 0.75 + Math.random() * 0.5;
            sleepTime = (long) (sleepTime * jitter);
        }

        sleepTime = Math.min(sleepTime, maxBackOff);

        try {
            Thread.sleep(sleepTime);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BackOffInterruptedException("Interrupted", e);
        }
    }
}

5. RetryListener: 모니터링·메트릭

재시도 이벤트를 Prometheus 메트릭으로 노출합니다.

@Component
@RequiredArgsConstructor
public class MetricsRetryListener implements RetryListener {

    private final MeterRegistry registry;

    @Override
    public <T, E extends Throwable> boolean open(
            RetryContext context, RetryCallback<T, E> callback) {
        // 재시도 시작: true를 반환해야 진행
        return true;
    }

    @Override
    public <T, E extends Throwable> void onError(
            RetryContext context, RetryCallback<T, E> callback, Throwable t) {
        
        String operation = (String) context.getAttribute("operation");
        if (operation == null) operation = "unknown";

        registry.counter("retry.attempts",
            "operation", operation,
            "exception", t.getClass().getSimpleName(),
            "attempt", String.valueOf(context.getRetryCount())
        ).increment();
    }

    @Override
    public <T, E extends Throwable> void close(
            RetryContext context, RetryCallback<T, E> callback, Throwable t) {
        
        String operation = (String) context.getAttribute("operation");
        if (operation == null) operation = "unknown";
        
        boolean exhausted = t != null;
        registry.counter("retry.completed",
            "operation", operation,
            "exhausted", String.valueOf(exhausted),
            "totalAttempts", String.valueOf(context.getRetryCount())
        ).increment();
    }
}

6. RecoveryCallback: 폴백 전략

@Service
@RequiredArgsConstructor
public class ExternalApiService {

    private final RetryTemplate retryTemplate;
    private final CacheManager cacheManager;

    public ExchangeRate getExchangeRate(String currency) {
        return retryTemplate.execute(
            // RetryCallback: 재시도 대상 로직
            context -> {
                context.setAttribute("operation", "getExchangeRate");
                return callExternalApi(currency);
            },
            // RecoveryCallback: 모든 재시도 실패 후 폴백
            context -> {
                Throwable lastError = context.getLastThrowable();
                log.warn("Exchange rate API failed after {} attempts: {}",
                    context.getRetryCount(), lastError.getMessage());

                // 폴백 1: 캐시된 이전 값 반환
                Cache cache = cacheManager.getCache("exchangeRates");
                if (cache != null) {
                    ExchangeRate cached = cache.get(currency, ExchangeRate.class);
                    if (cached != null) {
                        log.info("Returning cached rate for {}", currency);
                        return cached.withStale(true);
                    }
                }

                // 폴백 2: 기본값 반환
                log.warn("No cache available, returning default rate");
                return ExchangeRate.defaultRate(currency);
            }
        );
    }
}

7. Stateful Retry: 트랜잭션 롤백 후 재시도

DB 트랜잭션에서 데드락이 발생하면 트랜잭션을 롤백한 뒤 처음부터 다시 실행해야 합니다. Stateful retry는 예외를 호출자에게 전파하면서 다음 호출 시 재시도 상태를 유지합니다.

@Configuration
public class StatefulRetryConfig {

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

        // 데드락만 재시도
        Map<Class<? extends Throwable>, Boolean> retryable = Map.of(
            DeadlockLoserDataAccessException.class, true,
            CannotAcquireLockException.class, true
        );
        template.setRetryPolicy(new SimpleRetryPolicy(3, retryable, true));

        // 랜덤 백오프 (데드락 해소용)
        UniformRandomBackOffPolicy backOff = new UniformRandomBackOffPolicy();
        backOff.setMinBackOffPeriod(100);
        backOff.setMaxBackOffPeriod(500);
        template.setBackOffPolicy(backOff);

        return template;
    }
}

@Service
@RequiredArgsConstructor
public class InventoryService {

    private final RetryTemplate statefulRetryTemplate;
    private final InventoryRepository inventoryRepo;

    @Transactional
    public void decreaseStock(Long productId, int quantity) {
        statefulRetryTemplate.execute(context -> {
            Inventory inv = inventoryRepo
                .findByIdWithPessimisticLock(productId)
                .orElseThrow();
            
            if (inv.getStock() < quantity) {
                throw new InsufficientStockException();  // 재시도 안 함
            }
            
            inv.decrease(quantity);
            inventoryRepo.save(inv);
            return null;
        });
    }
}

8. RetryTemplate vs @Retryable 선택 기준

상황 추천 이유
단순 재시도 (3회, 고정 백오프) @Retryable 코드 간결
HTTP 상태별 분기 재시도 RetryTemplate 커스텀 RetryPolicy 필요
Retry-After 헤더 존중 RetryTemplate 커스텀 BackOffPolicy 필요
재시도 메트릭 수집 RetryTemplate + Listener 세밀한 이벤트 추적
복합 조건 (시간 + 횟수 + 예외) RetryTemplate CompositeRetryPolicy
테스트에서 정책 교체 RetryTemplate (Bean) Bean 교체로 테스트 용이

마무리

Spring RetryTemplate@Retryable의 한계를 넘어 런타임 조건 기반 재시도, Rate Limit 대응, 메트릭 수집을 가능하게 합니다. 커스텀 RetryPolicy로 HTTP 상태 코드별 분기를, RetryAfterBackOffPolicy로 429 응답을 존중하는 백오프를, RetryListener로 Prometheus 메트릭을 수집하세요. Spring Retry 기초를 이해한 뒤, Resilience4j 서킷브레이커와 함께 계층적 장애 대응 전략을 구축하는 것을 권장합니다.

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