--- 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( op: (attempt: number) => Promise, opts: { maxAttempts?: number; baseMs?: number; capMs?: number; isRetryable?: (e: unknown) => boolean; } = {} ): Promise { 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]]