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

  • Retry가 일시 장애를 완화하는 경우와 장애를 증폭하는 경우를 어떻게 구분하는가?
  • retry 대상 status, exception, method, idempotency를 어떻게 제한해야 하는가?
  • backoff, jitter, retry budget, Retry-After는 왜 필요한가?

개요

Retry는 실패한 요청을 다시 시도하는 전략이다. DNS 순간 실패, 일시적인 TCP reset, 짧은 503, leader election 중인 dependency처럼 “잠깐 기다리면 회복될 가능성이 높은 실패”에는 도움이 된다.

하지만 overload 상태에서는 retry가 추가 traffic이다. downstream이 이미 느려서 실패하는데 caller가 동시에 재시도하면, 실패한 요청이 두 배, 세 배로 늘어난다. 사용자, 브라우저, 모바일 앱, gateway, application, batch job이 각각 retry하면 작은 장애가 retry storm으로 커진다.

왜 백엔드 개발자가 알아야 하는가

백엔드 개발자는 retry를 “안정성 기능”으로만 보면 안 된다. retry는 사용자의 성공률을 높이기도 하지만 downstream을 공격하는 traffic generator가 되기도 한다.

  • 결제 API가 2초 동안 느려졌는데 주문 서비스가 모든 timeout을 즉시 retry해 결제 API를 더 느리게 만든다.
  • client retry와 server retry가 동시에 켜져 하나의 사용자 요청이 여러 개의 downstream 요청이 된다.
  • POST 요청을 idempotency key 없이 재시도해 중복 주문, 중복 예약, 중복 포인트 적립이 생긴다.
  • batch job이 실패 row를 무제한 재시도해 운영 API traffic을 잠식한다.
  • Retry-After를 무시해 rate limit 중인 API에 계속 요청을 보낸다.

retry는 timeout, idempotency, circuit breaker, rate limit과 한 묶음으로 설계해야 한다.

요청 흐름에서의 위치

Retry는 실패가 관찰된 뒤 같은 의도를 다시 실행하는 지점이다.

Caller request
→ Downstream call attempt #1
→ timeout / 503 / reset
→ wait with backoff + jitter
→ attempt #2
→ success, final failure, or circuit breaker open

이때 retry가 어느 계층에 있는지 알아야 한다. SDK retry, HTTP client retry, application retry, queue retry, gateway retry, client retry가 동시에 있으면 실제 시도 횟수는 설정값보다 훨씬 커진다.

원리

retry 판단에는 최소 다섯 가지 조건이 필요하다.

조건질문
실패 종류transient failure인가, overload인가, validation/business failure인가?
HTTP methodGET/PUT/DELETE처럼 idempotent 의미인가, POST처럼 side effect가 있는가?
idempotency같은 요청을 여러 번 실행해도 결과가 하나로 수렴하는가?
budget전체 요청 중 retry가 차지할 수 있는 비율과 횟수는 얼마인가?
backoff실패 직후 몰려가지 않고 점점 기다리며 분산되는가?

retry 대상은 넓게 잡을수록 위험하다. 모든 exception, 모든 5xx, 모든 method에 retry를 걸면 장애를 가릴 수는 있어도 언젠가 더 큰 장애를 만든다.

Retry 대상 구분

대상retry 판단
408 Request Timeoutidempotent 요청이면 제한적으로 가능
429 Too Many RequestsRetry-After 또는 rate limit reset 시간 존중
500 Internal Server Error원인 불명. 짧은 retry 가능하지만 광범위 retry 금지
502, 503, 504gateway/downstream 일시 장애 가능. backoff와 breaker 필요
connect timeout/reset네트워크 순간 실패 가능. idempotency 확인
read timeout서버에서 성공했을 수도 있음. 중복 민감 요청은 특히 주의
400, 401, 403, 404, 409, 422대부분 retry로 해결되지 않음

read timeout은 특히 조심해야 한다. caller는 응답을 못 받았지만 서버에서는 처리에 성공했을 수 있다. 결제, 주문, 예약은 retry보다 idempotency key와 상태 조회 API가 먼저다.

실무에서 자주 만나는 문제

  • 즉시 retry를 한다.
    • 장애 순간 모든 caller가 동시에 다시 몰린다.
    • exponential backoff와 jitter를 둔다.
  • retry 횟수만 보고 전체 점유 시간을 계산하지 않는다.
    • timeout 3초, retry 3회, backoff까지 있으면 사용자 요청 하나가 10초 이상 자원을 붙잡을 수 있다.
    • 전체 deadline 안에서 retry를 제한한다.
  • 여러 계층 retry가 곱해진다.
    • client 2회, gateway 2회, app 2회면 downstream은 최대 8번 맞을 수 있다.
    • retry 책임 계층을 정하고 나머지는 끈다.
  • business failure도 retry한다.
    • 잔액 부족, 권한 없음, validation error는 재시도로 해결되지 않는다.
    • retry 대상 exception과 status를 allowlist로 관리한다.
  • 장애 중 batch retry가 운영 traffic을 압박한다.
    • batch는 별도 rate limit, queue delay, circuit breaker를 둔다.

디버깅 방법

retry 장애를 의심하면 “실제 시도 횟수”부터 본다.

사용자 요청 수:
downstream 호출 수:
attempt별 status/exception:
retry reason:
retry wait:
최종 결과:
idempotency key:
circuit breaker state:

관측 지표는 다음이 필요하다.

  • dependency별 attempt count
  • retry success rate
  • retry exhausted count
  • retry reason별 count
  • downstream latency와 error rate
  • circuit breaker open/half-open count
  • 429Retry-After 수신 수

로그에는 attempt 번호와 idempotency key를 함께 남긴다.

requestId=abc dependency=payment attempt=1 result=read_timeout elapsedMs=3000
requestId=abc dependency=payment attempt=2 result=success elapsedMs=420

이 로그가 없으면 “retry가 도움 됐는지”와 “retry가 장애를 키웠는지”를 구분할 수 없다.

코드로 확인하기

Resilience4j retry는 대상 exception, 최대 시도, 대기 전략을 명시한다.

IntervalFunction backoffWithJitter =
        IntervalFunction.ofExponentialRandomBackoff(
                Duration.ofMillis(100),
                2.0,
                0.5
        );
 
RetryConfig retryConfig = RetryConfig.custom()
        .maxAttempts(3)
        .intervalFunction(backoffWithJitter)
        .retryExceptions(IOException.class, TimeoutException.class)
        .ignoreExceptions(IllegalArgumentException.class, AccessDeniedException.class)
        .build();

이 설정에서 봐야 할 것은 다음이다.

  • maxAttempts는 최초 시도를 포함한다. 실제 추가 retry 수를 착각하지 않는다.
  • intervalFunction은 즉시 재시도 대신 backoff와 jitter를 둔다.
  • retry exception은 allowlist에 가깝게 좁힌다.
  • business exception은 retry하지 않는다.

HTTP 응답 기반 retry는 method와 idempotency를 함께 본다.

boolean shouldRetry(String method, int status, boolean hasIdempotencyKey) {
    if (status == 429 || status == 503 || status == 504) {
        return isIdempotentMethod(method) || hasIdempotencyKey;
    }
    return false;
}
 
boolean isIdempotentMethod(String method) {
    return Set.of("GET", "HEAD", "PUT", "DELETE", "OPTIONS").contains(method);
}

실제 운영 코드는 더 많은 조건을 보겠지만, 핵심은 “실패 코드만 보고 retry하지 않는다”는 점이다.

주니어가 자주 하는 오해

  • retry를 넣으면 안정성이 올라간다고만 생각한다.
    • overload 상황의 retry는 실패한 시스템에 더 많은 요청을 보낸다.
  • 5xx는 모두 retry해도 된다고 생각한다.
    • 500은 애플리케이션 버그일 수 있고, 503은 rate limit이나 overload일 수 있다.
  • POST는 무조건 retry하면 안 된다고 외운다.
    • idempotency key와 중복 방지 저장소가 있으면 제한적으로 안전한 retry가 가능하다.
  • retry 횟수만 보고 위험을 판단한다.
    • timeout, backoff, 동시성, 여러 계층 retry 곱까지 함께 봐야 한다.

시니어의 설계 판단 기준

  • retry는 dependency별로 다르게 둔다. 결제, 검색, 추천, 알림은 실패 비용이 다르다.
  • 전체 deadline 안에서 retry 횟수와 backoff를 계산한다.
  • retry는 circuit breaker와 함께 둬서 이미 실패 중인 dependency에 계속 접근하지 않게 한다.
  • retry budget을 둬서 정상 traffic 대비 retry traffic 비율을 제한한다.
  • server-side retry와 client-side retry 중 어느 계층이 책임질지 정한다.

인프라 협업 포인트

  • Gateway/LB/Service Mesh의 retry 정책이 애플리케이션 retry와 중복되지 않는지 확인한다.
  • Envoy/Istio를 쓰면 route별 retry, timeout, outlier detection 설정을 앱 설정과 맞춘다.
  • 429나 rate limit 정책이 있다면 Retry-After 또는 reset header를 앱이 읽을 수 있어야 한다.
  • 장애 중 retry traffic을 줄이기 위한 feature flag, config rollback, traffic shedding 절차를 정한다.

실전 팁

  • retry log에는 dependency, attempt, reason, wait, final result를 남긴다.
  • retry success rate가 낮으면 retry가 장애를 완화하지 못하고 있다는 신호다.
  • batch retry는 운영 API와 다른 queue, rate limit, schedule을 둔다.
  • 모바일 앱은 네트워크 전환 때문에 retry가 필요하지만, 서버 idempotency와 중복 방지 없이는 위험하다.
  • retry를 추가한 PR에는 “최악의 요청 점유 시간” 계산을 함께 적는다.

위험 신호!

  • 모든 5xx와 모든 exception에 retry가 걸려 있다.
  • retry attempt metric이 없다.
  • gateway, client, application retry가 동시에 켜져 있지만 실제 최대 시도 횟수를 모른다.
  • 결제/주문 POST에 idempotency key 없이 retry가 가능하다.
  • Retry-After를 무시하고 고정 간격으로 계속 재시도한다.

확인 질문

확인 질문

  • retry가 장애를 증폭하는 대표 상황은 무엇인가?
    • downstream이 overload 상태인데 caller들이 동시에 재시도해 추가 traffic을 만들 때다.
  • read timeout 후 retry가 위험한 이유는 무엇인가?
    • caller는 응답을 못 받았지만 서버에서는 처리에 성공했을 수 있어 중복 side effect가 생길 수 있기 때문이다.
  • retry budget이 필요한 이유는 무엇인가?
    • retry traffic이 정상 traffic을 압도하지 않도록 전체 요청 중 retry가 차지할 수 있는 비율과 횟수를 제한하기 위해서다.

참고 문서