タイムアウトの後にリトライすると何が起きるか
한국어 원문으로 표시합니다.
한 줄 요약
타임아웃은 요청이 실패했다는 뜻이 아니라 결과를 모른다는 뜻입니다. 이 차이를 놓치면 재시도가 중복 결제와 중복 발송을 만듭니다.
왜 이게 필요했나
결제 연동에서 가장 흔한 사고는 이렇습니다.
- 우리가 결제 요청을 보냄
- 3초 타임아웃 — 응답이 안 옴
- "실패했나 보다" 하고 재시도
- 결제사에서는 두 번 승인됨
3번의 판단이 틀렸습니다. 응답을 못 받은 것과 처리가 안 된 것은 다릅니다. 요청은 도착했고, 처리도 됐고, 응답만 유실됐을 수 있습니다.
이걸 분산 시스템에서는 불확실한 결과(indeterminate) 라고 부릅니다. 성공도 실패도 아닌 세 번째 상태이고, 이 상태를 다루는 방법이 따로 있습니다.
재시도해도 되는 것과 안 되는 것
핵심 질문은 "이 연산이 멱등한가" 입니다. 같은 요청을 두 번 보내도 결과가 한 번 보낸 것과 같으면 멱등합니다.
| 연산 | 멱등? | 재시도 |
|---|---|---|
GET /orders/123 |
예 | 자유롭게 |
PUT /orders/123 {status:"PAID"} |
예 (같은 값을 씀) | 안전 |
DELETE /orders/123 |
예 (두 번째는 없음) | 안전 |
POST /orders {…} |
아니오 | 위험 — 멱등키 필요 |
POST /payments {amount:1000} |
아니오 | 위험 |
잔액 차감 balance -= 1000 |
아니오 | 위험 |
HTTP 메서드의 멱등성은 규약이지 보장이 아닙니다. 서버가 PUT 에서
카운터를 증가시키면 그 PUT 은 멱등하지 않습니다. 문서가 아니라
동작을 확인해야 합니다.
멱등키
멱등하지 않은 연산을 안전하게 재시도하려면, 클라이언트가 요청마다 고유한 키를 만들어 보냅니다.
POST /payments
Idempotency-Key: 3f9a1c7e-2b44-4c9d-a1f8-0d6e2b7c5a11
{"order_id": 123, "amount": 1000}
서버는 이 키를 저장해 두고, 같은 키로 다시 오면 새로 처리하지 않고 저장해 둔 결과를 그대로 돌려줍니다. 재시도가 몇 번이든 결제는 한 번입니다.
키를 만들 때 주의할 점이 있습니다. 재시도할 때 같은 키를 써야 합니다. 매 시도마다 새 UUID 를 만들면 아무 의미가 없습니다. 키는 "이 비즈니스 행위" 에 대해 하나이지 "이 HTTP 요청" 에 대해 하나가 아닙니다.
재시도 정책
무작정 재시도하면 장애를 키웁니다. 하류가 느려서 타임아웃이 났는데 전부가 즉시 재시도하면 부하가 배로 늘어 완전히 무너집니다.
- 지수 백오프 — 1초, 2초, 4초, 8초. 간격을 늘려 숨 쉴 틈을 줍니다.
- 지터 — 여기에 무작위를 섞습니다. 안 그러면 모든 클라이언트가 똑같은 시각에 동시에 재시도합니다(thundering herd).
- 상한 — 최대 횟수와 최대 총 시간을 정합니다. 무한 재시도는 금지.
- 회로 차단기 — 연속 실패가 임계를 넘으면 한동안 아예 시도하지 않습니다. 하류가 회복할 시간을 주는 장치입니다.
그리고 어떤 오류를 재시도할지 구분합니다.
| 응답 | 재시도 |
|---|---|
| 연결 거부, 타임아웃 | 예 (멱등하거나 멱등키가 있을 때) |
| 429 Too Many Requests | 예 — Retry-After 를 지켜서 |
| 500, 502, 503, 504 | 대개 예 |
| 400, 422 (요청이 잘못됨) | 아니오 — 몇 번을 보내도 같다 |
| 401, 403 | 아니오 — 자격증명을 고쳐야 한다 |
조사할 때의 신호
중복이 의심되면 이렇게 찾습니다.
SELECT order_id, count(*) FROM payments
GROUP BY 1 HAVING count(*) > 1;
그리고 그 중복 건들의 created_at 간격을 봅니다. 간격이 재시도 백오프와
비슷하다면(1초, 2초, 4초) 원인은 거의 확정입니다.
재시도가 장애를 키우는 방식
재시도는 잘 쓰면 일시적인 실패를 감춰 주고, 잘못 쓰면 작은 장애를 큰 장애로 키운다. 키우는 경로가 정해져 있다.
층마다 재시도하면 곱해진다. 클라이언트가 3번, 게이트웨이가 3번, 서비스가 3번 재시도하면 한 번의 사용자 요청이 상류에 27번의 부하로 도착한다. 상류가 느려서 시작된 일이 상류를 더 느리게 만든다. 원칙은 한 층에서만 재시도하는 것이고, 대개 사용자와 가장 가까운 층이 그 자리다.
같은 간격으로 재시도하면 파도가 된다. 장애 중 실패한 요청들이 정확히 1초 뒤에 한꺼번에 돌아온다. 회복되려던 서비스가 그 파도에 다시 넘어진다. 지수 백오프에 무작위 지터를 섞는 이유가 이것이다.
delay = min(cap, base * 2 ** attempt) * (0.5 + random.random() * 0.5)
회로 차단기가 없으면 재시도는 멈추지 않는다. 상류가 완전히 죽었을 때는 재시도가 아무 의미가 없고 자원만 태운다. 실패율이 임계를 넘으면 잠시 시도 자체를 멈추고, 가끔 한 건만 흘려보내 회복을 확인한다. 빨리 실패하는 것이 사용자에게도 낫다 — 30초 기다린 뒤의 오류보다 즉시 오는 오류가 재시도하기 쉽다.
기한(deadline)을 전파한다. 사용자가 5초를 기다린다면, 그 요청이 지나가는 모든 구간이 남은 시간을 알아야 한다. 남은 시간이 200ms 인데 3초짜리 호출을 새로 시작하는 것은 낭비다. gRPC 의 deadline 이나 헤더로 넘긴 만료 시각을 각 층이 확인한다.
재시도할 수 없는 오류를 재시도하지 않는다. 400·401·404·409 는 몇 번을 다시
보내도 같다. 재시도할 값이 있는 것은 429·502·503·504 와 연결 오류뿐이고,
429 에는 Retry-After 가 붙어 오므로 그 값을 지킨다.
재시도한 요청에 표시를 남긴다. 로그와 헤더에 시도 번호를 넣어 두면, 장애 조사에서 "요청이 3배 늘었다" 가 사용자가 늘어난 것인지 재시도인지 바로 갈린다.
현장에서 만나는 모습
- 월말 정산에서 금액이 안 맞는다 → 타임아웃 재시도로 생긴 중복.
- 알림이 두세 번 간다 → 발송 API 에 멱등키가 없다.
- 하류가 느려지자 전체가 무너졌다 → 백오프와 회로 차단기가 없다.
이어지는 실습에서 할 것
처리는 하고 응답만 잃어버리는 결제 API 를 상대로 세 번 결제해 봅니다.
멱등키 없이 주문 4건 → 기록 7건
비즈니스 행위당 키 하나 주문 4건 → 기록 4건
시도마다 새 키 주문 4건 → 기록 7건 ← 키를 붙였는데도 소용없다
세 번째가 이 실습의 핵심입니다. 키를 붙였다는 사실만으로는 아무것도 보장되지 않습니다.