- Published on
Claude 429 과금폭탄 막는 재시도·백오프 전략
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Claude API를 붙여서 서비스를 운영하다 보면 언젠가 429를 만나게 됩니다. 문제는 429 자체가 아니라, 이를 처리하는 방식이 비용을 폭증시키는 트리거가 된다는 점입니다. 특히 다음과 같은 상황이 겹치면 “과금폭탄”이 됩니다.
- 요청이 동시에 몰려 레이트 리밋에 걸림
- 클라이언트가 즉시 재시도하며 트래픽을 더 밀어 넣음
- 스트리밍이나 긴 프롬프트로 요청당 토큰이 큼
- 실패한 요청을 여러 번 반복하면서 동일한 토큰을 계속 태움
이 글에서는 Anthropic Claude에서 429 Too Many Requests가 발생했을 때, 비용과 안정성을 동시에 지키는 재시도·백오프 패턴을 실전 관점에서 정리합니다. Node.js 예제를 중심으로 설명하지만, 원리는 어떤 언어에도 그대로 적용됩니다.
참고로 툴 호출을 함께 쓰는 경우, 실패 후 재시도 과정에서 JSON 파싱 에러까지 연쇄로 터지기도 합니다. 툴 호출 응답 파싱 이슈는 별도로 정리한 글도 함께 참고하면 좋습니다: Claude Tool Use JSON 파싱 오류 5분 해결
429가 “비용 문제”가 되는 이유
429는 레이트 리밋 초과를 의미합니다. 보통 다음 두 종류가 섞여 나타납니다.
- 요청 수 기반 제한: 초당 요청 수가 제한을 넘음
- 토큰 기반 제한: 분당 입력·출력 토큰이 제한을 넘음
여기서 비용 폭증은 주로 다음 패턴에서 생깁니다.
- 사용자가 한 번 클릭
- 서버가 Claude에 요청
429발생- 서버가 즉시 재시도(또는 짧은 딜레이)
- 여전히
429라 또 재시도 - 그 사이 같은 사용자의 다른 요청도 쌓여 전체 큐가 폭발
즉, 재시도 로직이 공격 트래픽처럼 동작합니다. 이때 “재시도는 선”이라는 믿음이 오히려 비용을 키웁니다.
목표: 재시도는 하되, 폭발을 막는다
정답은 단순히 “재시도 횟수를 줄이자”가 아닙니다. 다음 5가지를 함께 설계해야 합니다.
- 지수 백오프(Exponential Backoff)
- 지터(Jitter) 로 동시 재시도 쏠림 방지
- 재시도 예산(Retry Budget) 으로 무한 루프 차단
- 동시성 제한(Concurrency Limit) 으로 토큰·요청 폭주 억제
- 서킷 브레이커(Circuit Breaker) 로 장애 시 빠른 실패
여기에 추가로, 가능하면 서버에서 Retry-After 헤더를 존중하고, 사용자에게는 “지연/대기”를 명확히 전달하는 것이 UX와 비용 모두에 유리합니다.
기본 원칙: 어떤 에러를 재시도할 것인가
다음은 실무에서 흔히 쓰는 분류입니다.
- 재시도 가능
429레이트 리밋500~599서버 오류- 네트워크 타임아웃, 일시적 DNS 오류
- 재시도 비권장
400잘못된 요청(프롬프트 포맷, 필드 누락)401/403인증/권한 문제404잘못된 엔드포인트
인증/권한 문제는 재시도해도 해결되지 않습니다. Azure OpenAI 사례지만 원리 동일하게, 권한/엔드포인트는 “진단 후 수정”이 먼저입니다: Azure OpenAI 401/403 - RBAC·엔드포인트 오류 해결
지수 백오프 + 지터: 최소 구성
가장 흔한 안티패턴은 setTimeout(1000); retry; 같은 고정 딜레이입니다. 여러 인스턴스가 동시에 재시도하면 1초마다 한꺼번에 몰려 다시 429를 재생산합니다.
지수 백오프는 대기 시간을 점점 늘리고, 지터는 그 시간에 랜덤성을 섞어 동시 재시도를 분산합니다.
Node.js 예제: 안전한 재시도 유틸
아래 예제는 다음을 포함합니다.
- 최대 재시도 횟수 제한
- 지수 백오프
- 지터
Retry-After헤더가 있으면 우선 적용
type RetryOptions = {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
};
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function calcBackoffMs(attempt: number, baseDelayMs: number, maxDelayMs: number) {
const exp = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
// full jitter: 0..exp
return Math.floor(Math.random() * exp);
}
function parseRetryAfterMs(retryAfter: string | null) {
if (!retryAfter) return null;
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const date = new Date(retryAfter);
const ms = date.getTime() - Date.now();
if (Number.isFinite(ms)) return Math.max(0, ms);
return null;
}
async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions): Promise<T> {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err: any) {
const status = err?.status ?? err?.response?.status;
const headers = err?.headers ?? err?.response?.headers;
const retryable = status === 429 || (status >= 500 && status <= 599) || status == null;
if (!retryable) throw err;
if (attempt >= opts.maxRetries) throw err;
const retryAfterMs = parseRetryAfterMs(headers?.["retry-after"] ?? headers?.get?.("retry-after") ?? null);
const backoffMs = calcBackoffMs(attempt, opts.baseDelayMs, opts.maxDelayMs);
const delayMs = retryAfterMs != null ? Math.min(opts.maxDelayMs, retryAfterMs) : backoffMs;
await sleep(delayMs);
attempt += 1;
}
}
}
이 정도만 해도 “동시에 몰려서 계속 재시도”하는 상황은 크게 줄어듭니다.
하지만 과금폭탄을 진짜로 막으려면, 재시도 자체를 “예산”으로 관리해야 합니다.
재시도 예산(Retry Budget): 비용 상한선 만들기
재시도 예산은 간단히 말해 “실패한 요청을 살리기 위해 쓸 수 있는 추가 호출 횟수/토큰의 상한”입니다.
예를 들어 다음 정책을 둘 수 있습니다.
- 사용자 요청 1건당 Claude 호출 최대 2회까지만 허용
- 또는 최근 1분 동안 전체 재시도는 총 20회까지만 허용
- 또는 최근 5분 동안 재시도로 소비 가능한 토큰을 제한
이렇게 하면 장애/레이트 리밋 상황에서 시스템이 스스로 비용 상한을 걸고, 더 이상의 폭주를 멈춥니다.
Node.js 예제: 간단한 전역 재시도 예산
프로덕션에서는 Redis 같은 공유 스토리지가 더 안전하지만, 단일 인스턴스 기준의 최소 구현은 다음과 같습니다.
class RetryBudget {
private windowMs: number;
private limit: number;
private used: number = 0;
private windowStart: number = Date.now();
constructor(windowMs: number, limit: number) {
this.windowMs = windowMs;
this.limit = limit;
}
tryConsume(n: number) {
const now = Date.now();
if (now - this.windowStart >= this.windowMs) {
this.windowStart = now;
this.used = 0;
}
if (this.used + n > this.limit) return false;
this.used += n;
return true;
}
}
const retryBudget = new RetryBudget(60_000, 30); // 1분에 재시도 30회까지
async function guardedRetry<T>(fn: () => Promise<T>) {
return withRetry(fn, {
maxRetries: 4,
baseDelayMs: 250,
maxDelayMs: 10_000,
});
}
async function callClaudeWithBudget<T>(fn: () => Promise<T>) {
try {
return await fn();
} catch (err: any) {
const status = err?.status ?? err?.response?.status;
if (status !== 429) throw err;
// 429에서만 예산을 태워 재시도를 허용
if (!retryBudget.tryConsume(1)) {
const e = new Error("Rate limited and retry budget exceeded");
(e as any).status = 429;
throw e;
}
return guardedRetry(fn);
}
}
핵심은 “모든 요청을 끝까지 살리겠다”가 아니라, 시스템 전체가 죽지 않도록 “살릴 요청만 살린다”는 전략입니다.
동시성 제한: 429를 만들지 않는 것이 최선
재시도는 사후약방문입니다. 비용과 안정성 측면에서 더 효과적인 것은 애초에 Claude로 나가는 동시 요청을 제한하는 것입니다.
- 웹 서버가 100개의 요청을 동시에 받더라도
- Claude로 나가는 호출은 예를 들어 5개만 동시에 수행
- 나머지는 큐에서 대기
이렇게 하면 429 자체가 크게 줄고, 재시도에 쓰는 토큰도 줄어듭니다.
Node.js 예제: p-limit로 동시성 제한
import pLimit from "p-limit";
const limit = pLimit(5); // Claude 동시 호출 5개로 제한
async function callClaudeLimited<T>(fn: () => Promise<T>) {
return limit(() => callClaudeWithBudget(fn));
}
큐 대기 시간이 늘어날 수는 있지만, 폭주로 인한 장애와 비용 폭증을 생각하면 훨씬 예측 가능한 운영이 됩니다.
서킷 브레이커: 레이트 리밋 구간에서는 빨리 실패하라
레이트 리밋이 계속 걸리는 구간에서 재시도는 대부분 실패합니다. 이때는 일정 조건에서 “일정 시간 동안 Claude 호출을 아예 중단”하는 서킷 브레이커가 더 싸고 안전합니다.
서킷 브레이커의 전형적인 동작:
429가 일정 비율 이상 발생하면 회로를 열고(Open)- N초 동안 모든 요청을 즉시 실패 처리(또는 캐시/대체 응답)
- N초 후 일부만 시험 호출(Half-open)
- 정상화되면 다시 닫음(Close)
Node.js 예제: 최소 서킷 브레이커 스케치
class CircuitBreaker {
private openUntil = 0;
private failureCount = 0;
constructor(private threshold: number, private coolDownMs: number) {}
canPass() {
return Date.now() >= this.openUntil;
}
onSuccess() {
this.failureCount = 0;
}
onRateLimit() {
this.failureCount += 1;
if (this.failureCount >= this.threshold) {
this.openUntil = Date.now() + this.coolDownMs;
this.failureCount = 0;
}
}
}
const cb = new CircuitBreaker(10, 15_000);
async function callClaudeProtected<T>(fn: () => Promise<T>) {
if (!cb.canPass()) {
const e = new Error("Circuit open: temporarily rejecting Claude calls");
(e as any).status = 503;
throw e;
}
try {
const res = await callClaudeLimited(fn);
cb.onSuccess();
return res;
} catch (err: any) {
const status = err?.status ?? err?.response?.status;
if (status === 429) cb.onRateLimit();
throw err;
}
}
운영에서는 실패율 기반, 슬라이딩 윈도우, 지표 연동 등 더 정교하게 만들지만, 개념은 이 정도로도 충분히 적용 가능합니다.
스트리밍과 재시도: “부분 성공”을 어떻게 볼 것인가
스트리밍 응답을 받다가 중간에 끊기면, 클라이언트는 종종 “실패”로 판단하고 재시도합니다. 그런데 이미 일부 토큰은 생성되어 과금이 발생했을 수 있습니다.
따라서 스트리밍에서는 다음 정책이 중요합니다.
- 스트림이 일정 길이 이상 진행되었으면 무조건 재시도하지 않기
- 재시도하더라도, 이미 받은 텍스트를 프롬프트에 포함해 이어쓰기(continuation)로 설계
- 사용자에게 “응답이 길어 지연”인지 “실패”인지 구분해서 표시
특히 “끊겼으니 처음부터 다시”는 비용을 2배, 3배로 만드는 지름길입니다.
프롬프트/토큰 관점 최적화: 429를 줄이는 비용 절감
429가 토큰 기반 제한에서 주로 발생한다면, 다음이 바로 효과가 납니다.
- 시스템 프롬프트를 짧게 유지하고 중복 텍스트 제거
- 대화 히스토리를 무한히 누적하지 말고 요약(summarize)로 축약
- 출력 길이 제한을 명확히(예: 최대 N자, 불릿 N개)
- RAG를 쓰면 검색 결과를 상위 N개만, 길이 제한과 함께 넣기
이런 최적화는 레이트 리밋 완화뿐 아니라 순수 비용 절감에도 직결됩니다.
관측 가능성: 429를 “지표”로 다뤄야 한다
재시도 로직을 넣어도, 지표가 없으면 어느 날 비용이 튀고 나서야 알게 됩니다. 최소한 아래를 기록하세요.
status=429비율- 재시도 횟수 분포(요청당 평균 재시도)
- 백오프 대기 시간 분포
- 사용자/테넌트별 호출량 상위
- 분당 입력 토큰, 출력 토큰
그리고 알람은 “429 발생”이 아니라 “429가 일정 비율을 넘음” 혹은 “재시도 예산 소진” 같은 운영 신호에 걸어두는 편이 노이즈가 적습니다.
실전 권장 조합(운영 난이도 대비 효과)
가장 추천하는 조합은 다음 순서입니다.
- 지수 백오프 + 지터
- 최대 재시도 횟수 제한
- 동시성 제한
- 재시도 예산
- 서킷 브레이커
이 5개를 넣으면 429가 떠도 시스템이 스스로 속도를 낮추고, 최악의 경우에도 비용이 상한선 안에서 제어됩니다.
마무리: “재시도”는 기능이 아니라 비용 정책이다
Claude의 429는 흔한 운영 이벤트지만, 대응 방식에 따라 단순 지연으로 끝날 수도 있고 비용 폭증으로 이어질 수도 있습니다. 재시도는 성공률을 올리는 도구이기도 하지만, 동시에 비용을 태우는 스위치이기도 합니다.
- 지수 백오프와 지터로 재시도 폭주를 막고
- 동시성 제한으로 429 자체를 줄이며
- 재시도 예산과 서킷 브레이커로 비용 상한을 강제
이 조합을 적용하면 “서비스는 살아있는데 청구서가 죽는” 상황을 대부분 피할 수 있습니다.