새벽 2시에 결제 API 호출이 실패했습니다. 일시적인 네트워크 문제였을까요, 요청 속도 제한이었을까요, 아니면 서버가 다운된 걸까요? 이 질문의 답에 따라 재시도가 트랜잭션을 살릴지, 고객에게 이중 청구를 일으킬지가 결정됩니다.
재시도는 분산 시스템에서 가장 흔한 복원력 패턴이지만, 가장 자주 잘못 구현되기도 합니다. 무작정 HTTP 호출을 반복하면 수천 개의 클라이언트가 장애 중인 서버를 공격해 30초의 장애를 30분으로 늘릴 수 있습니다. 반대로 올바르게 구현하면 일시적인 오류를 사용자가 알아채지 못하게 흡수할 수 있습니다.
이 글에서는 다음을 다룹니다.
- 재시도할 HTTP 상태 코드와 즉시 실패할 상태 코드
- 풀 지터가 적용된 지수 백오프
-
Retry-After헤더 - 멱등성 키
- 재시도 예산과 서킷 브레이커
- Apidog 목 서버를 사용한 429·503 시나리오 테스트
실패하는 서버를 대상으로 검증하지 않은 재시도 로직은 설계가 아니라 추측에 불과합니다.
어설픈 재시도가 장애를 악화시키는 이유
초당 1,000개의 요청을 처리하는 서비스에 5초 동안 문제가 발생했다고 가정해 보겠습니다. 모든 클라이언트가 즉시 3번씩 재시도하면, 이미 과부하 상태인 서버에 초당 4,000개의 요청이 들어갑니다. 서버가 다시 온라인되자마자 모든 클라이언트가 동시에 재시도하면 장애는 더 커집니다.
이 현상을 재시도 폭풍(retry storm)이라고 합니다. 서버 복구 순간에 클라이언트가 동시에 몰려드는 현상은 떼 지어 몰려드는 현상(thundering herd)입니다. Google SRE의 연쇄 실패 처리에서도 백오프 없는 재시도가 장애 중 부하를 증폭시키고, 원래 오류가 해결된 뒤에도 서비스를 계속 다운시킬 수 있다고 설명합니다.
대부분의 재시도 폭풍은 다음 두 가지 결함에서 시작됩니다.
- 지연 없는 재시도: 최악의 순간에 즉시 부하를 추가합니다.
- 고정 지연: 모든 클라이언트가 정확히 1초 후 동시에 재시도합니다.
해결책은 재시도를 없애는 것이 아닙니다. 다음 원칙으로 재시도해야 합니다.
- 재시도 가능한 오류만 선택합니다.
- 재시도할수록 지연 시간을 늘립니다.
- 지연 시간을 무작위화해 동기화를 피합니다.
- 재시도 횟수와 전체 부하에 상한을 둡니다.
재시도할 오류와 즉시 실패할 오류
백오프를 계산하기 전에 의사결정 테이블을 정의하세요. 서버가 유효하지 않다고 판단한 요청을 반복하면 용량만 낭비하고 로그만 오염시킵니다.
재시도할 경우
| 신호 | 의미 |
|---|---|
429 Too Many Requests |
요청 속도 제한에 도달했습니다. 기다린 후 더 느리게 재시도합니다. |
502 Bad Gateway |
업스트림에서 잘못된 응답을 반환했습니다. 일시적일 수 있습니다. |
503 Service Unavailable |
서버가 과부하 상태이거나 재시작 중입니다. |
504 Gateway Timeout |
업스트림 종속성이 너무 느렸습니다. |
| 연결 재설정, DNS 실패, 소켓 타임아웃 | 요청이 서버에 도착하지 않았을 수 있습니다. |
504 게이트웨이 타임아웃은 특히 주의해야 합니다. 게이트웨이가 응답을 기다리다 포기했더라도 원본 서버는 이미 요청을 처리했을 수 있습니다. 따라서 쓰기 요청에는 멱등성 보장이 필요합니다.
절대 재시도하지 않을 경우
| 신호 | 의미 |
|---|---|
400 Bad Request |
페이로드가 잘못되었습니다. 다시 보내도 실패합니다. |
401 Unauthorized |
자격 증명이 잘못되었거나 만료되었습니다. 토큰을 갱신해야 합니다. |
403 Forbidden |
권한이 없습니다. 재시도로 권한이 생기지 않습니다. |
422 Unprocessable Entity |
데이터 유효성 검사가 실패했습니다. 요청 내용을 수정해야 합니다. |
기본 규칙은 간단합니다.
- 서버 상태나 네트워크 문제라면 재시도합니다.
- 클라이언트 요청 문제라면 빠르게 실패합니다.
-
429는 재시도할 수 있지만, 전체 요청 속도를 낮춰야 한다는 신호이기도 합니다. 요청 속도 제한은 재시도 루프보다 상위 계층에서 해결해야 합니다.
지수 백오프와 지터
지수 백오프는 재시도할수록 더 오래 기다리는 방식입니다.
delay = base * 2^retry_count
기본 지연 시간이 500ms라면 지연 시간은 0.5초, 1초, 2초, 4초, 8초로 증가합니다. 지연이 무한히 커지지 않도록 상한을 추가합니다.
delay = min(cap, base * 2^retry_count)
하지만 지수 백오프만으로는 동기화 문제를 해결할 수 없습니다. 5,000개의 클라이언트가 동시에 실패하면 모두 t=0.5초, t=1초, t=2초에 돌아옵니다. 공격 강도는 낮아져도 여전히 요청이 파도처럼 몰립니다.
지터(jitter)는 지연 시간을 무작위화해 동기화를 깨뜨립니다. AWS Architecture Blog의 지수 백오프 및 지터 분석에서는 여러 지터 전략을 비교했고, 풀 지터가 총 호출 수와 완료 시간을 줄이는 데 효과적이라는 결과를 제시했습니다.
풀 지터 공식은 다음과 같습니다.
delay = random_between(0, min(cap, base * 2^retry_count))
지연 시간을 0부터 선택하면 클라이언트가 재시도 창 전체에 분산됩니다. 따라서 서버 부하가 한 시점에 집중되지 않습니다.
AWS 분석에서 비교한 전략은 다음과 같습니다.
-
풀 지터:
0부터 지수 상한까지 무작위 선택 - 균등 지터: 절반은 고정하고 절반은 무작위 선택
- 비상관 지터: 이전 지연 시간을 기반으로 무작위 선택
측정 결과가 없다면 구현이 간단한 풀 지터를 기본값으로 사용하세요.
서버가 지시하면 Retry-After를 따르세요
클라이언트의 백오프는 추정치입니다. 서버는 속도 제한이 풀리는 시점이나 유지보수가 끝나는 시점을 더 정확히 알고 있을 수 있습니다.
Retry-After 헤더는 429와 503 응답에서 초 단위 숫자 또는 HTTP 날짜로 제공됩니다.
HTTP/1.1 429 Too Many Requests
Retry-After: 12
헤더가 있으면 계산한 백오프보다 서버가 지정한 시간을 우선하세요. 다만 다음 제한은 계속 적용해야 합니다.
- 최대 지연 시간
- 최대 재시도 횟수
- 전체 재시도 예산
예를 들어 Retry-After: 86400처럼 비정상적으로 큰 값이 워커를 하루 동안 멈추게 해서는 안 됩니다.
POST 재시도의 전제 조건: 멱등성
GET, PUT, DELETE는 일반적으로 멱등적입니다. 같은 요청을 여러 번 보내도 최종 상태가 동일해야 합니다. 반면 POST는 기본적으로 멱등적이지 않습니다.
예를 들어 POST /v1/payments 요청이 서버에서 처리된 뒤 타임아웃되면, 클라이언트는 실패했다고 판단할 수 있습니다. 이때 같은 요청을 다시 보내면 결제가 두 번 생성될 수 있습니다.
해결책은 멱등성 키입니다. 각 논리적 작업에 대해 클라이언트가 UUID를 생성하고 헤더로 전송합니다. 서버는 첫 응답과 키를 저장한 뒤, 같은 키로 들어온 중복 요청에 저장된 응답을 재생합니다. Stripe의 멱등성 요청이 이 방식으로 동작합니다.
멱등성 키를 사용할 때는 두 가지 규칙을 지켜야 합니다.
- 같은 작업에는 같은 키를 사용합니다. 하나의 결제에 대한 모든 재시도는 같은 키를 사용하고, 새로운 사용자 작업에는 새 키를 사용합니다.
- 첫 전송 전에 키를 생성합니다. 재시도 루프 안에서 키를 만들면 매번 새로운 작업으로 인식됩니다.
호출하는 API가 멱등성 키를 지원하지 않는다면, 멱등적이지 않은 쓰기 작업을 자동으로 재시도하지 마세요. 실패를 알리고 사람이 후속 조치를 결정하도록 해야 합니다.
재시도 예산과 서킷 브레이커
백오프는 재시도 시점을 정하지만, 재시도가 시스템 전체에 얼마나 많은 부하를 추가하는지는 제한하지 않습니다. 장애가 길어지면 재시도 부하가 누적되고, 여러 계층의 재시도가 곱해질 수 있습니다.
예를 들어 API 게이트웨이와 서비스 클라이언트가 각각 3번씩 재시도하면, 사용자 클릭 한 번이 최대 9개의 요청으로 증폭됩니다.
재시도 예산
“요청마다 3번 재시도” 대신 슬라이딩 윈도우를 기준으로 재시도 부하를 제한합니다.
재시도는 전체 트래픽에 최대 10%의 추가 부하만 만들 수 있다.
예산이 소진되면 즉시 실패를 반환합니다. 이 방식은 동시에 많은 요청이 실패해도 재시도 증폭을 제한합니다. Linkerd와 Envoy 모두 재시도 예산 설정을 제공합니다.
서킷 브레이커
서킷 브레이커는 다운스트림별 실패율을 추적합니다. 실패율이 임계값을 넘으면 브레이커를 열고, 네트워크 요청 없이 즉시 실패를 반환합니다.
쿨다운 후에는 소수의 프로브 요청으로 종속성이 복구됐는지 확인합니다. 복구되면 브레이커를 닫고 정상 호출을 재개합니다.
백오프가 재시도 속도를 늦춘다면, 서킷 브레이커는 재시도 자체를 중단합니다. 프로덕션 재시도 설계에서는 두 메커니즘을 함께 사용하는 것이 안전합니다.
Python으로 구현한 프로덕션 준비 예제
다음 예제는 재시도 가능한 상태 필터링, 풀 지터, Retry-After, 멱등성 키, 최대 재시도 횟수를 모두 포함합니다.
import random
import time
import uuid
import requests
RETRYABLE = {429, 502, 503, 504}
BASE = 0.5 # seconds
CAP = 30.0 # ceiling on any single delay
MAX_RETRIES = 5
def create_payment(payload):
idempotency_key = str(uuid.uuid4()) # one key per logical payment
headers = {"Idempotency-Key": idempotency_key}
for retry_count in range(MAX_RETRIES + 1):
try:
resp = requests.post(
"https://api.acmepay.com/v1/payments",
json=payload, headers=headers, timeout=10,
)
if resp.status_code < 400:
return resp.json()
if resp.status_code not in RETRYABLE:
resp.raise_for_status() # 400/401/403/422: fail fast
retry_after = resp.headers.get("Retry-After")
except (requests.ConnectionError, requests.Timeout):
retry_after = None # network fault: fall through to backoff
if retry_count == MAX_RETRIES:
raise RuntimeError("payment failed after all retries")
if retry_after and retry_after.isdigit():
delay = min(CAP, float(retry_after))
else:
delay = random.uniform(0, min(CAP, BASE * 2 ** retry_count))
time.sleep(delay)
핵심은 다음과 같습니다.
- 멱등성 키는 루프 밖에서 한 번만 생성합니다.
-
Retry-After가 있으면 계산된 백오프보다 우선합니다. - 서버가 지정한 지연에도 상한을 적용합니다.
- 재시도할 수 없는 상태 코드는 즉시 예외를 발생시킵니다.
- 연결 오류와 타임아웃은 지수 백오프로 처리합니다.
JavaScript에서는 axios-retry의 retryCondition과 retryDelay 훅으로 같은 구조를 구현할 수 있습니다. 언어가 달라도 의사결정 테이블은 동일하게 유지해야 합니다.
프로덕션 장애 전에 재시도 동작 테스트하기
많은 팀이 성공 경로만 테스트한 뒤 재시도 로직을 배포합니다. 그러면 503 경로는 실제 장애가 발생했을 때 처음 실행됩니다. 실패 시나리오를 의도적으로 만들어 검증하세요.
목 서버로 실패 시뮬레이션하기
Apidog의 스마트 목을 사용하면 /v1/payments 같은 엔드포인트의 응답을 스크립팅할 수 있습니다.
다음 시나리오를 구성해 보세요.
- 첫 두 호출에는
503, 세 번째 호출에는200반환 -
Retry-After: 5와 함께429반환 - 15초 지연을 추가해 클라이언트 타임아웃 유도
클라이언트를 목 URL에 연결하고 각 재시도 시나리오에서 실제 동작을 확인하면 프로덕션 장애 없이 검증할 수 있습니다.
테스트 시나리오로 클라이언트 검증하기
Apidog 테스트 시나리오에서 요청, assertion, 타이밍 검증을 연결하세요.
다음 항목을 검증해야 합니다.
- 최종적으로 요청이 성공하는가?
- 총 소요 시간이 예상한 백오프 범위 안에 있는가?
- 멱등성 키 덕분에 리소스가 정확히 하나만 생성되는가?
이 시나리오를 CI에 연결하면 재시도 로직을 장애 때가 아니라 모든 커밋에서 실행할 수 있습니다.
이는 “재시도를 추가했다”와 “요청 속도 제한 및 부분 장애가 발생한 종속성에서도 클라이언트가 살아남는지 검증했다”의 차이입니다. Apidog를 무료로 다운로드해 클라이언트와 연동되는 실패 목 서버를 약 10분 만에 실행해 보세요.
FAQ
429를 재시도해야 할까요?
네. Retry-After 헤더가 있으면 해당 시간 이상 기다리세요. 헤더가 없으면 지터가 적용된 지수 백오프로 폴백합니다.
다만 반복되는 429를 정상적인 상황으로 간주해서는 안 됩니다. 클라이언트 측 스로틀링, 캐싱 또는 요청량 감소가 필요하다는 신호입니다.
풀 지터란 무엇인가요?
각 재시도 지연 시간을 다음 범위에서 균일하게 무작위로 선택하는 방식입니다.
random(0, min(cap, base * 2^n))
많은 클라이언트가 동시에 재시도하는 파동을 방지할 수 있습니다. AWS 시뮬레이션에서는 일반 백오프와 균등 지터보다 총 호출 수와 완료 시간 측면에서 좋은 결과를 보였습니다.
POST 요청을 재시도해도 안전한가요?
요청이 실제로 멱등적일 때만 안전합니다. POST라면 서버가 중복을 제거할 수 있도록 멱등성 키를 사용해야 합니다.
키가 없으면 타임아웃 후 재시도할 때 결제, 주문 또는 레코드가 중복될 수 있습니다. AI 에이전트가 쓰기 API를 호출할 때도 같은 문제가 발생합니다. 에이전트 오류 복구 패턴에서도 키가 있는 쓰기, 상한이 있는 재시도, 서킷 브레이커를 핵심 패턴으로 다룹니다.
몇 번 재시도해야 할까요?
대부분의 일시적 오류에는 3~5번의 재시도로 충분합니다. 그 이상은 성공률이 크게 개선되지 않는 반면 부하와 지연 시간은 계속 증가합니다.
요청별 재시도 상한을 전역 재시도 예산과 함께 사용하세요. 종속성이 마지막 재시도 후에도 다운 상태라면 추가 재시도보다 서킷 브레이커로 전환해야 합니다.
Top comments (0)