이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 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으로 동시 재시도
다시 overloadJitter는 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 openRetry 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을 막기 위해서다.