이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Retry를 적용해도 되는 실패와 적용하면 안 되는 실패를 어떻게 구분하는가?
  • Backoff와 jitter는 왜 retry 설계의 필수 요소인가?
  • Retry가 장애를 완화하지 않고 증폭하는 상황은 언제인가?

개요

Retry는 실패를 줄이는 마법이 아니다. 실패한 요청을 다시 보내 downstream에 추가 트래픽을 만드는 행위다.

따라서 retry의 첫 질문은 “몇 번 다시 시도할까?”가 아니라 “이 실패는 다시 시도하면 성공할 가능성이 있는가?”다. 의미가 확정된 실패는 다시 시도해도 성공하지 않는다. 반대로 네트워크 흔들림, connection reset, 일시적인 503, 짧은 timeout은 재시도할 가치가 있을 수 있다.

Retry는 timeout, idempotency, rate limit, circuit breaker와 함께 설계해야 한다. 이 중 하나라도 빠지면 일시 장애를 전체 장애로 키울 수 있다.

Retry 대상 분류

실패 종류Retry 여부이유
400 Bad Request하지 않음요청 형식이 틀렸으므로 반복해도 실패한다.
401/403하지 않음인증이나 권한 문제는 같은 요청 반복으로 해결되지 않는다.
404대체로 하지 않음리소스 부재가 확정된 경우가 많다.
409 Conflict조건부처리 중 상태나 중복 요청이면 조회로 전환할 수 있다.
429 Too Many Requests조건부Retry-After를 지켜야 한다. 즉시 재시도하면 안 된다.
500조건부서버 오류 원인이 일시적인지 확인해야 한다.
502/503/504가능일시적인 gateway, overload, timeout일 수 있다.
network timeout가능응답 유실일 수 있으므로 idempotency가 필요하다.

쓰기 요청에서 retry하려면 멱등성이 먼저다. 같은 주문 생성, 결제 승인, 포인트 적립이 두 번 실행되면 retry는 장애 완화가 아니라 데이터 오염이 된다.

Backoff가 필요한 이유

즉시 retry는 장애가 난 의존성을 더 세게 때린다.

정상 요청: 1,000 QPS
실패율: 50%
retry 1회 즉시 실행
downstream 실제 요청: 1,500 QPS
retry 2회 즉시 실행
downstream 최악 요청: 2,000 QPS 이상

장애 원인이 overload라면 retry는 복구 시간을 늘린다. Backoff는 재시도 간격을 늘려 downstream이 회복할 시간을 준다.

1차 실패 후 200ms 대기
2차 실패 후 500ms 대기
3차 실패 후 1000ms 대기
이후 포기 또는 queue 재처리로 전환

Backoff는 retry 횟수를 줄이는 것이 아니라 retry가 몰리는 시간을 흩는 장치다.

Jitter가 필요한 이유

Backoff만 있어도 모든 서버가 같은 시간표로 움직이면 재시도가 다시 한 번 몰린다.

서버 100대가 동시에 500ms backoff
500ms 뒤 같은 downstream으로 동시 재시도
다시 overload

Jitter는 backoff 시간에 무작위성을 넣어 retry 타이밍을 분산한다.

Retry retry = Retry.backoff(2, Duration.ofMillis(200))
    .maxBackoff(Duration.ofSeconds(1))
    .jitter(0.5);

이 설정은 200ms, 400ms처럼 고정된 간격이 아니라 일정 범위 안에서 흔들리는 대기 시간을 만든다.

Spring 설계 경계

Retry는 아무 계층에나 붙이면 안 된다. Repository, Service, Client 중 어느 경계가 재시도의 의미를 이해하는지 봐야 한다.

외부 API client는 네트워크 오류를 알고 있다.

public PaymentResponse approve(PaymentRequest request) {
    return retryTemplate.execute(context ->
        paymentHttpClient.approve(request)
    );
}

Service는 비즈니스 중복 위험을 알고 있다.

public PaymentResult pay(PaymentCommand command, String idempotencyKey) {
    return idempotencyService.execute(idempotencyKey, () ->
        paymentClient.approve(command.toRequest())
    );
}

Client에 retry만 있고 Service에 idempotency가 없으면 위험하다. 외부 호출이 두 번 성공했을 때 비즈니스적으로 어떻게 처리할지 모른다.

Retry Budget

기업 운영에서는 retry 횟수만 정하지 않고 retry budget을 둔다.

전체 요청 중 retry 허용 비율: 10%
특정 dependency retry QPS 상한: 200 QPS
retry 후 최종 실패율 alert: 3%
retry storm 감지 시 circuit open

Retry budget은 장애 중 “얼마나 더 두드릴 것인가”에 대한 상한이다. 예산이 없으면 모든 인스턴스가 각자 선의로 retry하면서 전체 트래픽을 예측할 수 없게 만든다.

Queue와 Retry

HTTP 요청 retry와 message retry는 다르다.

  • HTTP retry는 사용자 응답 시간 안에서 제한적으로 수행한다.
  • Message retry는 처리 지연을 감수할 수 있지만 queue backlog와 DLQ 정책이 필요하다.
  • Message retry는 consumer가 같은 메시지를 다시 받을 수 있음을 전제로 idempotent handler를 가져야 한다.
{
  "eventId": "evt-1001",
  "retryCount": 2,
  "nextAttemptAt": "2026-06-30T10:15:30+09:00"
}

retry count를 메시지에 남기면 무한 재처리를 막고 DLQ 이동 기준을 정할 수 있다.

위험 신호!

  • 모든 5xx를 무조건 retry한다.
  • retry 횟수는 있지만 전체 timeout budget을 계산하지 않았다.
  • backoff 없이 즉시 retry한다.
  • 모든 서버가 같은 backoff 간격으로 재시도한다.
  • 쓰기 API에 retry가 있는데 idempotency key가 없다.
  • 429를 받았는데 Retry-After를 무시한다.

실전 팁

  • retry 가능한 예외를 allowlist로 둔다. blocklist 방식은 새로운 실패를 잘못 retry하기 쉽다.
  • retry 로그에는 attempt, elapsed time, final result, dependency name을 남긴다.
  • retry 후 성공률을 본다. retry 대부분이 실패한다면 장애를 완화하는 것이 아니라 부하를 추가하는 것이다.
  • retry 횟수를 늘리기 전에 timeout과 circuit breaker를 먼저 확인한다.
  • 사용자가 기다리는 동기 요청 안에서는 retry 횟수를 작게 유지한다.

확인 질문

확인 질문

  • 확인 질문: retry하면 안 되는 대표 실패는 무엇인가?
    • 답변: 요청 형식 오류, 인증/인가 실패, 잔액 부족처럼 같은 요청을 반복해도 성공 가능성이 없는 실패다.
  • 확인 질문: jitter가 없으면 어떤 문제가 생기는가?
    • 답변: 여러 인스턴스가 같은 backoff 뒤 동시에 재시도해 downstream overload를 반복할 수 있다.
  • 확인 질문: retry budget이 필요한 이유는 무엇인가?
    • 답변: 장애 중 retry가 전체 트래픽을 얼마나 늘릴 수 있는지 상한을 두어 retry storm을 막기 위해서다.

참고 문서