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기반 재시도도 여러 탭이 동시에 오프라인에서 온라인으로 전환되면 같은 문제가 발생할 수 있다.
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개...- 요청이 시간에 걸쳐 고르게 분산 → 서버 부하 최소화왜 이렇게 설계되었는가 — 분산 시스템에서 재시도의 철학
재시도는 “일시적 실패는 다시 시도하면 성공한다”는 가정에 기반한다. 하지만 재시도 자체가 시스템에 추가 부하를 주기 때문에, 잘못 설계된 재시도 로직은 장애를 악화시킨다. 이상적인 재시도 설계는 세 가지 원칙을 따른다: (1) 재시도할 에러를 선별한다(4xx는 재시도 불필요), (2) 점진적으로 대기 시간을 늘린다(Exponential Backoff), (3) 동시 재시도를 분산한다(Jitter). AWS Builders Library는 이 세 원칙을 “재시도를 안전하게 만드는 삼위일체”로 정의한다.
AWS SDK의 Adaptive Retry Mode — 2025년 권장
AWS SDK v3는 세 가지 재시도 모드를 제공한다. 2025년 현재 adaptive 모드가 프로덕션 권장 방식이다.
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: 실패율에 따라 재시도 횟수를 동적으로 조정 (2025 권장)const adaptiveClient = new DynamoDBClient({ retryMode: "adaptive", // 서버 부하 상황을 감지해 자동으로 재시도 속도 조절 maxAttempts: 5,});// adaptive 모드: 429(Too Many Requests) 응답이 오면 자동으로 속도를 늦춤// → 서버가 복구될 시간을 주면서 재시도 폭발을 방지📖 더 보기: Exponential Backoff And Jitter - AWS Architecture Blog — AWS가 대규모 실험으로 검증한 Jitter 전략 비교와 Full 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초)⚠️ maxDelay(cap) 미설정의 위험: cap 없이 지수만 적용하면 10회 시도 후 대기시간이 17분, 20회면 12일이 된다. 반드시 최대 대기시간(cap)을 설정해야 사용자 경험이 보장된다.
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 헤더를 전송하면 서버가 중복을 자동으로 처리해준다.
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를 활용한 고성능 버전 설명
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로 이어지는 복원력 패턴 결정 트리 (입문)
실전 아키텍처 패턴
섹션 제목: “실전 아키텍처 패턴”패턴 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와 어떻게 다른지 설명할 수 있다
8. 추가 학습 키워드
섹션 제목: “8. 추가 학습 키워드”Circuit Breaker, Bulkhead Pattern, Timeout 설정, Retry Storm, At-least-once Delivery, Exactly-once Processing, Redis SET NX
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에서 멱등성을 구현한 실제 방법과 설계 원칙 (중급)
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)를 방지한다
- 멱등성은 “같은 요청을 여러 번 해도 결과가 같다”는 보장이다
- 이 세 가지를 조합하면 일시적 장애에 강한 안정적인 시스템을 만들 수 있다
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 Adaptive Retry Mode (2024~2025 권장 설정):new DynamoDBClient({ retryMode: 'adaptive', // 실패율에 따라 재시도 횟수 자동 조정 maxAttempts: 5,})→ standard: 고정 지수 백오프→ adaptive: 서버 부하 감지 시 재시도 속도 자동 조절 (더 안전)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 재시도 제어 원칙 (중급)