- Published on
Claude API 529 Overloaded 재시도·폴백 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Claude API를 운영 환경에 붙이다 보면, 요청이 정상인데도 간헐적으로 529(Overloaded) 응답을 만나는 순간이 있습니다. 이 코드는 애플리케이션 버그라기보다 공급자 측 과부하 또는 순간적인 용량 부족을 의미하는 경우가 많습니다. 문제는 “그럼 재시도하면 되지”로 끝나지 않는다는 점입니다.
- 재시도가 동시에 몰리면 오히려 과부하를 악화시켜 더 오래 실패합니다.
- 사용자 요청은 기다려주지 않습니다. 지연이 커지면 UX가 무너집니다.
- 비동기 작업(요약, 분류, 임베딩 등)은 큐로 넘기는 편이 전체 안정성에 유리합니다.
이 글에서는 529를 포함한 과부하 계열 실패를 재시도 정책, 서킷 브레이커, 폴백(모델/기능/캐시/큐), 관측(Observability) 관점에서 한 덩어리로 설계하는 방법을 정리합니다.
문맥상 함께 보면 좋은 글:
529 Overloaded를 “일시 장애”로만 보면 생기는 함정
529는 대체로 “잠깐 기다렸다 다시 시도하면 된다”에 가깝지만, 운영에서는 다음 함정이 큽니다.
- 동시 재시도 폭풍(thundering herd)
- 트래픽 피크에
529가 뜨면, 다수의 워커/서버가 동시에 재시도합니다. - 재시도 자체가 추가 부하가 되어 회복 시간을 늘립니다.
- 동일 요청의 중복 처리 비용
- LLM 호출은 비싸고 느립니다.
- 동일 입력을 여러 번 보내는 순간 비용과 지연이 동시에 증가합니다.
- 사용자 체감 지연의 급증
- “최대 5회 재시도” 같은 단순 정책은 최악의 경우 응답 시간을 수십 초로 늘릴 수 있습니다.
- 특히 동기 API(채팅, 검색 보조)에서는 타임아웃 전에 폴백이 필요합니다.
따라서 529 대응의 핵심은 “언제까지 재시도할지”와 “재시도 실패 시 무엇으로 대체할지”를 함께 정하는 것입니다.
재시도 설계의 기본: 지수 백오프 + 지터 + 예산
1) 지수 백오프(Exponential Backoff)
재시도 간격을 base * 2^n 형태로 늘려, 회복 시간을 줍니다.
2) 지터(Jitter)
모든 클라이언트가 같은 타이밍에 재시도하지 않도록 랜덤을 섞습니다.
- Full jitter:
sleep = random(0, backoff) - Equal jitter:
sleep = backoff/2 + random(0, backoff/2)
3) 재시도 예산(Retry budget)
재시도는 “무제한”이 아니라 예산입니다.
- 요청 단위: 최대 재시도 횟수, 최대 누적 대기 시간
- 서비스 단위: 분당 재시도 총량 제한(재시도 트래픽이 원 트래픽의 몇 %를 넘지 않게)
아래는 Node.js(Typescript)에서 529에 대해 지수 백오프+지터+예산을 적용한 예시입니다.
type ClaudeCall<T> = () => Promise<T>
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms))
}
function isRetryableStatus(status: number) {
// 과부하/일시 장애 계열을 묶어서 취급
return status === 529 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504
}
export async function withRetry<T>(fn: ClaudeCall<T>, opts?: {
maxAttempts?: number
baseDelayMs?: number
maxDelayMs?: number
maxTotalSleepMs?: number
}) {
const maxAttempts = opts?.maxAttempts ?? 5
const baseDelayMs = opts?.baseDelayMs ?? 250
const maxDelayMs = opts?.maxDelayMs ?? 5_000
const maxTotalSleepMs = opts?.maxTotalSleepMs ?? 8_000
let totalSleep = 0
let lastErr: any
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn()
} catch (err: any) {
lastErr = err
const status = err?.status ?? err?.response?.status
if (!status || !isRetryableStatus(status) || attempt === maxAttempts) {
throw err
}
const exp = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt - 1))
const jitter = Math.floor(Math.random() * exp) // full jitter
const delay = Math.min(jitter, maxTotalSleepMs - totalSleep)
if (delay <= 0) {
throw err
}
await sleep(delay)
totalSleep += delay
}
}
throw lastErr
}
포인트는 maxTotalSleepMs 같은 누적 대기 상한입니다. 동기 요청은 “재시도하다가 타임아웃”이 가장 나쁜 결말이므로, 일정 시간 내에 안 되면 폴백으로 넘어가야 합니다.
재시도만으로 부족할 때: 서킷 브레이커 + 동시성 제한
1) 서킷 브레이커(Circuit Breaker)
529가 일정 비율 이상 지속되면, 당분간 Claude 호출을 “빠르게 실패(fail fast)”시키고 폴백으로 보냅니다.
- Closed: 정상 호출
- Open: 일정 시간 호출 차단(즉시 폴백)
- Half-open: 일부만 테스트 호출 후 회복 판단
이를 적용하면 장애 시 재시도 폭풍을 막고, 시스템 전체를 보호할 수 있습니다.
2) 동시성 제한(Concurrency limit)
한 프로세스/한 노드에서 Claude로 나가는 동시 요청 수를 제한합니다.
- 동시성 제한은 “우리 서비스가 공급자에 주는 압력”을 일정하게 만들고, tail latency를 줄입니다.
간단한 세마포어 예시입니다.
class Semaphore {
private queue: Array<() => void> = []
private inUse = 0
constructor(private readonly max: number) {}
async acquire() {
if (this.inUse < this.max) {
this.inUse++
return
}
await new Promise<void>((resolve) => this.queue.push(resolve))
this.inUse++
}
release() {
this.inUse--
const next = this.queue.shift()
if (next) next()
}
}
const claudeSemaphore = new Semaphore(10)
export async function limitedClaudeCall<T>(fn: () => Promise<T>) {
await claudeSemaphore.acquire()
try {
return await fn()
} finally {
claudeSemaphore.release()
}
}
실전에서는 “요청 유형별”로 동시성 풀을 나누는 편이 좋습니다.
- 채팅/검색 보조: 낮은 지연이 중요
- 배치 요약/분류: 지연 허용, 처리량 중요
폴백 설계: 실패했을 때 무엇을 제공할 것인가
529 대응의 완성은 폴백입니다. 폴백은 “대체 모델 호출”만 의미하지 않습니다.
1) 모델 폴백(다른 모델, 다른 리전, 다른 공급자)
- 1순위: 동일 공급자 내 더 가벼운 모델로 다운그레이드
- 2순위: 다른 리전 엔드포인트(가능한 경우)
- 3순위: 다른 LLM 공급자
모델 폴백은 품질 저하가 있을 수 있으므로, 기능별로 정책을 분리합니다.
- 고객 응대 메시지: 톤/정확성 중요, 무리한 다운그레이드 금지
- 단순 요약/키워드 추출: 다운그레이드 허용
2) 기능 폴백(기능 축소)
LLM이 반드시 필요한 기능만 남기고 나머지는 축소합니다.
- 예: “긴 문서 요약” 실패 시 “핵심 문장 3개만 추출”로 축소
- 예: Tool use가 실패하면 도구 호출 없이 일반 답변으로 전환
Tool use를 쓰는 경우에는 스키마 문제로 400이 나기도 하니, 과부하 대응과 별개로 스키마 검증 체계를 갖추는 게 좋습니다. 관련해서는 Claude Tool Use 400 invalid_tool_schema 해결 가이드도 함께 참고하면 설계가 단단해집니다.
3) 캐시 폴백(최근 응답/결과 재사용)
동일 프롬프트(또는 정규화된 입력)에 대한 결과를 캐시해두면, 과부하 시에도 “최소한의 응답”을 유지할 수 있습니다.
캐시 키 설계 팁:
- 사용자 입력 원문 그대로는 위험(개인정보, 길이)
- 정규화 후 해시: 공백 정리, 날짜/세션 값 제거, 중요 파라미터만 포함
import crypto from "crypto"
function cacheKey(input: {
userId: string
task: string
modelFamily: string
prompt: string
}) {
const normalized = input.prompt.trim().replace(/\s+/g, " ")
const raw = JSON.stringify({
userId: input.userId,
task: input.task,
modelFamily: input.modelFamily,
prompt: normalized,
})
return crypto.createHash("sha256").update(raw).digest("hex")
}
캐시는 “정답이 변하면 안 되는 작업”에 특히 유효합니다.
- 문서 요약(원문이 동일)
- 규칙 기반 분류(버전이 동일)
반대로 최신성이 중요한 작업(실시간 검색/시황 요약 등)은 TTL을 짧게 두거나 캐시 폴백을 제한합니다.
4) 큐잉 폴백(비동기 전환)
동기 요청에서 529가 나면, 즉시 “접수 완료”로 전환하고 백그라운드 큐에서 처리하는 전략이 강력합니다.
- 동기 API:
202로 작업 ID 반환 - 워커: 재시도/백오프를 길게 가져가도 사용자 UX에 영향이 적음
- 결과: Webhook, SSE, 폴링, 알림 등으로 전달
이 패턴은 인프라 전반의 복원력 설계와 유사합니다. 인증/캐시 계층에서 장애를 흡수하는 방식은 Kong OIDC JWT 401 - JWKS 캐시·키회전 대응 같은 글에서 다룬 접근과도 결이 같습니다.
요청 타입별 권장 정책(실전 체크리스트)
LLM 호출을 한 정책으로 통일하면, 어떤 워크로드는 과도한 지연을 겪고 어떤 워크로드는 비용이 폭증합니다. 최소한 아래처럼 나누는 것을 권합니다.
1) 사용자 대면(온라인) 요청
- 목표: p95 지연 안정화
- 재시도: 1
2회, 누적 대기 짧게(예:1s2s) - 폴백: 더 가벼운 모델 또는 기능 축소, 캐시 결과 제공
- 서킷 브레이커: 적극적으로 Open 전환
2) 내부 자동화/배치 요청
- 목표: 처리량과 비용 최적화
- 재시도: 5~8회, 누적 대기 길게 가능
- 폴백: 큐 재적재, 지연 처리
- 서킷 브레이커: Open이어도 큐에 쌓고 천천히 소화
3) 고정확도 필수(컴플라이언스/정산)
- 목표: 정확성, 재현성
- 재시도: 충분히 하되, 결과가 늦어도 됨
- 폴백: 다른 모델로의 무분별한 전환 금지(품질/정책 리스크)
- 대안: 사람 검수 큐로 전환, 또는 “처리 지연”을 명시
관측(Observability): 529를 지표로 다루는 법
장애 대응은 로직만으로 끝나지 않고, “언제 나빠지는지”를 빨리 알아야 합니다.
필수 지표:
status=529비율(분당, 5분 이동 평균)- 재시도 횟수 분포(평균이 아니라 p95, p99)
- 누적 백오프 시간(p95)
- 폴백 발생률(모델 폴백, 캐시 폴백, 큐잉 전환 등 태그)
- 사용자 체감 지연(p95/p99)
로그/트레이싱 팁:
- 요청마다
requestId를 부여하고 재시도 시 동일 ID로 묶기 - “최초 실패 원인”과 “최종 결과(성공/폴백/실패)”를 분리 기록
- 프롬프트 원문을 그대로 로깅하지 말고 해시/길이/토큰 수만 남기기
아키텍처 예시: 동기 API에서의 안전한 호출 흐름
아래는 온라인 요청에서 권장하는 흐름 예시입니다.
- 캐시 조회
- 서킷 브레이커 확인(Open이면 폴백)
- 동시성 제한 획득
- 짧은 예산으로 재시도
- 실패 시 폴백(경량 모델 또는 기능 축소)
- 그래도 실패 시 “비동기 전환” 또는 사용자 메시지로 종료
async function answerUserQuestion(input: { userId: string; prompt: string }) {
const key = cacheKey({ userId: input.userId, task: "qa", modelFamily: "claude", prompt: input.prompt })
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
if (circuitBreaker.isOpen("claude")) {
return fallbackAnswer("temporary_overload")
}
try {
const result = await limitedClaudeCall(() =>
withRetry(
() => callClaudeApi(input.prompt),
{ maxAttempts: 2, maxTotalSleepMs: 1200, baseDelayMs: 200, maxDelayMs: 1200 }
)
)
await redis.setex(key, 60, JSON.stringify(result))
return result
} catch (e: any) {
const status = e?.status ?? e?.response?.status
if (status === 529 || status === 503 || status === 429) {
circuitBreaker.recordFailure("claude")
return fallbackAnswer("temporary_overload")
}
throw e
}
}
위 예시는 개념을 보여주기 위한 것이고, 실제로는 callClaudeApi에서 타임아웃(클라이언트 타임아웃과 서버 타임아웃)을 명확히 두고, 폴백 응답에는 “정확도 저하 가능” 같은 제품 문구를 포함하는 편이 안전합니다.
운영 팁: 재시도 정책을 코드가 아니라 설정으로 빼기
장애 상황에서 가장 하고 싶은 일은 “재시도 횟수 줄이기”, “폴백 빠르게 태우기”, “동시성 낮추기”입니다. 이를 배포 없이 바꾸려면 정책을 설정화해야 합니다.
- 환경 변수 또는 동적 설정(Feature flag)
- 요청 타입별 정책 테이블
- 서킷 브레이커 임계치(실패율, 최소 샘플 수, Open 유지 시간)
예를 들어 다음처럼 “작업 타입”을 키로 하는 설정을 두면, 운영 중에 qa만 빠르게 폴백시키는 식의 대응이 가능합니다.
{
"qa": { "maxAttempts": 2, "maxTotalSleepMs": 1200, "concurrency": 10 },
"summarize": { "maxAttempts": 6, "maxTotalSleepMs": 20000, "concurrency": 3 }
}
마무리: 529 대응의 정답은 조합이다
529 Overloaded는 흔히 “재시도하면 언젠가 된다”로 접근하지만, 운영에서는 재시도만으로는 비용과 지연, 그리고 장애 전파를 막기 어렵습니다.
- 재시도는
지수 백오프 + 지터 + 예산으로 제한하고 서킷 브레이커 + 동시성 제한으로 시스템을 보호하며- 실패 시에는
모델/기능/캐시/큐잉중 하나로 반드시 폴백 경로를 준비하는 것
이 조합이 갖춰지면, Claude API가 일시적으로 과부하인 순간에도 서비스는 “느리게 죽는 것”이 아니라 “품질을 조절하며 살아남는 것”에 가까워집니다.