재시도 대상
네트워크 오류, timeout, 429, 500, 502, 503, 504처럼 시간이 지나면 성공할 수 있는 실패다.
하위 시스템 회복 가능성이 있고, retry budget이 남아 있을 때분류: Layer 6 - 운영 심화: 관측성 & 복원력 | 선수지식: Queue/Worker Basics
Retry는 실패한 작업을 다시 시도하는 전략이다. Backoff는 재시도 간격을 점점 늘려 회복 중인 시스템을 더 밀어붙이지 않게 하는 전략이다. Jitter는 그 재시도 시점에 무작위성을 섞어 여러 클라이언트가 같은 순간에 몰리지 않게 하는 장치다. Idempotency는 같은 요청이 여러 번 도착해도 최종 부작용이 한 번만 일어나도록 만드는 설계다.
이 문서의 핵심은 네트워크 일시 장애를 “그냥 다시 보내기”로 다루지 않는 것이다. 일시 실패는 재시도로 흡수하되, 동시 재시도는 backoff와 jitter로 분산하고, 중복 실행은 idempotency key로 안전하게 만든다.
네트워크 요청은 실패할 수 있다.
서버가 실제로 죽었을 수도 있고, 과부하라서 잠깐 늦을 수도 있고, 요청 처리는 성공했지만 응답만 유실됐을 수도 있다.
이 세 경우를 모두 같은 error로 보고 즉시 재시도하면 장애가 줄지 않고 커진다.
가장 위험한 오해는 timeout을 “서버가 처리하지 않았다”는 뜻으로 해석하는 것이다. Timeout은 호출자가 기다리기로 한 시간이 끝났다는 뜻이지, 하위 시스템이 작업을 실행하지 않았다는 증거가 아니다. 결제 API가 카드 승인을 끝낸 뒤 응답만 늦어졌다면, 클라이언트의 재시도는 두 번째 결제가 될 수 있다.
Queue/Worker에서도 같은 문제가 생긴다. at-least-once delivery는 메시지 유실을 줄여주지만, 같은 메시지가 두 번 이상 처리될 수 있다는 대가를 가진다. 따라서 worker 재시도는 “작업을 다시 실행해도 괜찮은가”를 먼저 묻고, 괜찮지 않다면 idempotency나 deduplication을 먼저 둬야 한다.
선행 방식의 한계는 단순했다. “실패하면 바로 한 번 더 보낸다”는 규칙은 단일 사용자, 단일 요청에서는 직관적이다. 하지만 서비스 간 호출과 worker가 섞인 시스템에서는 같은 규칙이 세 가지 문제를 만든다.
첫째, 일시 장애에는 도움이 되지만 영구 장애에는 의미가 없다. 422 validation error, 잘못된 인증 정보, 존재하지 않는 리소스는 같은 요청을 반복해도 성공하지 않는다.
둘째, 여러 클라이언트가 같은 정책으로 동시에 재시도하면 thundering herd가 된다. 서비스가 복구되는 순간 1,000개의 클라이언트가 같은 1초 뒤에 다시 요청하면 복구 중인 서버를 다시 쓰러뜨릴 수 있다.
셋째, 부작용이 있는 요청은 중복 실행될 수 있다. 주문 생성, 결제 승인, 포인트 차감, 이메일 발송은 “요청이 두 번 들어왔다”가 곧 “효과가 두 번 발생했다”로 이어질 수 있다.
그래서 retry/backoff/idempotency는 한 묶음으로 이해해야 한다. Retry는 일시 실패를 흡수한다. Backoff와 jitter는 재시도가 하위 시스템을 공격하지 않게 만든다. Idempotency는 중복 도착이 중복 부작용으로 바뀌지 않게 만든다.
flowchart LR Fail["일시 실패 또는 timeout"] --> Retry["Retry: 다시 시도할지 판단"] Retry --> Herd["동시 재시도 증폭 위험"] Herd --> Backoff["Backoff: 대기 시간 증가"] Backoff --> Jitter["Jitter: 재시도 시점 분산"] Jitter --> SideEffect["부작용 있는 작업?"] SideEffect -->|"예"| Idem["Idempotency Key로 중복 실행 방지"] SideEffect -->|"아니오"| Return["성공 또는 최종 실패 반환"] Idem --> Return
Retry는 실패한 작업을 제한된 횟수 안에서 다시 실행하는 것이다. 목표는 일시적 네트워크 순단, 503, 429, 짧은 lock contention처럼 시간이 지나면 성공할 수 있는 실패를 흡수하는 것이다. Retry는 실패를 숨기는 마법이 아니라 하위 시스템에 추가 요청을 보내는 행위다.
Timeout은 호출자가 더 이상 기다리지 않겠다고 정한 시간 제한이다. Timeout이 발생했다는 사실은 “응답을 받지 못했다”는 뜻이지 “하위 작업이 실행되지 않았다”는 뜻이 아니다. 이 구분이 idempotency가 필요한 이유의 출발점이다.
Exponential Backoff는 재시도 간격을 base * 2^attempt처럼 지수적으로 늘리는 방식이다.
예를 들어 200ms, 400ms, 800ms, 1.6s처럼 대기 시간이 커진다.
대기 시간이 무한히 커지지 않도록 cap을 둔다.
Jitter는 backoff로 계산한 대기 시간 안에서 랜덤한 시점을 고르는 방식이다.
Full jitter는 0 ~ backoff 사이에서 랜덤 값을 뽑는다.
동일한 retry 정책을 가진 클라이언트들이 같은 시각에 몰리는 문제를 줄인다.
Retry Budget은 전체 트래픽 중 retry가 차지할 수 있는 비율이나 횟수의 상한이다. 예를 들어 “정상 요청 100개당 retry는 10개까지만 허용”하거나 token bucket(초당 채워지는 토큰 수만큼만 retry를 허용하는 카운터)으로 초당 retry 수를 제한한다. Budget이 없으면 장애 중 retry가 정상 요청보다 많아질 수 있다.
Idempotency는 같은 요청을 여러 번 실행해도 최종 상태가 한 번 실행한 것과 같다는 성질이다.
DELETE /users/123은 여러 번 호출해도 최종 상태가 “사용자 없음”이므로 보통 멱등하다.
POST /orders는 여러 번 호출하면 주문이 여러 개 생길 수 있으므로 기본적으로 멱등하지 않다.
Idempotency Key는 부작용이 있는 요청마다 붙이는 고유 식별자다. 서버는 같은 key가 다시 들어오면 작업을 새로 실행하지 않고, 이전 처리 결과를 재사용하거나 “이미 처리 중”이라고 응답한다. 결제, 주문 생성, 포인트 차감처럼 중복 실행 비용이 큰 API에서 특히 중요하다.
Circuit Breaker는 실패율이 일정 기준을 넘으면 호출을 잠시 차단하는 패턴이다. Retry가 “다시 시도”라면 circuit breaker는 “지금은 시도하지 않는다”에 가깝다. 장기 장애에서 retry가 시스템 자원을 계속 소모하지 않게 한다.
DLQ(Dead Letter Queue)는 worker가 여러 번 처리에 실패한 메시지를 격리하는 큐다. 무한 재시도로 worker를 계속 점유하는 대신, 실패 메시지를 별도 큐로 보내 원인 분석과 수동 재처리를 가능하게 한다. DLQ는 실패를 해결하는 장치가 아니라 실패를 본 처리 흐름에서 분리하는 장치다. Dead-lettering은 메시지를 DLQ로 보내는 행위다. 즉 “실패를 무시한다”가 아니라 “정상 처리 흐름에서 격리해 별도로 다룬다”는 의미다.
Retry의 전제는 실패가 일시적이라는 것이다. 네트워크 연결이 순간적으로 끊겼거나, 하위 서비스가 짧게 과부하였거나, 429 응답이 온 경우에는 잠깐 기다린 뒤 다시 시도하면 성공할 수 있다. 반대로 요청 자체가 잘못된 경우에는 retry가 낭비다.
네트워크 오류, timeout, 429, 500, 502, 503, 504처럼 시간이 지나면 성공할 수 있는 실패다.
하위 시스템 회복 가능성이 있고, retry budget이 남아 있을 때400, 401, 403, 404, 422처럼 입력, 권한, 인증, 존재하지 않는 리소스 문제다.
같은 요청을 반복해도 같은 실패가 날 때POST, 메시지 처리, 결제 승인처럼 부작용이 있는 작업이다. idempotency가 있어야 안전하다.
중복 실행 비용이 크지만 일시 실패를 흡수해야 할 때퀴즈
힌트: 재시도가 해결할 수 있는 실패인지, 같은 입력이 계속 실패할지 구분한다.
아니요. 422는 요청 내용이 유효하지 않다는 뜻이므로 같은 payload를 반복해도 성공하지 않는다. 결제처럼 부작용이 있는 POST는 retry 전에 요청 검증과 idempotency key가 먼저다.
Retry 정책은 “몇 번 재시도할까”보다 “무엇을 재시도하지 않을까”가 먼저다. 재시도 금지 대상을 좁혀두지 않으면 4xx나 validation failure까지 반복되어 로그, queue, 하위 API를 오염시킨다.
Timeout은 retry와 항상 같이 설계한다. Timeout이 너무 짧으면 정상 처리 중인 요청을 실패로 오판하고 retry를 늘린다. Timeout이 너무 길면 사용자는 이미 포기했는데 서버 자원은 계속 묶인다.
중요한 기준은 “한 번의 시도 timeout”과 “전체 요청 deadline”을 분리하는 것이다. 예를 들어 사용자 결제 API의 전체 deadline이 8초라면, 각 시도 timeout 2초, retry 2회, backoff 대기 합계 1~2초 정도로 예산을 맞출 수 있다. 반대로 각 시도 timeout 5초에 retry 3회를 허용하면 최악의 경우 사용자는 20초 가까이 기다리거나, 상위 요청이 끝난 뒤에도 내부 retry가 계속될 수 있다.
| 항목 | 의미 | 예시 |
|---|---|---|
| 시도 timeout | 단일 하위 호출을 기다리는 최대 시간 | PG API 한 번을 2초까지 대기 |
| 전체 deadline | 사용자 요청 전체가 끝나야 하는 시간 | 결제 요청은 8초 안에 성공 또는 실패 |
| backoff budget | retry 사이 대기에 쓸 수 있는 시간 | 200ms + 500ms + jitter |
| retry budget | 전체 트래픽에서 retry가 차지할 수 있는 상한 | 정상 요청 100개당 retry 10개 이하 |
Timeout을 정의하지 않은 retry는 위험하다. 하위 호출이 오래 걸리는 동안 상위 계층이 또 retry를 시작하면 같은 작업이 겹쳐 실행될 수 있다. Timeout을 너무 공격적으로 줄인 retry도 위험하다. 하위 시스템이 처리 중인 요청을 계속 끊어 retry storm을 만들 수 있다.
재시도는 성공 가능성을 높이지만, 동시에 하위 시스템에 요청을 추가한다. 하위 시스템이 과부하라면 즉시 retry는 회복 시간을 빼앗는다. Backoff는 실패 직후가 아니라 조금 기다린 뒤 다시 시도하게 해서 회복 시간을 준다.
기본 공식은 다음처럼 잡을 수 있다.
delay = min(cap, base * 2^attempt)full_jitter_delay = random(0, delay)base는 첫 대기 시간이다.
attempt는 몇 번째 재시도인지 나타낸다.
cap은 최대 대기 시간이다.
cap이 없고 base = 1초라면 10번째 재시도 대기는 약 17분, 20번째 재시도 대기는 약 12일이 된다. 그래서 backoff에는 항상 cap과 전체 deadline이 같이 있어야 한다.
Full jitter를 적용하면 같은 정책을 가진 클라이언트도 서로 다른 시점에 재시도한다.
고정 backoff는 1초 뒤 모두 재시도를 만들고, 지수 backoff만으로도 1초, 2초, 4초 뒤 모두 재시도라는 큰 파동이 남는다.
Jitter는 이 파동을 여러 작은 요청으로 쪼갠다.
하위 API가 3초 동안 503을 반환하다가 복구된다고 가정하자. 1,000개 클라이언트가 같은 요청을 하고, 모두 retry 정책을 가진다.
| 정책 | 재시도 시점 | 복구 직후 부하 모양 | 직관 |
|---|---|---|---|
| 즉시 retry 3회 | 실패 직후 거의 즉시 3번 | 짧은 시간에 최대 3,000개 추가 요청 | 하위 API가 회복할 시간을 주지 않는다 |
| 고정 1초 retry | 1초, 2초, 3초에 모두 재시도 | 매초 1,000개 요청이 같은 순간 몰림 | 복구 시점과 겹치면 다시 과부하 |
| 지수 backoff만 | 1초, 2초, 4초에 모두 재시도 | 4초 시점에 1,000개가 한 번에 몰림 | 파동 간격은 넓어지지만 동기화가 남음 |
| full jitter | 0 | 몇 초 구간에 재시도가 퍼짐 | 회복 중인 서버가 작은 묶음으로 처리 |
정확한 초당 요청 수는 난수와 구현에 따라 달라진다. 하지만 핵심은 분명하다. Backoff는 retry의 빈도를 낮추고, jitter는 retry의 동시성을 낮춘다. 둘 중 하나만 있으면 부족하다.
function fullJitterDelay(attempt: number, baseMs = 200, capMs = 5000): number { const exponential = Math.min(capMs, baseMs * 2 ** attempt); return Math.random() * exponential;}
async function retryWithBackoff<T>( operation: () => Promise<T>, options = { maxRetries: 3 },): Promise<T> { for (let attempt = 0; attempt <= options.maxRetries; attempt++) { try { return await operation(); } catch (error) { if (attempt === options.maxRetries) throw error;
const delayMs = fullJitterDelay(attempt); await new Promise((resolve) => setTimeout(resolve, delayMs)); } }
throw new Error("unreachable");}이 코드는 학습용 뼈대다. 실제 서비스에서는 retry할 error인지 먼저 분류하고, 전체 deadline을 넘겼는지 확인하며, retry마다 로그와 metric을 남긴다. 또한 부작용이 있는 작업에는 idempotency key를 같이 전달한다.
Retry amplification은 호출 체인의 여러 계층이 각자 retry하면서 하위 시스템 부하가 곱셈으로 늘어나는 현상이다. 단일 서비스에서 retry 3회는 작아 보이지만, 여러 서비스가 같은 정책을 가지면 빠르게 폭발한다.
A -> B -> C
A가 1회 요청 B가 C 호출을 최대 3회 시도 C는 최대 3개 요청을 받음
A도 B 호출을 최대 3회 시도 A 3회 * B 3회 = C는 최대 9개 요청을 받음3단계라면 9배지만, 5단계 호출 체인에서 각 계층이 3회씩 retry하면 최하위 시스템은 최대 3^5 = 243개의 요청을 받을 수 있다.
하위 데이터베이스가 이미 과부하인 상황에서 이 곱셈은 장애 복구가 아니라 장애 증폭이다.
따라서 retry 위치를 정해야 한다. 일반적으로 사용자 요청의 가장 바깥쪽 계층이나, 실패를 가장 잘 해석할 수 있는 계층 하나에 retry를 둔다. 중간 서비스는 하위 실패를 상위로 전달하고, 자체 retry를 하지 않는 편이 안전한 경우가 많다.
| 설계 | 장점 | 위험 |
|---|---|---|
| 모든 계층 retry | 각 서비스가 독립적으로 복구 시도 | retry amplification, timeout 중첩, 원인 추적 어려움 |
| 가장 바깥 계층 retry | 전체 요청 단위로 예산 관리 쉬움 | 중간 서비스가 일시 실패를 너무 빨리 포기할 수 있음 |
| 하위 SDK retry만 사용 | 검증된 기본 정책 활용 | 상위 deadline과 idempotency 요구를 놓칠 수 있음 |
| endpoint별 retry 정책 | 실패 원인과 비용에 맞춤 | 정책 관리가 복잡해짐 |
Retry budget은 이 증폭을 숫자로 막는 장치다. 예를 들어 분당 정상 요청이 10,000개라면 retry를 분당 1,000개 이하로 제한할 수 있다. 그 이상 실패가 늘면 retry를 계속 늘리는 대신 circuit breaker, fallback, queue 격리로 전환한다.
Idempotency는 retry의 안전벨트다. Retry는 같은 요청이 두 번 도착할 가능성을 만든다. Idempotency는 그 두 번의 도착이 같은 부작용을 두 번 만들지 않게 한다.
HTTP 메서드만으로 멱등성을 판단하면 위험하다.
GET은 보통 안전하지만, 실제 구현이 조회수 증가나 감사 로그를 비즈니스 부작용으로 다루면 별도 판단이 필요하다.
PUT은 같은 값을 덮어쓰면 멱등하지만, “현재 값에 10을 더한다” 같은 상대 변경이면 멱등하지 않다.
POST는 보통 새 리소스를 만들기 때문에 기본 비멱등이다.
GET, PUT, DELETE는 같은 요청을 반복해도 최종 리소스 상태가 같도록 설계하기 쉽다.
읽기, 전체 덮어쓰기, 이미 삭제된 리소스 재삭제POST와 상대적 PATCH는 호출 횟수만큼 리소스 생성, 승인, 차감, 증가가 일어날 수 있다.
주문 생성, 결제 승인, 포인트 차감, 이메일 발송HTTP 메서드와 별개로 business key나 idempotency key로 중복 실행을 막는다.
동일 주문은 한 번만 결제되어야 하는 경우다음 상황을 손으로 따라가 보자.
10:00:00 클라이언트 -> 서버: POST /payments { orderId: O-100, amount: 50000 }10:00:01 서버 -> PG: 카드 승인 요청10:00:03 PG: 승인 성공10:00:05 서버: PG 응답을 기다리다 timeout10:00:06 클라이언트: 실패로 보고 같은 결제를 다시 POST10:00:07 서버 -> PG: 두 번째 카드 승인 요청클라이언트 입장에서는 “첫 요청이 실패했으니 다시 보냈다”가 맞다.
하지만 PG 입장에서는 첫 번째 승인이 이미 성공했다.
서버가 orderId나 idempotency key로 중복을 막지 않으면 두 번째 승인도 정상 요청처럼 처리된다.
이 반례가 보여주는 것은 retry의 문제가 아니다. Retry는 일시 실패를 다루기 위해 필요하다. 문제는 부작용이 있는 요청에 중복 방어 없이 retry를 허용한 것이다.
Idempotency key는 “이 요청은 논리적으로 같은 요청이다”를 서버에 알려주는 값이다. 클라이언트는 부작용이 있는 요청을 만들 때 key를 생성하고, retry할 때 같은 key를 다시 보낸다. 서버는 key와 요청 fingerprint를 저장해 같은 key가 다시 들어왔을 때 새 작업을 실행하지 않는다. 여기서 fingerprint는 요청 본문을 정규화해 해시한 값이다. 같은 key에 다른 payload가 들어오면 “같은 요청의 retry”가 아니라 “key 재사용 버그”로 보아야 한다.
flowchart TD Request["POST /payments + Idempotency-Key"] --> Check["key 존재 여부 확인"] Check -->|"없음"| Lock["processing 상태를 원자적으로 기록"] Lock --> Execute["결제 승인 실행"] Execute --> Store["status code와 response 저장"] Store --> Return1["응답 반환"] Check -->|"완료됨"| Return2["저장된 응답 반환"] Check -->|"처리 중"| Conflict["409 또는 processing 응답"] Check -->|"같은 key 다른 payload"| Reject["422 또는 충돌 응답"]
좋은 idempotency 저장소는 세 가지를 기억한다. 첫째, key와 요청 fingerprint를 같이 저장한다. 같은 key로 다른 payload가 들어오면 재시도가 아니라 버그나 공격일 수 있다.
둘째, 처리 결과를 저장한다. 성공 응답뿐 아니라 실패 응답도 일정 시간 저장해야 같은 요청의 retry가 일관된 결과를 받는다. 예를 들어 결제 API가 첫 요청에서 PG 승인을 성공시키고 201 응답 body까지 만들었다면, 같은 key의 두 번째 요청에는 PG를 다시 호출하지 않고 저장된 status code와 response를 돌려준다. 반대로 첫 요청이 500으로 끝났다면 그 실패도 일정 시간 저장해 같은 retry가 매번 다른 결과를 만들지 않게 한다.
셋째, 처리 중 상태를 원자적으로 기록한다. 두 요청이 거의 동시에 들어왔을 때 둘 다 “key가 없다”고 판단하면 race condition이 생긴다.
| 방식 | 예시 | 적합한 경우 | 주의사항 |
|---|---|---|---|
| UUID v4 | 550e8400-e29b-41d4-a716 | 클라이언트가 서버 맥락을 모르는 범용 API | 완전 랜덤이라 DB 인덱스 locality가 낮을 수 있음 |
| UUID v7 | 01960000-0000-7xxx-xxxx | 쓰기 부하가 높고 시간 순 정렬이 유리한 저장소 | 시간 정보 노출을 민감하게 보는 환경에서는 검토 필요 |
| 비즈니스 키 | orderId:userId:payment | 같은 비즈니스 작업을 한 번만 허용해야 할 때 | key 구성 규칙을 잘못 잡으면 충돌 또는 누락 발생 |
| 복합 키 | uuid:userId:operation | 추적성과 고유성을 같이 원할 때 | key 길이와 저장소 인덱스 비용 증가 |
결제처럼 “동일 주문은 한 번만 승인”이라는 규칙이 강하면 business key가 중요하다.
클라이언트가 매 retry마다 새 UUID를 만들면 서버는 논리적으로 같은 결제를 서로 다른 요청으로 본다.
따라서 retry할 때 같은 idempotency key를 유지해야 하고, 서버는 externalOrderId 같은 business key로도 한 번 더 방어하는 편이 안전하다.
Idempotency record는 무한히 보관할 수 없다. TTL을 둬야 저장소가 커지지 않는다. 하지만 TTL이 너무 짧으면 늦은 retry가 새 요청처럼 처리된다.
예를 들어 결제 retry SLA가 최대 6시간인데 idempotency TTL이 30분이면, 네트워크 복구 후 늦게 도착한 retry가 새 결제로 처리될 수 있다. 반대로 TTL이 너무 길면 저장소 비용과 개인정보 보존 문제가 커진다. 실무에서는 비즈니스 재시도 창, 환불/취소 정책, audit 요구사항을 기준으로 TTL을 정한다.
TTL이 지난 뒤에도 도메인 규칙상 중복이 치명적인 작업은 business key로 한 번 더 막는다. 예를 들어 externalOrderId + merchantId에 unique constraint를 두면 idempotency record가 만료된 뒤 늦은 retry가 와도 같은 주문에 두 번째 결제를 만들지 못한다. 이때 idempotency key는 “재시도 응답을 재사용하는 키”이고 business key는 “도메인에서 한 번만 허용되는 작업을 식별하는 키”다.
Idempotency key를 Redis에 저장할 때 가장 흔한 실수는 GET -> 처리 -> SET 순서다.
두 요청이 동시에 들어오면 둘 다 GET에서 비어 있다고 보고 둘 다 결제를 실행할 수 있다.
처리 시작 상태를 원자적으로 잡아야 한다.
async function executeIdempotent<T>( key: string, ttlSeconds: number, operation: () => Promise<T>,): Promise<T | { status: "processing" }> { const lockKey = `idempotency:${key}`; const acquired = await redis.set( lockKey, "processing", "EX", ttlSeconds, "NX", );
if (!acquired) { const cached = await redis.get(lockKey); if (cached && cached !== "processing") return JSON.parse(cached); return { status: "processing" }; }
try { const result = await operation(); await redis.set(lockKey, JSON.stringify(result), "EX", ttlSeconds); return result; } catch (error) { await redis.del(lockKey); throw error; }}이 코드도 완전한 프로덕션 구현은 아니다. 실패 응답을 저장할지, 처리 중 응답을 409로 보낼지, operation이 성공했지만 결과 저장에 실패하면 어떻게 복구할지 결정해야 한다. 학습 포인트는 하나다. 중복 방어는 “조회 후 저장”이 아니라 “먼저 원자적으로 점유”해야 한다.
Queue/Worker에서는 retry가 HTTP 호출보다 더 자연스럽게 보인다. Worker가 메시지 처리에 실패하면 메시지를 삭제하지 않고 다시 보이게 만들 수 있다. 하지만 queue도 중복 실행을 피하지 못한다.
SQS 같은 큐에서는 visibility timeout 동안 메시지가 다른 worker에게 보이지 않는다. Worker가 그 안에 처리와 삭제를 끝내면 성공이다. Worker가 실패하거나 visibility timeout 안에 삭제하지 못하면 메시지는 다시 visible 상태가 되어 다른 worker가 처리할 수 있다.
이 구조는 메시지 유실을 줄이지만, 같은 메시지가 여러 번 실행될 수 있다는 뜻이다.
따라서 worker는 message id만 믿지 말고 business key로 idempotency를 구현해야 한다.
예를 들어 orderId + eventType을 처리 기록으로 남기면 같은 주문의 같은 이벤트가 두 번 들어와도 두 번째 실행을 건너뛸 수 있다.
DLQ는 무한 retry를 막는 마지막 격리 지점이다. 특정 메시지가 5회 실패했다면 그 메시지를 본 큐에서 계속 재시도하지 않고 DLQ로 보낸다. 이때 DLQ는 실패 원인을 해결하지 않는다. Poison pill(형식 오류, 스키마 불일치, 영구 비즈니스 오류처럼 처리할 때마다 실패하는 메시지)을 격리해서 정상 메시지가 계속 처리되게 만드는 역할을 한다.
| Queue 개념 | 재시도와의 관계 | 실패 신호 |
|---|---|---|
| Visibility Timeout | 실패한 메시지가 언제 다시 보일지 결정 | 처리 시간이 timeout보다 길어 중복 처리 발생 |
| Max Receive Count | 몇 번 실패하면 DLQ로 보낼지 결정 | 너무 낮으면 일시 장애도 DLQ로 밀림 |
| DLQ | 반복 실패 메시지를 격리 | DLQ depth가 계속 증가 |
| Business Idempotency | 같은 작업의 중복 실행 방지 | 같은 주문, 같은 이벤트의 처리 기록이 여러 개 |
Backoff는 queue에서도 필요하다. 실패 즉시 메시지를 다시 보이게 만들면 같은 worker들이 같은 poison pill을 계속 집어간다. receive count가 올라갈수록 visibility timeout을 30초, 60초, 120초처럼 늘리면 정상 메시지를 처리할 여유가 생긴다.
Retry는 실패가 일시적일 때 유효하다. 하위 서비스가 수분 이상 완전히 다운되었거나, 실패율이 50%를 넘는 상황에서는 retry가 성공 가능성을 크게 높이지 못한다. 그때는 빨리 실패하고 자원을 보호하는 쪽이 낫다.
Circuit breaker는 세 상태로 생각할 수 있다.
Closed: 정상 호출을 통과시키고 실패율을 측정한다.Open: 실패율이 기준을 넘으면 호출을 즉시 거부한다.Half-Open: 일정 시간이 지난 뒤 소수 요청만 보내 회복 여부를 확인한다.Circuit breaker는 retry를 대체하기도 하고 보완하기도 한다. 짧은 순단에는 retry + backoff가 맞다. 장기 장애에는 circuit breaker가 맞다. 장애 중 사용자에게 대체 응답이나 축소 기능을 줄 수 있다면 fallback도 함께 쓴다. Fallback은 “성공한 척”이 아니라 캐시 응답, 읽기 전용 모드, 일부 기능 비활성화처럼 빠르게 실패 비용을 낮추는 패턴이다. Circuit breaker가 Open에서 오래 머물면 장애가 끝난 뒤에도 호출이 차단될 수 있다. 그래서 Half-Open에서 소수 probe 요청을 보내고, 몇 번 연속 성공하면 닫는 recovery threshold(회복 판정 기준)를 둔다.
| 상황 | 더 적합한 패턴 | 이유 |
|---|---|---|
| 실패율 1~5%, 짧은 네트워크 순단 | Retry + Backoff + Jitter | 잠깐 기다리면 성공 가능성이 높음 |
| 429가 늘고 하위 서비스가 rate limit 중 | Backoff + Retry Budget | 성공 가능성은 있지만 재시도 속도 제한 필요 |
| 실패율 30% 이상, 복구 여부 불확실 | Retry + Circuit Breaker | 일부 retry는 허용하되 폭발은 차단 |
| 실패율 50% 이상, 장기 장애 | Circuit Breaker + Fallback | retry가 자원 낭비가 될 가능성이 큼 |
| 결제/주문 생성 timeout | Idempotency Key + 제한된 Retry | 성공 여부가 불명확하므로 중복 방어가 먼저 |
결제 API를 예로 전체 흐름을 연결해 보자. 요구사항은 “일시적 PG 장애는 흡수하되, 결제는 한 번만 승인한다”이다.
orderId에 묶인 idempotency key를 만든다.Idempotency-Key와 payload fingerprint를 저장한다.externalOrderId에 서로 다른 key가 여러 개 생기면 중복 결제 의심 신호로 본다.수치로 보면 다음과 같다.
| 항목 | 값 | 이유 |
|---|---|---|
| 전체 deadline | 8초 | 사용자가 기다릴 수 있는 상한 |
| 시도 timeout | 2초 | PG 지연을 빠르게 감지 |
| max retry | 2회 | 원 요청 포함 최대 3회 |
| backoff | 200ms, 400ms 기반 full jitter | 복구 시간을 주되 deadline 안에 제한 |
| idempotency TTL | 비즈니스 재시도 창보다 길게 | 늦은 retry가 새 결제가 되지 않게 |
이 설계의 핵심은 retry 횟수가 아니다. 성공 여부가 애매한 timeout을 idempotency로 감싸고, retry가 하위 PG를 밀어붙이지 않게 제한하는 것이다.
서비스 체인이 Web -> Order -> Payment -> PG로 이어진다고 하자.
각 계층이 하위 호출을 최대 3회 시도하면 PG가 받을 수 있는 요청 수는 다음과 같다.
| 계층 | retry 위치 | PG 최대 요청 수 |
|---|---|---|
| Web만 retry | Web이 Order 전체를 최대 3회 | 3 |
| Web + Order retry | Web 3회 * Order 3회 | 9 |
| Web + Order + Payment retry | 3 _ 3 _ 3 | 27 |
PG 장애는 Payment 계층에서 가장 가까이 보인다. 하지만 Payment가 retry하고, Order도 retry하고, Web도 retry하면 PG에는 같은 결제 시도가 27번까지 도착할 수 있다. 이때 idempotency key가 없으면 중복 결제 위험이고, key가 있어도 PG와 내부 저장소는 27번의 중복 요청을 처리해야 한다.
이 예시에서 더 나은 정책은 하나의 계층에 retry 책임을 모으고, 나머지는 실패를 빠르게 전달하는 것이다. 또한 retry budget으로 PG retry 총량을 제한해야 한다.
| 개념 A | 개념 B | 차이점 |
|---|---|---|
| Retry | Backoff | Retry는 다시 시도하는 행위이고, backoff는 다시 시도하기 전 대기 시간을 조절하는 방식이다. |
| Backoff | Jitter | Backoff는 간격을 늘리고, jitter는 같은 간격에 몰리지 않도록 무작위성을 더한다. |
| Timeout | Retry | Timeout은 기다림의 한계이고, retry는 그 실패를 다시 시도할지의 정책이다. |
| Idempotency | Deduplication | Idempotency는 같은 요청의 결과를 같게 만드는 설계이고, deduplication은 중복 입력을 찾아 제거하는 구현 기법이다. |
| Idempotency Key | Business Key | Idempotency key는 요청 재시도 식별자이고, business key는 주문/결제 같은 도메인 작업 식별자다. 둘을 함께 쓰면 더 안전하다. |
| Retry Budget | Rate Limit | Retry budget은 실패 복구용 추가 요청의 상한이고, rate limit은 전체 요청 속도의 상한이다. |
| Circuit Breaker | Retry | Circuit breaker는 실패가 많을 때 호출을 멈추고, retry는 제한된 조건에서 다시 호출한다. |
| DLQ | Retry | DLQ는 반복 실패 메시지를 격리하고, retry는 같은 메시지를 다시 처리한다. |
긴 on-call 절차보다 먼저 익힐 것은 실패 신호다. 운영 중 어떤 현상을 보면 retry, backoff, idempotency 중 어디가 깨졌는지 가설을 세울 수 있어야 한다.
| 실패 신호 | 의미 | 먼저 의심할 개념 |
|---|---|---|
| 장애 복구 직후 outbound request가 수직 상승한다 | 같은 시점에 retry가 몰린다 | jitter 부재, retry budget 부재 |
| 4xx 응답에도 retry 로그가 반복된다 | 재시도 조건이 너무 넓다 | error classification |
| 사용자는 한 번 눌렀는데 결제/주문이 두 번 생긴다 | timeout 뒤 중복 부작용이 실행됐다 | idempotency key, business key |
| 같은 idempotency key로 빠르게 두 요청이 모두 처리된다 | key 저장이 원자적이지 않다 | SET NX, unique constraint, processing state |
| DLQ depth가 계속 증가한다 | poison pill 또는 영구 실패가 본 큐에서 분리된다 | DLQ 원인 분석, schema mismatch |
| 하위 서비스 장애 중 thread/connection pool이 고갈된다 | retry가 자원을 붙잡고 있다 | timeout, circuit breaker |
| 장애가 끝났는데도 호출이 계속 차단된다 | circuit breaker가 open 상태로 오래 남았다 | half-open probe, recovery threshold |
| p95 latency는 늘었는데 error rate는 낮다 | retry가 성공을 만들지만 지연을 숨긴다 | retry observability, attempt count metric |
| 한 리소스의 throttling 뒤 다른 리소스 호출도 느려진다 | 공유 client의 adaptive throttling 영향일 수 있다 | client scope 분리 |
실패 신호는 명령어보다 오래 남는다. 명령어는 팀의 로깅, 클라우드, queue 종류마다 다르지만, “복구 직후 동시 재시도”, “timeout 뒤 중복 부작용”, “DLQ 증가”라는 패턴은 시스템이 달라도 반복된다.
Retry는 사용자에게 보이는 error rate를 낮출 수 있다. 하지만 그만큼 내부 실패를 숨길 수도 있다. 최종 응답이 200이어도 내부적으로 3번 retry했다면 하위 시스템은 이미 불안정할 수 있다.
따라서 retry 정책은 metric과 함께 배포해야 한다.
최소한 “시도 횟수”, “최종 실패”, “재시도 지연”, “idempotency hit”을 분리해 본다.
단일 request_failed 로그만 있으면 retry가 장애를 줄였는지, 장애를 키웠는지 판단하기 어렵다.
| 지표 | 의미 | 이상 신호 |
|---|---|---|
retry_attempt_total | retry가 실제로 몇 번 발생했는가 | 배포 직후 또는 하위 장애 때 급증 |
retry_exhausted_total | 모든 retry를 소진하고 실패한 요청 수 | retry가 더 이상 회복에 도움 안 됨 |
retry_delay_ms | retry 사이 대기 시간 분포 | cap에 자주 닿으면 장기 장애 가능성 |
attempt_count | 성공 요청이 몇 번째 시도에서 성공했는가 | 1회 성공 비율 하락, p95 latency 상승 |
idempotency_hit_total | 같은 key로 재요청된 수 | 클라이언트 timeout 또는 중복 submit 증가 |
idempotency_conflict_total | 같은 key에 다른 payload가 들어온 수 | key 재사용 버그 또는 요청 변조 가능성 |
dlq_visible_messages | DLQ에 쌓인 메시지 수 | poison pill, schema mismatch, 영구 실패 |
oldest_message_age | 가장 오래된 미처리 메시지 나이 | worker 처리 지연 또는 retry backlog |
이 지표들은 서로 연결해서 봐야 한다.
retry_attempt_total이 증가했는데 retry_exhausted_total은 낮고 latency만 조금 늘었다면 retry가 일시 장애를 흡수한 것이다.
반대로 retry 시도와 latency, DLQ, 하위 API 5xx가 함께 증가하면 retry가 회복이 아니라 증폭을 만들고 있을 수 있다.
Idempotency 지표도 성공 지표처럼 다루면 안 된다.
idempotency_hit_total은 중복 결제를 막았다는 좋은 신호일 수 있지만, 동시에 클라이언트 timeout이나 중복 제출이 많다는 신호일 수 있다.
hit가 갑자기 늘면 “잘 막고 있다”에서 끝내지 말고 왜 같은 key가 반복되는지 본다.
좋은 정책은 무엇을 하지 않을지 명확하다. 아래 패턴은 구현은 쉬워 보이지만 장애 때 비용이 커진다.
| Anti-pattern | 왜 위험한가 | 더 나은 방향 |
|---|---|---|
| 모든 error를 retry | 4xx, validation error, 권한 오류까지 반복한다 | retryable error를 명시적으로 좁힌다 |
| cap 없는 exponential backoff | 대기 시간이 비현실적으로 커진다 | cap, max retry, 전체 deadline을 같이 둔다 |
| jitter 없는 backoff | 모든 클라이언트가 같은 시점에 재시도한다 | full jitter 또는 SDK의 jitter 정책을 쓴다 |
| 계층마다 독립 retry | 하위 시스템 부하가 곱셈으로 늘어난다 | retry 책임 위치를 하나로 모은다 |
| POST retry에 새 key 생성 | 논리적으로 같은 작업을 새 요청으로 만든다 | 같은 작업에는 같은 idempotency key를 유지한다 |
| Redis GET 후 처리 후 SET | 동시 요청이 둘 다 처리될 수 있다 | SET NX, unique constraint, processing state를 둔다 |
| DLQ 없는 worker retry | poison pill이 worker를 계속 점유한다 | max receive count와 DLQ를 둔다 |
| retry 로그만 있고 metric 없음 | 성공 응답 뒤 내부 실패가 숨는다 | attempt count와 retry exhausted metric을 남긴다 |
Anti-pattern은 암기용 목록이 아니다.
각 항목은 앞에서 배운 원리의 반대 사례다.
예를 들어 “POST retry에 새 key 생성”은 timeout이 작업 미실행을 증명하지 못한다는 사실을 잊은 결과다.
GET -> 처리 -> SET은 idempotency가 저장소의 원자성까지 포함한다는 사실을 놓친 결과다.
브라우저의 fetch retry도 같은 원리를 따른다.
다만 이 문서의 중심은 서버 간 호출과 worker다.
서버 쪽에서는 같은 retry 정책을 가진 인스턴스가 많고, 실패가 하위 시스템 전체로 증폭될 수 있기 때문이다.
재시도 정책은 요청의 성격과 실패 비용을 같이 본다. 읽기 요청인지, 쓰기 요청인지, 중복 실행 비용이 큰지, 사용자가 기다릴 수 있는 시간이 짧은지, 하위 시스템이 rate limit을 주는지에 따라 답이 달라진다.
| 요청 유형 | 권장 조합 | 피해야 할 것 |
|---|---|---|
| 캐시 가능한 읽기 | 짧은 retry + backoff + fallback cache | 긴 retry로 사용자 응답을 붙잡기 |
| 외부 API 조회 | retry 대상 error 제한 + full jitter | 모든 4xx/5xx를 같은 방식으로 retry |
| 결제/주문 생성 | idempotency key + business key + 제한 retry | 새 key로 POST 재시도 |
| Queue Worker | receive count 기반 backoff + DLQ + idempotent handler | poison pill 무한 재처리 |
| 429 rate limit | Retry-After 헤더(서버가 알려준 재시도 대기 시간) 존중 + retry budget | 즉시 retry 또는 인스턴스별 독립 폭주 |
| 장기 장애 | circuit breaker + fallback | retry 횟수만 늘리기 |
가장 좋은 retry는 사용자가 거의 느끼지 못하고, 하위 시스템도 공격하지 않으며, 중복 부작용도 만들지 않는 retry다. 이를 위해서는 retry를 코드 한 줄 옵션으로 보지 않고 timeout, budget, jitter, idempotency, 관측 지표를 함께 설계해야 한다.
아래 내용은 본문 이해에 필수는 아니다. 실제 코드나 점검 지점을 떠올릴 때 참고하면 된다.
function shouldRetry(error: HttpError): boolean { const status = error.response?.status; if (!status) return true; // network error 또는 timeout if (status === 429) return true; if (status >= 500 && status < 600) return true; return false; // 4xx 대부분은 재시도하지 않는다.}
async function callWithRetry<T>(operation: () => Promise<T>): Promise<T> { const maxRetries = 2;
for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await operation(); } catch (error) { if (!shouldRetry(error as HttpError) || attempt === maxRetries) { throw error; }
const delayMs = fullJitterDelay(attempt, 200, 3000); logger.warn({ attempt: attempt + 1, delayMs }, "retrying request"); await sleep(delayMs); } }
throw new Error("unreachable");}SELECT external_order_id, COUNT(*) AS attempts, COUNT(DISTINCT idempotency_key) AS keysFROM payment_attemptsWHERE created_at >= NOW() - INTERVAL '30 minutes'GROUP BY external_order_idHAVING COUNT(*) > 1 OR COUNT(DISTINCT idempotency_key) > 1;결과가 나오면 같은 주문에 서로 다른 key가 쓰였는지, 같은 key가 동시에 처리됐는지, PG 승인 결과와 내부 결제 상태가 어긋났는지 확인한다. 쿼리 자체보다 중요한 것은 message id나 request id만 보지 않고 business key로 묶어 본다는 점이다.
receiveCount = 1 -> visibility timeout 30초receiveCount = 2 -> visibility timeout 60초receiveCount = 3 -> visibility timeout 120초receiveCount = 4 -> visibility timeout 240초receiveCount >= 5 -> DLQ 격리이 값은 예시다. 실제 값은 평균 처리 시간, 하위 서비스 복구 시간, 메시지 TTL, DLQ 운영 절차를 보고 정한다. 핵심은 실패 메시지를 즉시 다시 보이게 하지 않고, 반복 실패가 정상 메시지 처리를 막기 전에 격리하는 것이다.
Idempotency-Key 또는 business key가 유지되는지 본다.messageId만이 아니라 orderId, paymentId, eventType 같은 business key로 중복 처리를 막는지 본다.SDK가 이미 retry를 제공하면 애플리케이션 retry와 중복되지 않는지 확인한다.
일반적으로 standard는 기본 출발점으로 적합하고, adaptive는 throttling scope별로 client를 분리할 수 있을 때 신중하게 검토한다.
한 DynamoDB client로 여러 테이블을 공유하는데 adaptive throttling이 걸리면 한 테이블의 throttle이 다른 테이블 호출 지연으로 전파될 수 있다.