Retry / Backoff / Idempotency
분류: Layer 6 - 운영 심화: 관측성 & 복원력 | 선수지식: Queue/Worker Basics
1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”Retry는 실패 시 다시 시도하는 것, Backoff는 재시도 간격을 점점 늘리는 전략, Idempotency는 같은 요청을 여러 번 해도 결과가 같게 만드는 설계이다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”네트워크 요청은 실패할 수 있다. 서버가 일시적으로 과부하일 수도, 네트워크가 불안정할 수도 있다. 이때 단순히 재시도만 하면 서버에 부하를 더 주고, 재시도를 안 하면 서비스가 멈춘다. 이 세 가지 패턴은 안정적인 시스템의 기본 방어 기제이다.
프론트엔드 개발자 관점에서: 프론트엔드에서도
fetch실패 시 재시도 로직을 구현하거나,navigator.onLine을 감지해 오프라인이 풀리면 요청을 재전송하는 패턴을 사용한다. 개념은 서버 사이드와 동일하다 — 일시적 실패를 재시도로 극복하는 것. 차이점은 맥락과 규모다. 프론트엔드 재시도는 단일 사용자의 단일 요청 흐름에서 발생하고, 서버 사이드 재시도는 초당 수천 개의 서비스 간 호출이 동시에 실패할 수 있다. 그래서 Exponential Backoff와 Jitter가 필수다 — 수천 개의 클라이언트가 동시에 재시도하면 서버가 다시 과부하에 걸린다(Thundering Herd).navigator.onLine기반 재시도도 여러 탭이 동시에 오프라인에서 온라인으로 전환되면 같은 문제가 발생할 수 있다.
즉시 재시도와 단순 큐 재전달의 한계
섹션 제목: “즉시 재시도와 단순 큐 재전달의 한계”이 토픽은 “실패하면 다시 보낸다”는 단순한 규칙이 분산 시스템에서 깨지는 지점에서 등장한다. Queue/Worker의 at-least-once delivery는 메시지 유실을 줄여주지만, 같은 메시지가 두 번 처리될 수 있고 HTTP 타임아웃은 “요청이 서버에 도착하지 않았다”와 “서버는 처리했지만 응답만 유실됐다”를 구분해주지 않는다. 그래서 결제·주문 생성처럼 부작용이 있는 작업은 재시도만으로는 안전해지지 않는다.
메커니즘은 세 겹이다. 첫째, Backoff는 실패 직후 재시도 간격을 늘려 하위 시스템에 회복 시간을 준다. 둘째, Jitter는 여러 클라이언트가 같은 초에 몰리는 현상을 분산한다. AWS Architecture Blog의 100개 동시 클라이언트 시뮬레이션에서는 jitter를 넣었을 때 호출 수가 절반 이하로 줄었다. 셋째, Idempotency Key는 응답 유실 후 같은 요청을 다시 보내도 서버가 “이미 처리한 요청”으로 인식하게 만든다. Stripe 공식 문서는 첫 요청의 status code와 body를 저장하고 같은 키의 후속 요청에는 같은 결과를 반환하며, 키는 최소 24시간 이후 pruning될 수 있다고 설명한다. 즉 이 문서의 핵심은 네트워크 일시 장애를 재시도로 넘기되, 지연을 분산하고 중복 부작용을 막는 것이다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”세 가지가 함께 동작하는 방식 (전체 흐름)
섹션 제목: “세 가지가 함께 동작하는 방식 (전체 흐름)”비유:
- Retry: “문이 잠겨 있으면 다시 두드린다”
- Backoff: “한 번 두드리고 안 열리면 1초 기다렸다가, 또 안 열리면 2초, 4초로 점점 더 기다린다”
- Jitter: “혼자가 아니라 여러 명이 동시에 두드리면 소음만 커지니, 각자 조금씩 다른 시간에 두드리도록 랜덤하게 조절한다”
- Idempotency: “문을 10번 두드렸다고 집에 10번 들어가지 않는다 — 한 번 들어간 결과는 같다”
원리: Thundering Herd Problem (왜 Jitter가 필수인가)
서버 장애 후 복구 시 모든 클라이언트가 동시에 재시도하면 서버가 다시 과부하에 걸린다. AWS가 실제 대규모 실험으로 검증한 결과, Full Jitter 방식이 서버 부하를 가장 효과적으로 분산시킨다.
Jitter 없는 경우:- 1초 후: 클라이언트 1,000개가 동시 요청 → 서버 다시 과부하- 2초 후: 다시 1,000개 동시 요청 → 반복
Jitter 있는 경우 (Full Jitter):- 0.3초 후: 클라이언트 100개, 0.7초 후: 150개, 1.2초 후: 200개...- 요청이 시간에 걸쳐 고르게 분산 → 서버 부하 최소화왜 이렇게 설계되었는가 — 분산 시스템에서 재시도의 철학
재시도는 “일시적 실패는 다시 시도하면 성공한다”는 가정에 기반한다. 하지만 재시도 자체가 시스템에 추가 부하를 주기 때문에, 잘못 설계된 재시도 로직은 장애를 악화시킨다. AWS Builders Library는 5단계 서비스 호출 체인에서 각 계층이 3회씩 독립 재시도하면 최하위 데이터베이스 부하가 243배까지 늘 수 있다고 설명한다. 그래서 재시도 설계는 (1) 재시도할 에러를 선별하고, (2) 점진적으로 대기 시간을 늘리며, (3) 동시 재시도를 분산하고, (4) 부작용이 있는 API에는 멱등성 장치를 요구하는 방향으로 발전했다.
AWS SDK의 Retry Mode 선택 — standard가 기본 출발점
AWS SDK v3는 세 가지 재시도 모드를 제공한다. AWS SDK 공식 Retry behavior 문서는 standard를 일반 권장 모드로 설명하고, adaptive는 실험적 모드이며 클라이언트를 throttling scope별로 분리할 수 있고 지연 증가를 감수할 수 있는 특수 상황에만 쓰라고 안내한다. adaptive는 요청 전송 속도를 클라이언트 쪽 token bucket으로 조절할 수 있어 강력하지만, 같은 DynamoDB 클라이언트로 여러 테이블을 호출하면 한 테이블의 throttle이 다른 테이블 요청까지 늦추는 silent failure가 될 수 있다.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
// legacy: 고정 재시도 횟수const legacyClient = new DynamoDBClient({ retryMode: "legacy", // 기본 재시도, Jitter 없음 maxAttempts: 3,});
// standard: Jitter 포함, 일반적인 기본 선택const standardClient = new DynamoDBClient({ retryMode: "standard", // Jitter + Exponential Backoff maxAttempts: 5,});
// adaptive: throttling 단위별로 클라이언트를 분리할 수 있을 때만 신중히 사용const adaptiveClientForSingleTable = new DynamoDBClient({ retryMode: "adaptive", // 서버 부하 상황을 감지해 자동으로 재시도 속도 조절 maxAttempts: 5,});// adaptive 모드: 429(Too Many Requests) 응답이 오면 자동으로 속도를 늦춤// → 단일 리소스에는 유용하지만, 여러 리소스를 공유하는 클라이언트에서는 정상 요청까지 지연 가능📖 더 보기: AWS SDK Retry behavior, Exponential Backoff And Jitter - AWS Architecture Blog — AWS SDK retry mode 선택 기준과 jitter 전략 비교
어떤 에러에 재시도해야 하는가 — 분류 기준
재시도는 “일시적 장애”에만 해야 한다. 클라이언트 실수를 재시도하면 낭비다.
| 에러 유형 | HTTP 코드 | 재시도 여부 | 이유 |
|---|---|---|---|
| 네트워크 오류 | - | ✅ 재시도 | 일시적 연결 문제 |
| 서버 과부하 | 429, 503 | ✅ 재시도 | 일시적 과부하, 회복 가능 |
| 서버 에러 | 500, 502 | ✅ 재시도 | 일시적 서버 오류 가능 |
| 클라이언트 에러 | 400, 422 | ❌ 재시도 안 함 | 요청 자체가 잘못됨, 재시도해도 같은 결과 |
| 인증 에러 | 401, 403 | ❌ 재시도 안 함 | 권한 문제, 재시도해도 해결 안 됨 |
| 리소스 없음 | 404 | ❌ 재시도 안 함 | 존재하지 않는 리소스 |
NestJS에서 HTTP 클라이언트 재시도 구현 (axios-retry):
// npm install axios axios-retryimport axios from "axios";import axiosRetry from "axios-retry";
const httpClient = axios.create({ baseURL: "https://api.external.com" });
axiosRetry(httpClient, { retries: 3, // 최대 3번 재시도 retryDelay: axiosRetry.exponentialDelay, // 지수 백오프 자동 적용 retryCondition: (error) => { // 5xx 에러나 네트워크 오류만 재시도 (4xx는 제외) return ( axiosRetry.isNetworkOrIdempotentRequestError(error) || (error.response?.status ?? 0) >= 500 ); }, onRetry: (retryCount, error) => { console.log(`재시도 ${retryCount}회차: ${error.message}`); },});
// 사용: 내부적으로 실패 시 자동 재시도const response = await httpClient.get("/orders/123");Backoff (지수 백오프)
재시도 간격을 점점 늘리는 전략. 서버가 과부하일 때 모든 클라이언트가 동시에 재시도하면 더 죽는다(thundering herd).
1차 시도: 실패 → 1초 후 재시도2차 시도: 실패 → 2초 후 재시도3차 시도: 실패 → 4초 후 재시도4차 시도: 실패 → 8초 후 재시도 (+ 랜덤 jitter)
공식: delay = min(cap, base * 2^attempt) + random(0, base)AWS SDK 기본값: base=1초, cap=20초 (최대 20초)공식 AWS SDK 문서는 대부분의 SDK가 jitter가 포함된 truncated binary exponential backoff를 쓰며, seconds_to_sleep_i = min(b * 2^i, 20 seconds) 형태로 계산한다고 설명한다(b는 0~1 사이 난수). 애플리케이션 코드에서 직접 구현할 때도 같은 원리를 따라 “전체 요청 deadline 안에서 최대 몇 번 재시도할 수 있는가”를 먼저 정해야 한다. 예를 들어 사용자 결제 API의 전체 deadline이 8초인데 baseMs=1000, capMs=20000, maxRetries=5로 두면 최악의 경우 사용자는 이미 포기한 뒤에도 서버가 계속 재시도할 수 있다.
⚠️ maxDelay(cap) 미설정의 위험: cap 없이 지수만 적용하면 10회 시도 후 대기시간이 17분, 20회면 12일이 된다. 반드시 최대 대기시간(cap)을 설정해야 사용자 경험이 보장된다. 반대로 cap만 있고 재시도 횟수 제한이 없으면 모든 클라이언트가 cap 간격으로 영구 재시도할 수 있으므로 maxRetries, 전체 deadline, circuit breaker를 함께 둔다.
Jitter 적용한 직접 구현 예시 (TypeScript):
function exponentialBackoffWithJitter( attempt: number, baseMs = 1000, capMs = 20000,): number { const exponential = Math.min(capMs, baseMs * Math.pow(2, attempt)); // Full Jitter: 0 ~ exponential 사이 랜덤값 return Math.random() * exponential;}
async function retryWithBackoff<T>( fn: () => Promise<T>, maxRetries = 3,): Promise<T> { for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { if (attempt === maxRetries) throw error; const delayMs = exponentialBackoffWithJitter(attempt); console.log( `시도 ${attempt + 1}회 실패. ${delayMs.toFixed(0)}ms 후 재시도...`, ); await new Promise((resolve) => setTimeout(resolve, delayMs)); } } throw new Error("unreachable");}
// 사용 예시const result = await retryWithBackoff(() => paymentApi.charge(orderId, amount));// 출력 예시:// 시도 1회 실패. 423ms 후 재시도...// 시도 2회 실패. 1847ms 후 재시도...// 시도 3회 성공 → result 반환Jitter (지터)
Backoff에 랜덤 값을 추가. 여러 클라이언트가 같은 타이밍에 재시도하는 것을 방지.
Idempotency (멱등성)
같은 요청을 1번 하든 10번 하든 결과가 동일한 성질.
- ✅ 멱등:
DELETE /users/123→ 10번 호출해도 결과는 “123 삭제됨” - ❌ 비멱등:
POST /orders→ 10번 호출하면 주문이 10개 생김
HTTP 메서드별 멱등성:
| 메서드 | 멱등성 | 이유 |
|---|---|---|
| GET | ✅ 멱등 | 읽기만 하므로 상태 변경 없음 |
| PUT | ✅ 멱등 | 동일 데이터로 덮어쓰면 결과 동일 |
| DELETE | ✅ 멱등 | 이미 삭제된 리소스 다시 삭제해도 동일 |
| POST | ❌ 비멱등 | 매번 새 리소스 생성 |
| PATCH | ❌ 비멱등 | 상대적 변경(+1 등)이면 결과 달라짐 |
Idempotency Key
각 요청에 고유 키를 붙임. 서버가 같은 키의 요청은 중복 처리하지 않음.
POST /payments+ Header:Idempotency-Key: abc-123→ 같은 키로 여러 번 보내도 한 번만 처리
실제로 Stripe, Toss Payments 같은 결제 PG사 API는 이미 Idempotency Key를 공식 지원한다. 클라이언트에서 Idempotency-Key 헤더를 전송하면 서버가 중복을 자동으로 처리해준다.
Idempotency Key 고급 설계 고려사항
키 생성 전략과 TTL 만료 처리는 운영 안정성에 직접 영향을 미친다.
UUID v4 vs 비즈니스 키 선택 기준:
| 방식 | 예시 | 적합한 경우 | 주의사항 |
|---|---|---|---|
| UUID v4 | 550e8400-e29b-41d4-a716 | 클라이언트가 서버 맥락 모를 때, 범용 API | DB 인덱스 단편화 발생 가능 |
| UUID v7 | 01960000-0000-7xxx-xxxx | 고쓰기 부하 환경, DB 인덱스 성능 중요 | 시간 정보 노출 (보안 민감 환경 주의) |
| 비즈니스 키 | orderId:userId:action | 재처리 가능 여부를 비즈니스 로직으로 판단 | 키 충돌 가능성, 사전에 고유성 설계 필요 |
| 복합 키 | UUID:userId:transactionType | 트레이스 추적 + 고유성 동시 보장 | 키 길이 증가 |
UUID v7은 타임스탬프 + 랜덤 비트 조합으로, B-Tree 인덱스에 순차적으로 삽입되어 v4 대비 쓰기 성능이 크게 향상된다. 고쓰기 환경에서는 v7이 적합하다.
TTL 만료 후 재처리 시나리오:
TTL이 만료된 키로 다시 요청이 들어오면 “새 요청”으로 처리된다. 이때 발생 가능한 문제와 대응:
// 시나리오: TTL=24시간인 결제 키가 만료 후 클라이언트가 재시도// → 이미 처리된 결제인데 새 요청으로 처리되면 중복 결제 위험
// 해결 1: TTL을 비즈니스 재시도 SLA보다 길게 설정// 결제 재시도는 보통 수 분 ~ 수 시간 → TTL 48~72시간으로 여유 확보
// 해결 2: 비즈니스 키 기반으로 서버 측 중복 검증 추가async function createPaymentSafe( idempotencyKey: string, dto: CreatePaymentDto,) { // 1차: Idempotency Key로 Redis 확인 (빠른 경로) const cached = await redis.get(`idempotency:${idempotencyKey}`); if (cached) return JSON.parse(cached);
// 2차: TTL 만료 대비 — DB에서 동일 주문 존재 여부 재확인 (느리지만 안전) const existingOrder = await orderRepo.findOne({ where: { externalOrderId: dto.externalOrderId }, }); if (existingOrder) { // 이미 처리됨 → 캐시 복구 후 반환 await redis.setex( `idempotency:${idempotencyKey}`, 86400, JSON.stringify(existingOrder), ); return existingOrder; }
// 3차: 실제 처리 return executeAndCache(idempotencyKey, dto);}📖 더 보기: On Idempotency Keys - Gunnar Morling — UUID v7 전환 이유, TTL 설계, 비즈니스 키 vs UUID 선택 기준 심층 분석 (중급)
NestJS에서 Idempotency Key 구현 (Interceptor + DB):
@Injectable()export class IdempotencyInterceptor implements NestInterceptor { constructor( @InjectRepository(IdempotencyRecord) private readonly repo: Repository<IdempotencyRecord>, ) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> { const request = context.switchToHttp().getRequest(); const key = request.headers['idempotency-key'];
if (!key) return next.handle(); // 키 없으면 그냥 통과
// 이미 처리된 요청인지 확인 const existing = await this.repo.findOne({ where: { key } }); if (existing) { // 동일 키 → 저장된 응답 반환 (중복 처리 안 함) return of(existing.response); }
return next.handle().pipe( tap(async (response) => { // 처리 완료 후 응답 저장 (TTL 24시간) await this.repo.save({ key, response, createdAt: new Date() }); }), ); }}
// 사용: 결제 등 중요한 엔드포인트에 적용@Post('payments')@UseInterceptors(IdempotencyInterceptor)async createPayment(@Body() dto: CreatePaymentDto) { return this.paymentService.charge(dto);}📖 더 보기: NestJS Idempotency Interceptor 구현 가이드 - DEV Community — 위 Interceptor 패턴의 전체 구현 코드와 Redis를 활용한 고성능 버전 설명
Retry Amplification — 마이크로서비스 체인의 재시도 폭발
단일 서비스에서의 재시도는 안전하지만, 마이크로서비스 체인에서 각 서비스가 독립적으로 재시도하면 하위 서비스에 기하급수적인 부하가 집중된다.
A → B → C (각 서비스가 3회 재시도 정책 보유)
A가 1회 요청 └─ B가 최대 3회 재시도 = B는 최대 3번 시도 └─ C가 최대 3회 재시도 = C는 최대 9번 요청 수신
A 자체도 3회 재시도하면: └─ A 3회 × B 3회 × C 3회 = C는 최대 27개 요청을 받음이 현상을 Retry Amplification이라 한다. C가 이미 과부하 상태일 때 27배의 트래픽이 쏟아지면 복구 대신 완전 장애로 전환된다.
AWS 공식 권장 대응 전략:
| 전략 | 내용 |
|---|---|
| 클라이언트만 재시도 | 재시도 로직은 엔드 클라이언트(A)에만. 중간 서비스(B)는 에러를 상위로 pass-through |
| Retry Budget | 서비스별 초당 최대 재시도 횟수 상한 설정 (Token Bucket 패턴) |
| 엔드포인트별 정책 분리 | 중요도/복구 시간에 따라 서비스마다 다른 maxRetries, backoff 설정 |
| Circuit Breaker 연계 | 연쇄 재시도가 시작되면 Circuit Breaker로 조기 차단 |
// ❌ 잘못된 패턴: 중간 서비스(B)가 자체적으로 재시도// A → B(재시도 3회) → C(재시도 3회) = C에 9배 부하
// ✅ 올바른 패턴: 중간 서비스는 pass-through, 클라이언트만 재시도// ServiceB: C 호출 실패 시 바로 에러 반환 (재시도 없음)async function callServiceC(payload: unknown) { try { return await httpClient.post("/c/process", payload); } catch (error) { // 재시도 없이 상위로 전달 → A가 B 전체를 재시도 throw error; }}📖 더 보기: Timeouts, Retries and Backoff with Jitter - AWS Builders Library — Amazon 내부 엔지니어링 경험 기반의 Retry Amplification 대응 전략 (중급)
Circuit Breaker — 재시도의 한계를 넘어서
Retry + Backoff만으로는 부족한 상황: 외부 서비스가 수분~수시간 동안 완전히 다운된 경우, 재시도가 계속 실패하면서 시스템 자원을 낭비한다.
Circuit Breaker는 전기 차단기처럼 동작한다. 실패가 일정 비율 이상이면 “회로를 끊어서” 더 이상 시도하지 않고, 일정 시간 후 회복 여부를 테스트한다.
Closed (정상): 요청 통과 → 실패율 측정 ↓ (실패율 > 임계값 초과)Open (차단): 요청 즉시 거부 (빠른 실패) → 일정 시간 대기 ↓ (타임아웃 후)Half-Open (테스트): 소수 요청 통과 → 성공이면 Closed로, 실패면 다시 OpenNestJS에서 Circuit Breaker (opossum 패키지):
// npm install opossum @types/opossumimport CircuitBreaker from "opossum";
const options = { timeout: 3000, // 3초 이내 응답 없으면 실패 처리 errorThresholdPercentage: 50, // 50% 이상 실패 시 Open 상태로 전환 resetTimeout: 30000, // 30초 후 Half-Open으로 전환해서 테스트};
const breaker = new CircuitBreaker(paymentApi.charge, options);
breaker.on("open", () => logger.warn("결제 API Circuit Breaker OPEN"));breaker.on("halfOpen", () => logger.log("결제 API 회복 테스트 중"));breaker.on("close", () => logger.log("결제 API Circuit Breaker 정상화"));
// 사용: breaker.fire()가 자동으로 상태 관리try { const result = await breaker.fire(orderId, amount);} catch (error) { // OPEN 상태면 즉시 에러 반환 (재시도 없음) throw new ServiceUnavailableException("결제 서비스 일시 불가");}📖 더 보기: Retry with Backoff Pattern - AWS Prescriptive Guidance — Retry → Backoff → Circuit Breaker로 이어지는 복원력 패턴 결정 트리 (입문)
패턴 선택 의사결정 프레임워크
상황에 따라 어떤 패턴을 적용할지 결정할 때는 실패율만 보지 않는다. “최근 5분 실패율”, “호출 비용”, “부작용 여부”, “상위 요청 deadline”, “체인 깊이”를 같이 본다. 예를 들어 읽기 전용 프로필 조회는 503이 1~2%일 때 짧은 retry가 합리적이지만, POST /payments는 504가 1건뿐이어도 응답 유실 후 중복 청구가 날 수 있으므로 Idempotency Key 없이는 재시도하지 않는다. IETF Idempotency-Key draft도 같은 키가 다른 payload에 재사용되면 422, 원 요청이 아직 처리 중이면 409로 응답하는 시나리오를 제시한다.
| 상황 | 권장 패턴 | 이유 |
|---|---|---|
| 실패율 < 5%, 대부분 네트워크 순단 | Retry + Backoff | 일시적 장애, 재시도 자체가 해결책 |
| 실패율 10~30%, 간헐적 서비스 불안정 | Retry + Circuit Breaker | 재시도 폭발 방지, 서비스 회복 시간 확보 |
| 실패율 > 50%, 하위 서비스 장기 다운 | Circuit Breaker + Fallback | 재시도 의미 없음, 즉시 차단 후 대체 응답 |
| 결제/주문 등 중복 처리 위험 작업 | Retry + Idempotency Key | 재시도가 필수이지만 중복 처리는 금지 |
| 마이크로서비스 3단계 이상 체인 | Retry는 엔드포인트만 + 중간 pass-through | Retry Amplification 방지 |
의사결정 흐름:
실패 감지 → 에러가 4xx? → 재시도 안 함 (클라이언트 실수) → 에러가 5xx/네트워크? → 최근 실패율 > 50%? → Circuit Breaker 열기 + Fallback 반환 → 최근 실패율 < 50%? → Retry + Exponential Backoff + Jitter → 중복 위험 있는 작업? → Idempotency Key 필수 추가가장 위험한 실패는 에러가 크게 보이지 않는 경우다. 예를 들어 PG사는 결제를 성공 처리했지만 우리 서버가 5초 timeout으로 504를 기록하고, 클라이언트가 새 Idempotency Key를 만들어 다시 요청하면 로그에는 “서로 다른 두 요청”처럼 보인다. 이때는 externalOrderId 또는 merchantOrderId 같은 비즈니스 키로 요청을 묶어 확인한다.
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;예상 출력은 중복 의심 주문만 나오는 것이다. 결과가 0행이면 같은 주문에 대한 중복 재시도 증거가 없고, 행이 나오면 두 번째 청구 취소·idempotency cache 복구·external_order_id unique constraint 추가 순서로 복구한다. 이 원리는 HTTP 결제뿐 아니라 Queue Worker에도 그대로 적용된다. 메시지 ID만 믿지 말고 비즈니스 키로 중복 처리 여부를 확인해야 at-least-once delivery의 중복 실행을 막을 수 있다.
📖 더 보기: Resilience Design Patterns - codecentric — Retry, Fallback, Circuit Breaker 각 패턴의 적용 조건과 조합 전략 (중급)
실전 아키텍처 패턴
섹션 제목: “실전 아키텍처 패턴”패턴 1: SQS Queue 기반 지수 백오프 (대규모 분산 시스템)
HTTP 클라이언트 재시도와 달리, SQS 기반 Worker에서는 큐 자체를 재시도 메커니즘으로 활용할 수 있다. 이 패턴은 수십만 건의 작업을 안정적으로 처리하는 실제 프로덕션 패턴이다.
// SQS 기반 지수 백오프 재시도 패턴// Worker에서 실패 시 지연 시간을 계산해 Visibility Timeout을 동적으로 연장
@SqsMessageHandler("order-queue", false)async handleOrder(message: Message) { const payload = JSON.parse(message.Body!); const receiveCount = parseInt(message.Attributes?.ApproximateReceiveCount ?? "1");
try { await this.processOrder(payload); } catch (error) { if (receiveCount >= 5) { // 5번 이상 실패 → DLQ로 이동하도록 에러 던짐 this.logger.error(`최대 재시도 초과. DLQ로 이동: ${payload.orderId}`); throw error; }
// 지수 백오프: 1회=30초, 2회=60초, 3회=120초, 4회=240초 const backoffSeconds = Math.min(30 * Math.pow(2, receiveCount - 1), 300); // Visibility Timeout 연장 → 다른 Worker가 즉시 처리 못하도록 await sqsClient.changeMessageVisibility({ QueueUrl: process.env.SQS_ORDER_QUEUE_URL, ReceiptHandle: message.ReceiptHandle!, VisibilityTimeout: backoffSeconds, });
console.log(`처리 실패 (${receiveCount}회). ${backoffSeconds}초 후 재시도`); // 에러를 던지지 않으면 라이브러리가 DeleteMessage 호출하므로 주의 throw error; }}패턴 2: Redis 기반 Idempotency Key (고성능)
DB 대신 Redis를 Idempotency Key 저장소로 쓰면 조회 속도가 빠르고 TTL 자동 만료가 편리하다.
// Redis를 활용한 고성능 Idempotency Key 구현// npm install ioredisimport Redis from "ioredis";
@Injectable()export class IdempotencyService { constructor(private redis: Redis) {}
async executeIdempotent<T>( key: string, ttlSeconds: number, operation: () => Promise<T>, ): Promise<T> { const cacheKey = `idempotency:${key}`;
// 이미 처리된 요청인지 Redis에서 확인 const cached = await this.redis.get(cacheKey); if (cached) { console.log(`중복 요청 감지. 캐시된 응답 반환: ${key}`); return JSON.parse(cached); }
const result = await operation();
// 결과를 Redis에 저장 (TTL 적용, 자동 만료) await this.redis.setex(cacheKey, ttlSeconds, JSON.stringify(result)); return result; }}
// 사용 예시async createPayment(idempotencyKey: string, dto: CreatePaymentDto) { return this.idempotencyService.executeIdempotent( idempotencyKey, 86400, // 24시간 TTL () => this.paymentGateway.charge(dto), );}// 동일 key로 두 번 호출 시:// 1회: 실제 결제 실행 → Redis에 결과 저장// 2회: Redis에서 캐시된 결과 반환 → 결제 미실행패턴 3: 실제 서비스의 재시도 정책 비교
주요 서비스들이 재시도와 멱등성을 어떻게 실제로 구현하는지:
Stripe 결제 API:- Idempotency-Key 헤더 필수 지원- 같은 키로 재요청 시 원래 응답 그대로 반환 (실제 결제 미실행)- 키는 24시간 동안 유효
AWS SDK (v3):- 기본 최대 3회 재시도- Adaptive retry mode: 실패율에 따라 재시도 횟수를 동적으로 조정- 설정: new DynamoDBClient({ maxAttempts: 5 })
Toss Payments:- Idempotency-Key 헤더 지원- 동일 키로 재요청 시 원래 응답 반환- 결제 취소 API도 멱등성 보장4. 실무에서 어디에 쓰이나
섹션 제목: “4. 실무에서 어디에 쓰이나”- API 호출 실패 시 자동 재시도 (HTTP Client)
- Queue Worker에서 메시지 처리 실패 시 재시도
- 결제 API 호출 시 멱등성 보장 (중복 결제 방지)
- 외부 서비스 연동 시 일시적 장애 대응
5. 현재 내 업무와 연결점
섹션 제목: “5. 현재 내 업무와 연결점”- 외부 API 연동 시 “가끔 실패한다” 이슈의 대응 패턴
- Queue 메시지가 중복 처리되는 문제 발생 시 멱등성 점검
- 배포 직후 일시적 에러 발생 시 재시도 정책 확인
- 장애 상황에서 “재시도 폭풍”이 발생하지 않도록 Backoff 설정 확인
6. 자주 헷갈리는 개념 비교
섹션 제목: “6. 자주 헷갈리는 개념 비교”| 개념 A | 개념 B | 차이점 |
|---|---|---|
| Retry | Backoff | Retry는 “다시 시도”, Backoff는 “간격을 늘리며 다시 시도” |
| 멱등 | 비멱등 | GET/PUT/DELETE는 보통 멱등, POST는 보통 비멱등 |
| Jitter | Fixed Backoff | Jitter는 랜덤 간격 추가, Fixed는 고정 간격 (Jitter가 더 안전) |
| Circuit Breaker | Retry | Retry는 계속 시도, Circuit Breaker는 실패가 많으면 아예 시도를 멈춤 |
| DB Idempotency | Redis Idempotency | DB는 영구 저장, Redis는 TTL 자동 만료 (Redis가 더 빠르고 관리 편함) |
6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”🔧 재시도 로직이 오히려 서버를 더 죽인다
섹션 제목: “🔧 재시도 로직이 오히려 서버를 더 죽인다”증상: 장애 발생 후 서버가 복구되는 듯하다가 다시 죽는 패턴이 반복됨 원인: Jitter 없이 고정 간격으로 재시도 → 모든 클라이언트가 동시에 재시도 → Thundering Herd 해결:
axiosRetry.exponentialDelay대신 직접 Full Jitter 구현 적용 (위 코드 예시 참고)- AWS SDK 사용 중이면
retryMode: 'adaptive'설정 — 자동으로 Jitter 포함 - Circuit Breaker 패턴 도입 검토 (
opossum패키지): 일정 비율 이상 실패하면 재시도 자체를 차단
🔧 결제가 두 번 청구된다 (중복 결제)
섹션 제목: “🔧 결제가 두 번 청구된다 (중복 결제)”증상: 사용자가 결제 버튼을 한 번 눌렀는데 카드에서 두 번 승인됨 원인: 클라이언트가 응답을 못 받아서 재시도했는데, 서버는 이미 처음 요청을 처리했음 (at-least-once) 해결:
- 결제 요청 시 클라이언트에서
Idempotency-Key: UUID헤더 전송 - 서버에서 Interceptor로 중복 처리 차단 (위 IdempotencyInterceptor 적용)
- PG(결제 대행사) API가 이미 Idempotency Key를 지원하는 경우가 많으니 공식 문서 확인 (Stripe, Toss 등)
🔧 4xx 에러에도 재시도가 발생한다
섹션 제목: “🔧 4xx 에러에도 재시도가 발생한다”증상: 잘못된 요청(422 Validation Error)인데도 재시도가 3번 반복됨 → 불필요한 서버 부하
원인: retry 조건에서 4xx를 포함시킴 (예: isNetworkError만 체크하지 않고 모든 에러를 재시도)
해결:
// 잘못된 설정 (모든 에러 재시도)retryCondition: (error) => true,
// 올바른 설정 (5xx와 네트워크 오류만 재시도)retryCondition: (error) => { const status = error.response?.status; if (status && status >= 400 && status < 500) return false; // 4xx는 재시도 안 함 return axiosRetry.isNetworkOrIdempotentRequestError(error) || (status ?? 0) >= 500;},🔧 Idempotency Key를 Redis에 저장했는데 중복 요청이 여전히 처리된다
섹션 제목: “🔧 Idempotency Key를 Redis에 저장했는데 중복 요청이 여전히 처리된다”증상: 동일 Idempotency-Key로 빠르게 두 번 요청했는데 두 번 모두 처리됨 원인: Redis GET → 처리 → Redis SET 사이에 Race Condition 발생. 두 번째 요청이 첫 번째 SET 전에 GET을 완료함 해결:
// 잘못된 방식: GET → 처리 → SET (Race Condition 발생 가능)const exists = await redis.get(key);if (exists) return JSON.parse(exists);const result = await processRequest(); // ← 두 번째 요청이 여기에 도달 가능await redis.set(key, JSON.stringify(result));
// 올바른 방식: SET NX (Not eXists) - 원자적 조건부 쓰기const acquired = await redis.set(key, "processing", "EX", 86400, "NX");if (!acquired) { // 이미 처리 중 또는 완료 → 완료될 때까지 폴링 또는 에러 반환 const cached = await redis.get(key); return cached ? JSON.parse(cached) : { status: "processing" };}const result = await processRequest();await redis.set(key, JSON.stringify(result), "EX", 86400); // 결과로 덮어쓰기🔧 Circuit Breaker가 Open 상태로 계속 유지된다
섹션 제목: “🔧 Circuit Breaker가 Open 상태로 계속 유지된다”증상: 외부 서비스가 복구됐는데도 Circuit Breaker가 OPEN 상태에서 벗어나지 않음
원인: resetTimeout 값이 너무 크거나, Half-Open 상태에서 테스트 요청도 실패함
해결:
resetTimeout값 확인 및 조정 (기본 30초, 서비스 복구 시간에 맞게 설정)- Half-Open 테스트 요청 실패 원인 파악 — 외부 서비스가 진짜 복구됐는지 별도 헬스체크 엔드포인트로 확인
- Circuit Breaker 이벤트 리스너에 CloudWatch 지표 전송 추가:
breaker.on("open", async () => {await cloudwatch.putMetricData({Namespace: "MyApp/CircuitBreaker",MetricData: [{ MetricName: "PaymentAPICircuitOpen", Value: 1, Unit: "Count" },],});});
🔧 재시도 횟수를 다 소진해도 에러 로그가 남지 않는다
섹션 제목: “🔧 재시도 횟수를 다 소진해도 에러 로그가 남지 않는다”증상: API 호출이 조용히 실패하거나, 최종 실패 시 어떤 재시도가 있었는지 로그가 없어서 원인 파악이 어려움
원인: axios-retry의 기본 설정은 재시도 이벤트에 대한 로깅이 없다. 재시도마다 로그를 남기지 않으면 운영 중 문제 추적이 불가능함
해결:
import axiosRetry from "axios-retry";import { Logger } from "@nestjs/common";
const logger = new Logger("HttpRetry");
axiosRetry(axiosInstance, { retries: 3, retryDelay: (retryCount, error) => { const base = Math.min(20000, 1000 * Math.pow(2, retryCount)); return Math.random() * base; }, retryCondition: (error) => { const status = error.response?.status; if (status && status >= 400 && status < 500) return false; return ( axiosRetry.isNetworkOrIdempotentRequestError(error) || (status ?? 0) >= 500 ); }, onRetry: (retryCount, error, requestConfig) => { // ← 재시도마다 반드시 로그 남기기 logger.warn( `Retry #${retryCount} for ${requestConfig.method?.toUpperCase()} ${requestConfig.url}` + ` — ${error.message} (status: ${error.response?.status ?? "no response"})`, ); },});재시도 로그 예시:
[HttpRetry] Retry #1 for POST /payments/charge — timeout of 5000ms exceeded (status: no response)[HttpRetry] Retry #2 for POST /payments/charge — Request failed with status code 503 (status: 503)[HttpRetry] Retry #3 for POST /payments/charge — Request failed with status code 503 (status: 503)7. 체크리스트
섹션 제목: “7. 체크리스트”- Retry할 때 어떤 에러에만 재시도해야 하는지 설명할 수 있다
- Exponential Backoff + Jitter가 왜 필요한지 설명할 수 있다
- 멱등성이 뭔지, 왜 중요한지 설명할 수 있다
- Idempotency Key의 동작 방식을 설명할 수 있다
- Circuit Breaker가 Retry와 어떻게 다른지 설명할 수 있다
- A→B→C 3단계 체인에서 각 3회 재시도 시 C가 받는 요청 수를 계산할 수 있다 (27개)
- 실패율·복구 시간에 따라 Retry/Circuit Breaker/Fallback 중 어떤 패턴을 선택할지 설명할 수 있다
- UUID v4 vs v7 vs 비즈니스 키 중 어떤 상황에 어떤 전략을 선택하는지 설명할 수 있다
8. 추가 학습 키워드
섹션 제목: “8. 추가 학습 키워드”Circuit Breaker, Bulkhead Pattern, Timeout 설정, Retry Storm, Retry Amplification, At-least-once Delivery, Exactly-once Processing, Redis SET NX, Token Bucket, UUID v7, Fallback Pattern
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”- 📖 Exponential Backoff And Jitter - AWS Architecture Blog — AWS가 실험으로 검증한 Full/Equal/Decorrelated Jitter 성능 비교, 수식 설명 (중급)
- 📖 Timeouts, Retries and Backoff with Jitter - AWS Builders Library — Amazon 내부 엔지니어링 경험 기반의 실전 재시도 전략 가이드 (중급)
- 📖 NestJS Idempotency Interceptor 구현 - DEV Community — Interceptor + DB/Redis로 Idempotency Key 구현하는 전체 코드 예제 (중급)
- 📖 Retry with Backoff Pattern - AWS Prescriptive Guidance — 클라우드 환경에서 재시도 패턴의 결정 기준과 Circuit Breaker 연결 전략 (입문)
- 📖 Making Retries Safe with Idempotent APIs - AWS Builders Library — Amazon이 자사 API에서 멱등성을 구현한 실제 방법과 설계 원칙 (중급)
- 📖 AWS SDK Retry behavior — standard/adaptive retry mode, token bucket, 20초 max backoff 기준 (중급)
- 📖 The Idempotency-Key HTTP Header Field - IETF draft — Idempotency-Key 헤더 표준화 초안과 409/422 실패 시나리오 (중급)
- 📖 Resilience Design Patterns: Retry, Fallback, Circuit Breaker - codecentric — Retry/Fallback/Circuit Breaker 세 패턴 적용 조건과 조합 전략 (중급)
- 📖 On Idempotency Keys - Gunnar Morling — UUID v4 vs v7 선택 이유, TTL 설계, 비즈니스 키 설계 심층 분석 (중급)
9. 내가 직접 확인해볼 것
섹션 제목: “9. 내가 직접 확인해볼 것”- 팀 서비스의 HTTP Client 코드에서 Retry 설정 확인
예상 출력: retry 설정이 있으면 해당 파일 경로 표시, 없으면 개선 기회
Terminal window grep -r "axiosRetry\|retry\|Retry" src/ --include="*.ts" - Queue Worker에 재시도 정책이 어떻게 되어 있는지 확인
SQS 콘솔:
Queue → Dead-letter queue → Maximum receives확인 - 외부 API 연동 부분에서 멱등성이 보장되는지 확인 특히 결제/주문 생성 API: Idempotency-Key 헤더 전송 여부 확인
- Backoff 설정이 있는지, Jitter가 적용되어 있는지 확인
- 재시도 조건에서 4xx를 제외하고 있는지 확인 (불필요한 재시도 방지)
10. 5줄 요약
섹션 제목: “10. 5줄 요약”- Retry는 일시적 실패에 대한 자동 재시도이다 (4xx는 재시도 불필요)
- Backoff는 재시도 간격을 점점 늘려서 서버 부하를 줄이는 전략이다
- Jitter를 추가해야 여러 클라이언트의 동시 재시도(Thundering Herd)를 방지한다
- 마이크로서비스 체인에서 각 서비스가 독립 재시도하면 Retry Amplification(A→B→C 각 3회 = C에 27개)이 발생한다 — 재시도는 엔드 클라이언트에만
- 멱등성은 “같은 요청을 여러 번 해도 결과가 같다”는 보장이며, Idempotency Key 설계 시 UUID v7과 TTL 만료 시나리오를 함께 고려해야 한다
- 실패율·복구 시간·서비스 체인 깊이에 따라 Retry / Circuit Breaker / Fallback 중 적합한 패턴을 선택해야 한다
11. 실전 장애 대응 시나리오 (On-Call Runbook)
섹션 제목: “11. 실전 장애 대응 시나리오 (On-Call Runbook)”Retry/Backoff/Idempotency 관련 on-call 상황에서의 대응 체크리스트
시나리오 A: “장애 복구 후 서버가 다시 다운됐다” (Thundering Herd)
섹션 제목: “시나리오 A: “장애 복구 후 서버가 다시 다운됐다” (Thundering Herd)”패턴: 외부 서비스 복구 → 재시도 폭발 → 재다운 반복
즉각 확인:1. 재시도 로직에 Jitter가 없는지 확인 → 코드에서 axiosRetry 설정 확인: retryDelay: axiosRetry.exponentialDelay (Jitter 없음) vs 직접 구현한 Full Jitter 함수 (Jitter 있음)
2. CloudWatch에서 외부 서비스로 나가는 요청 수 확인 → 복구 시점에 요청이 수직 상승하는 그래프가 있으면 Thundering Herd
즉각 조치:1. Circuit Breaker가 있다면 강제 Open 상태로 설정 → opossum: breaker.open()2. 외부 서비스가 복구됐는지 확인 → 천천히 트래픽 재개
재발 방지:axiosRetry 설정에 Full Jitter 직접 구현: retryDelay: (retryCount) => { const base = Math.min(20000, 1000 * Math.pow(2, retryCount)); return Math.random() * base; // Full Jitter }시나리오 B: “결제가 2번 됐다”는 사용자 신고
섹션 제목: “시나리오 B: “결제가 2번 됐다”는 사용자 신고”초기 대응 (30분 이내):1. 결제 ID로 PG사 관리자 콘솔에서 실제 청구 건수 확인2. 중복 청구가 확실하면 2번째 건 즉시 취소 처리
원인 분석:→ 서버 로그에서 해당 사용자의 결제 요청 타임라인 확인 filter userId = "[userId]" and eventType = "payment_attempt" | sort @timestamp asc
→ 예상 패턴: 10:00:00 - 1차 요청 수신 10:00:05 - PG API 응답 타임아웃 (서버는 처리됐지만 클라이언트는 실패로 인식) 10:00:06 - 클라이언트가 재시도 → 2차 청구 발생
근본 해결:1. 결제 요청에 Idempotency-Key 헤더 추가 (클라이언트 → 서버)2. 서버의 PaymentService에 IdempotencyInterceptor 적용3. PG API 호출 시에도 PG사의 Idempotency Key 헤더 사용 (Stripe: Idempotency-Key, Toss: idempotency-key)시나리오 C: “Circuit Breaker가 열린 채로 복구가 안 된다”
섹션 제목: “시나리오 C: “Circuit Breaker가 열린 채로 복구가 안 된다””증상: 외부 서비스는 복구됐는데 앱이 계속 "서비스 이용 불가" 반환
확인:1. Circuit Breaker 상태 확인 → opossum 기준: breaker.opened (true면 OPEN 상태) → CloudWatch에 Circuit Breaker 상태 지표가 있다면 확인
2. resetTimeout 값 확인 → options.resetTimeout: 기본 30000ms (30초) → 외부 서비스 복구 확인 후 30초가 지났는지 확인
3. Half-Open 테스트 실패 원인 → Half-Open에서 소수 요청이 통과됐는데도 실패하면 외부 서비스가 완전히 복구 안 된 것 → 외부 서비스 헬스체크 엔드포인트 직접 호출로 상태 재확인
즉각 조치:→ 외부 서비스 완전 복구 확인 후: breaker.close() // 강제로 Closed 상태로 전환→ 또는 앱 재배포 (상태 초기화)2025년 최신 동향
섹션 제목: “2025년 최신 동향”AWS Well-Architected 재시도 한계 원칙 (2025년 업데이트)
AWS Well-Architected Framework 2025년 업데이트(REL05-BP03)에서 재시도 제어 원칙이 강화됐다:
- 토큰 버킷(Token Bucket) 패턴: 초당 재시도 횟수에 상한을 두어 재시도 폭발 방지
- 최대 재시도 횟수 제한: 무한 재시도 금지 (AWS SDK 기본값: 최대 3회)
- 재시도 가능 에러 명시적 분류: 재시도 조건을 코드로 명시하고 문서화
AWS SDK v3 기본 출발점:new DynamoDBClient({ retryMode: 'standard', // 공식 문서의 일반 권장 모드 maxAttempts: 3,})→ standard: jittered exponential backoff + retry token bucket→ adaptive: experimental client-side rate limiting 추가. 단일 throttling scope별 클라이언트 분리가 가능하고 지연 증가를 감수할 때만 사용NestJS 전용 Retry 패키지 등장
2024~2025년 @pebula/nesbus 등 NestJS 전용 Backoff/Retry 라이브러리가 성숙했다. 메서드 레벨 데코레이터로 재시도 정책을 선언적으로 적용할 수 있다. 단, axios-retry + 직접 Jitter 구현이 여전히 가장 많이 사용되는 패턴이다.
Idempotency Key 표준 헤더 논의
IETF에서 Idempotency-Key 헤더를 HTTP 표준으로 제안하는 논의가 진행 중이다(draft-ietf-httpapi-idempotency-key-header). Stripe, Toss 등이 이미 사용하는 사실상의 업계 표준이며, 향후 공식 HTTP 헤더로 표준화될 가능성이 높다.
📖 더 보기: Stop Breaking Your APIs - Retry and Exponential Backoff in NestJS — NestJS에서 프로덕션 수준의 재시도 로직을 구현하는 전체 가이드 (중급) 📖 더 보기: REL05-BP03 Control and limit retry calls - AWS Well-Architected — 2025년 업데이트된 AWS Well-Architected 재시도 제어 원칙 (중급)