Files
2nd/10_Wiki/Topics/Coding/Backend_Retry_Strategy.md
T
2026-05-09 21:08:02 +09:00

150 lines
4.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: backend-retry-strategy
title: Backend 재시도 전략 — 지수 백오프 + Jitter
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [backend, resilience, retry, backoff, vibe-coding]
tech_stack: { language: "TypeScript / 모든 언어", applicable_to: ["Backend", "Worker"] }
applied_in: []
aliases: [exponential backoff, jitter, retry budget, idempotency-aware retry]
---
# Backend 재시도 전략
> 무조건 재시도 = thundering herd. **지수 백오프 + jitter + 멱등 보장 + 재시도 예산** 4가지 모두 있어야 production-grade. 그리고 **재시도 가능 에러만** 재시도.
## 📖 핵심 개념
- 지수 백오프: 1s → 2s → 4s → 8s, 상한 (보통 30s).
- Jitter: 랜덤 분산 — 모든 클라이언트가 동시 재시도 방지.
- Retry budget: 짧은 시간에 N번 초과면 즉시 fail.
- 멱등성: 재시도 안전한 연산만 재시도.
## 💻 코드 패턴
### 기본 retry with backoff + full jitter
```ts
async function withRetry<T>(
op: (attempt: number) => Promise<T>,
opts: {
maxAttempts?: number;
baseMs?: number;
capMs?: number;
isRetryable?: (e: unknown) => boolean;
} = {}
): Promise<T> {
const { maxAttempts = 5, baseMs = 200, capMs = 30_000,
isRetryable = defaultRetryable } = opts;
let lastErr: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await op(attempt);
} catch (e) {
lastErr = e;
if (!isRetryable(e) || attempt === maxAttempts - 1) throw e;
// Full jitter — Math.random * exp
const exp = Math.min(capMs, baseMs * Math.pow(2, attempt));
const delay = Math.random() * exp;
await new Promise(r => setTimeout(r, delay));
}
}
throw lastErr;
}
function defaultRetryable(e: any): boolean {
if (e?.code === 'ECONNRESET' || e?.code === 'ETIMEDOUT') return true;
if (e?.response?.status >= 500) return true;
if (e?.response?.status === 429) return true; // rate limited
return false;
}
```
### Idempotency-Key 와 결합
```ts
const key = crypto.randomUUID();
await withRetry(() =>
fetch('/api/payments', {
method: 'POST',
headers: { 'Idempotency-Key': key, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(checkStatus)
);
```
같은 key 로 재시도 → 서버가 중복 처리 안 함.
### Retry-After 헤더 존중
```ts
isRetryable: (e) => {
const status = e?.response?.status;
if (status === 429 || status === 503) {
const retryAfter = e?.response?.headers?.['retry-after'];
if (retryAfter) {
// Retry-After 가 있으면 그 시간 + jitter
// (위 backoff 무시하고 별도 delay 계산)
}
return true;
}
return status >= 500 && status < 600;
}
```
### Retry budget (token bucket)
```ts
class RetryBudget {
private tokens: number;
constructor(private capacity = 100, private refillPerSec = 10) {
this.tokens = capacity;
}
tryAcquire(): boolean {
// refill ... 단순화
if (this.tokens < 1) return false;
this.tokens--;
return true;
}
}
const budget = new RetryBudget();
await withRetry(op, {
isRetryable: (e) => defaultRetryable(e) && budget.tryAcquire(),
});
```
## 🤔 의사결정 기준
| 에러 종류 | 재시도 |
|---|---|
| 5xx (서버) | ✅ |
| 429 + Retry-After | ✅ — Retry-After 존중 |
| 408 timeout | ✅ |
| 4xx (400/401/403/404) | ❌ — 클라이언트 잘못 |
| network ECONNRESET / ETIMEDOUT | ✅ |
| validation 실패 | ❌ |
| 사용자 cancel | ❌ |
| 작업 종류 | 재시도 적합 |
|---|---|
| GET 조회 | ✅ |
| 멱등 PUT/DELETE | ✅ |
| POST without idempotency-key | ❌ |
| POST with idempotency-key | ✅ |
## ❌ 안티패턴
- **모든 에러 재시도**: 4xx 도 무한 시도. 사용자 잘못 한 요청을 100번 보냄.
- **jitter 없음**: 모든 클라이언트가 같은 시간 재시도 = thundering herd.
- **무한 재시도**: maxAttempts 또는 deadline.
- **POST 재시도 + idempotency-key 없음**: 두 번 결제.
- **Retry-After 무시**: 서버가 "1분 뒤" 라고 알려줬는데 1초 뒤 재시도 → 영구 차단 가능.
- **재시도 안에 또 재시도** (HTTP 클라이언트 + 우리 wrapper): N×N 폭발.
- **로그 안 남김**: 재시도가 일어나는지 모름. 메트릭 + 알림.
## 🤖 LLM 활용 힌트
- "지수 backoff + full jitter + 멱등 보장 + retryable predicate" 4종 세트.
- 외부 의존성마다 재시도 정책 명시.
## 🔗 관련 문서
- [[Idempotent_Operations]]
- [[Backend_Circuit_Breaker]]
- [[Backend_Rate_Limiting]]