콘텐츠로 이동

Retry / Backoff / Idempotency

분류: Layer 6 - 운영 심화: 관측성 & 복원력 | 선수지식: Queue/Worker Basics

Retry는 실패 시 다시 시도하는 것, Backoff는 재시도 간격을 점점 늘리는 전략, Idempotency는 같은 요청을 여러 번 해도 결과가 같게 만드는 설계이다.

네트워크 요청은 실패할 수 있다. 서버가 일시적으로 과부하일 수도, 네트워크가 불안정할 수도 있다. 이때 단순히 재시도만 하면 서버에 부하를 더 주고, 재시도를 안 하면 서비스가 멈춘다. 이 세 가지 패턴은 안정적인 시스템의 기본 방어 기제이다.

프론트엔드 개발자 관점에서: 프론트엔드에서도 fetch 실패 시 재시도 로직을 구현하거나, navigator.onLine을 감지해 오프라인이 풀리면 요청을 재전송하는 패턴을 사용한다. 개념은 서버 사이드와 동일하다 — 일시적 실패를 재시도로 극복하는 것. 차이점은 맥락과 규모다. 프론트엔드 재시도는 단일 사용자의 단일 요청 흐름에서 발생하고, 서버 사이드 재시도는 초당 수천 개의 서비스 간 호출이 동시에 실패할 수 있다. 그래서 Exponential Backoff와 Jitter가 필수다 — 수천 개의 클라이언트가 동시에 재시도하면 서버가 다시 과부하에 걸린다(Thundering Herd). navigator.onLine 기반 재시도도 여러 탭이 동시에 오프라인에서 온라인으로 전환되면 같은 문제가 발생할 수 있다.

세 가지가 함께 동작하는 방식 (전체 흐름)

섹션 제목: “세 가지가 함께 동작하는 방식 (전체 흐름)”

비유:

  • 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-retry
import 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):

idempotency.interceptor.ts
@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로, 실패면 다시 Open

NestJS에서 Circuit Breaker (opossum 패키지):

// npm install opossum @types/opossum
import 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 ioredis
import 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도 멱등성 보장
  • API 호출 실패 시 자동 재시도 (HTTP Client)
  • Queue Worker에서 메시지 처리 실패 시 재시도
  • 결제 API 호출 시 멱등성 보장 (중복 결제 방지)
  • 외부 서비스 연동 시 일시적 장애 대응
  • 외부 API 연동 시 “가끔 실패한다” 이슈의 대응 패턴
  • Queue 메시지가 중복 처리되는 문제 발생 시 멱등성 점검
  • 배포 직후 일시적 에러 발생 시 재시도 정책 확인
  • 장애 상황에서 “재시도 폭풍”이 발생하지 않도록 Backoff 설정 확인
개념 A개념 B차이점
RetryBackoffRetry는 “다시 시도”, Backoff는 “간격을 늘리며 다시 시도”
멱등비멱등GET/PUT/DELETE는 보통 멱등, POST는 보통 비멱등
JitterFixed BackoffJitter는 랜덤 간격 추가, Fixed는 고정 간격 (Jitter가 더 안전)
Circuit BreakerRetryRetry는 계속 시도, Circuit Breaker는 실패가 많으면 아예 시도를 멈춤
DB IdempotencyRedis IdempotencyDB는 영구 저장, Redis는 TTL 자동 만료 (Redis가 더 빠르고 관리 편함)

🔧 재시도 로직이 오히려 서버를 더 죽인다

섹션 제목: “🔧 재시도 로직이 오히려 서버를 더 죽인다”

증상: 장애 발생 후 서버가 복구되는 듯하다가 다시 죽는 패턴이 반복됨 원인: Jitter 없이 고정 간격으로 재시도 → 모든 클라이언트가 동시에 재시도 → Thundering Herd 해결:

  1. axiosRetry.exponentialDelay 대신 직접 Full Jitter 구현 적용 (위 코드 예시 참고)
  2. AWS SDK 사용 중이면 retryMode: 'adaptive' 설정 — 자동으로 Jitter 포함
  3. Circuit Breaker 패턴 도입 검토 (opossum 패키지): 일정 비율 이상 실패하면 재시도 자체를 차단

🔧 결제가 두 번 청구된다 (중복 결제)

섹션 제목: “🔧 결제가 두 번 청구된다 (중복 결제)”

증상: 사용자가 결제 버튼을 한 번 눌렀는데 카드에서 두 번 승인됨 원인: 클라이언트가 응답을 못 받아서 재시도했는데, 서버는 이미 처음 요청을 처리했음 (at-least-once) 해결:

  1. 결제 요청 시 클라이언트에서 Idempotency-Key: UUID 헤더 전송
  2. 서버에서 Interceptor로 중복 처리 차단 (위 IdempotencyInterceptor 적용)
  3. 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 상태에서 테스트 요청도 실패함 해결:

  1. resetTimeout 값 확인 및 조정 (기본 30초, 서비스 복구 시간에 맞게 설정)
  2. Half-Open 테스트 요청 실패 원인 파악 — 외부 서비스가 진짜 복구됐는지 별도 헬스체크 엔드포인트로 확인
  3. 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)
  • Retry할 때 어떤 에러에만 재시도해야 하는지 설명할 수 있다
  • Exponential Backoff + Jitter가 왜 필요한지 설명할 수 있다
  • 멱등성이 뭔지, 왜 중요한지 설명할 수 있다
  • Idempotency Key의 동작 방식을 설명할 수 있다
  • Circuit Breaker가 Retry와 어떻게 다른지 설명할 수 있다

Circuit Breaker, Bulkhead Pattern, Timeout 설정, Retry Storm, At-least-once Delivery, Exactly-once Processing, Redis SET NX

  • 팀 서비스의 HTTP Client 코드에서 Retry 설정 확인
    Terminal window
    grep -r "axiosRetry\|retry\|Retry" src/ --include="*.ts"
    예상 출력: retry 설정이 있으면 해당 파일 경로 표시, 없으면 개선 기회
  • Queue Worker에 재시도 정책이 어떻게 되어 있는지 확인 SQS 콘솔: Queue → Dead-letter queue → Maximum receives 확인
  • 외부 API 연동 부분에서 멱등성이 보장되는지 확인 특히 결제/주문 생성 API: Idempotency-Key 헤더 전송 여부 확인
  • Backoff 설정이 있는지, Jitter가 적용되어 있는지 확인
  • 재시도 조건에서 4xx를 제외하고 있는지 확인 (불필요한 재시도 방지)
  1. Retry는 일시적 실패에 대한 자동 재시도이다 (4xx는 재시도 불필요)
  2. Backoff는 재시도 간격을 점점 늘려서 서버 부하를 줄이는 전략이다
  3. Jitter를 추가해야 여러 클라이언트의 동시 재시도(Thundering Herd)를 방지한다
  4. 멱등성은 “같은 요청을 여러 번 해도 결과가 같다”는 보장이다
  5. 이 세 가지를 조합하면 일시적 장애에 강한 안정적인 시스템을 만들 수 있다

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 상태로 전환
→ 또는 앱 재배포 (상태 초기화)

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 재시도 제어 원칙 (중급)