- Published on
Claude API 529 과부하 대응 - 재시도·큐잉 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 안정적으로 보이는데도 Claude 호출만 몰리면 간헐적으로 529가 터지는 경우가 있습니다. 이때 무작정 재시도를 늘리면 더 큰 폭주(thundering herd)를 만들고, 사용자 체감 지연은 길어지며, 결국 전체 시스템이 느려지는 악순환이 생깁니다.
이 글은 Anthropic Claude API의 529 과부하 상황을 전제로, 재시도 정책을 “안전하게” 설계하고, 큐잉으로 트래픽을 평탄화하며, 관측(Observability)과 장애 격리까지 포함한 실무 패턴을 정리합니다.
529는 무엇이고, 왜 위험한가
529는 보통 “Overloaded” 계열로 해석되는 응답입니다. 즉, 클라이언트 요청이 틀린 것이 아니라 서버가 일시적으로 처리 여력이 부족하다는 신호입니다.
이 신호를 잘못 다루면 다음 문제가 터집니다.
- 재시도 폭주: 모든 요청이 동시에 재시도하면 부하가 더 커져 회복이 늦어짐
- 꼬리 지연 증가: 일부 요청이 수십 초~수분까지 늘어져 사용자 경험 악화
- 리소스 고갈: 애플리케이션 워커/스레드/이벤트루프가 대기 요청으로 잠김
- 비용 폭증: 실패한 호출도 토큰/네트워크/인프라 비용을 유발
따라서 핵심은 “성공률을 올리는 재시도”가 아니라, 전체 시스템을 안정화하는 재시도·큐잉·차단의 조합입니다.
대응 전략의 큰 그림
실무에서 권장하는 우선순위는 다음과 같습니다.
- 클라이언트 측 재시도는 제한적으로: 지수 백오프 + 지터 + 최대 시도 수 + 전체 타임아웃
- 동시성 제한(Concurrency limit): 프로세스/인스턴스 단위로 Claude 호출을 제한
- 큐잉(Queueing)으로 평탄화: 들어오는 요청을 흡수하고, 워커가 일정 속도로 처리
- 서킷 브레이커(Circuit breaker): 529가 일정 비율 이상이면 짧게 차단하고 빠르게 실패/대체
- 우선순위/등급별 정책: 유료/핵심 플로우는 더 잘 살리고, 배치/저우선은 과감히 지연
- 관측과 자동 튜닝: 529율, 재시도 횟수, 큐 길이, p95/p99 지연을 기반으로 조정
이 구조는 DB 커넥션 고갈을 막기 위해 “동시성 제한 + 큐잉”을 두는 방식과 유사합니다. 가상 스레드가 있어도 병목은 사라지지 않듯, Claude 호출도 무한 동시성을 주면 결국 외부 의존성에서 터집니다. 관련해서는 Spring Boot 3 가상스레드로 DB 커넥션 고갈 막기 글의 사고방식이 그대로 적용됩니다.
재시도 설계: “언제, 몇 번, 어떻게”
1) 재시도 대상 분류
- 재시도 권장
529과부하408타임아웃(네트워크 성격일 때)5xx계열(일시 장애)
- 재시도 금지 또는 매우 제한
4xx중 입력 오류(예: 인증/권한/요청 형식)- 토큰/프롬프트가 규격을 위반하는 경우
529는 “일시적”이라는 전제가 강하므로 재시도 자체는 타당하지만, 동시 재시도는 금물입니다.
2) 지수 백오프 + 풀 지터(Full Jitter)
- 지수 백오프:
base * 2^attempt - 지터: 대기 시간을 무작위로 분산
풀 지터 예시:
sleep = random(0, min(cap, base * 2^attempt))
이 방식은 재시도 요청이 한 타이밍에 몰리는 것을 크게 줄여줍니다.
3) Retry-After가 있으면 최우선
서버가 Retry-After 헤더를 준다면 그 값을 최우선으로 존중하세요. 없으면 백오프로 계산합니다.
4) 최대 시도 수와 전체 타임아웃
- 최대 재시도 횟수: 예를 들어 3~6회
- 전체 타임아웃: 예를 들어 20~60초
여기서 중요한 건 “재시도를 많이 해서 결국 성공”이 아니라, 사용자 경험과 시스템 안정성에 맞는 상한을 두는 것입니다.
동시성 제한: 재시도보다 먼저 해야 하는 것
재시도를 잘 설계해도, 동시에 200개가 Claude로 날아가면 529는 계속 납니다. 따라서 인스턴스 단위 동시성 제한이 1차 방어선입니다.
- 예: 인스턴스당 Claude 호출 동시성 5~20
- 트래픽이 늘면 인스턴스를 늘리되, 각 인스턴스의 동시성은 유지
이렇게 하면 “요청은 들어오되 처리 속도는 일정”해지고, 나머지는 큐가 흡수합니다.
큐잉 설계: 529를 ‘흡수’하는 완충 장치
큐잉은 529 대응에서 가장 강력한 카드입니다. 목표는 간단합니다.
- 프론트/API 서버는 빠르게 응답(접수/진행 상태)
- 백그라운드 워커가 정해진 속도로 처리
- 과부하 시 큐가 늘어나며, 필요하면 드롭/지연/대체
큐잉이 특히 유리한 요청
- 콘텐츠 생성, 요약, 분류 등 비동기 처리 가능한 작업
- 사용자가 “즉시 답”이 아니어도 되는 플로우
- 배치/백필/인덱싱
반대로, 채팅처럼 초저지연이 중요한 플로우는 큐잉을 쓰더라도 짧은 큐 + 빠른 실패가 필요합니다.
큐 메시지 설계 핵심
idempotency_key: 중복 실행 방지user_id또는tenant_id: 공정성/쿼터 적용priority: 우선순위attempt: 재시도 횟수created_at,deadline_at: 만료/드롭 판단payload_hash: 동일 작업 dedupe
Node.js 예시: 동시성 제한 + 재시도 래퍼
아래 코드는 “인스턴스 내 동시성 제한”과 “529 재시도”를 함께 묶은 최소 예시입니다.
import pLimit from "p-limit";
type ClaudeResponse = unknown;
type CallOptions = {
maxRetries?: number;
baseDelayMs?: number;
capDelayMs?: number;
timeoutMs?: number;
};
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function parseRetryAfterSeconds(headers: Headers): number | null {
const v = headers.get("retry-after");
if (!v) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function fullJitterDelay(base: number, cap: number, attempt: number) {
const exp = Math.min(cap, base * Math.pow(2, attempt));
return Math.floor(Math.random() * exp);
}
async function withTimeout<T>(p: Promise<T>, timeoutMs: number): Promise<T> {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), timeoutMs);
try {
// 호출부에서 signal을 받아 사용하도록 구성하는 편이 더 좋지만,
// 예시는 단순화를 위해 timeout 경쟁으로 처리합니다.
return await Promise.race([
p,
new Promise<T>((_, rej) =>
ac.signal.addEventListener("abort", () => rej(new Error("timeout")))
),
]);
} finally {
clearTimeout(t);
}
}
async function callClaudeOnce(prompt: string): Promise<{ ok: boolean; status: number; headers: Headers; json?: ClaudeResponse }> {
// 실제로는 Anthropic SDK를 쓰거나 fetch로 호출합니다.
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": process.env.ANTHROPIC_API_KEY ?? "",
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-3-5-sonnet-latest",
max_tokens: 512,
messages: [{ role: "user", content: prompt }],
}),
});
const ok = res.ok;
const status = res.status;
const headers = res.headers;
if (!ok) return { ok, status, headers };
return { ok, status, headers, json: await res.json() };
}
export function createClaudeClient(concurrency: number) {
const limit = pLimit(concurrency);
return {
call(prompt: string, opts: CallOptions = {}) {
const {
maxRetries = 4,
baseDelayMs = 250,
capDelayMs = 4000,
timeoutMs = 30000,
} = opts;
return limit(async () => {
let lastErr: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const res = await withTimeout(callClaudeOnce(prompt), timeoutMs);
if (res.ok) return res.json;
// 529: 과부하이므로 재시도 후보
if (res.status === 529) {
const ra = parseRetryAfterSeconds(res.headers);
const delay = ra != null
? ra * 1000
: fullJitterDelay(baseDelayMs, capDelayMs, attempt);
await sleep(delay);
continue;
}
// 기타 상태코드: 여기서는 단순화
throw new Error(`claude error status=${res.status}`);
} catch (e) {
lastErr = e;
// 네트워크 오류도 제한적으로 재시도 가능
if (attempt < maxRetries) {
const delay = fullJitterDelay(baseDelayMs, capDelayMs, attempt);
await sleep(delay);
continue;
}
}
}
throw lastErr ?? new Error("claude call failed");
});
},
};
}
이 예시의 포인트는 다음입니다.
- 동시성 제한이 먼저 적용되어, 재시도도 제한된 풀 안에서 수행됨
529에만 명시적으로 백오프를 적용Retry-After가 있으면 우선
하지만 이것만으로는 “요청 폭증”을 근본적으로 흡수하지 못합니다. 다음 단계가 큐입니다.
큐 기반 비동기 처리: API는 접수만, 워커가 실행
HTTP API 패턴
POST /ai/jobs요청 수신- 즉시 DB 또는 큐에 작업을 저장
202 Accepted와job_id반환- 클라이언트는
GET /ai/jobs/{job_id}로 폴링하거나 SSE/WebSocket으로 수신
여기서 중요한 건 프론트/API 서버가 Claude 응답을 기다리며 붙잡히지 않게 만드는 것입니다.
PostgreSQL 기반 간단 큐(예시)
외부 큐(Redis, SQS, Kafka)를 쓰는 게 일반적이지만, 작은 서비스는 PostgreSQL만으로도 시작할 수 있습니다.
create table ai_job (
id bigserial primary key,
idempotency_key text not null,
status text not null check (status in ('queued','running','succeeded','failed')),
priority int not null default 0,
payload jsonb not null,
attempt int not null default 0,
available_at timestamptz not null default now(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index ai_job_idem_uk on ai_job (idempotency_key);
create index ai_job_pick_idx on ai_job (status, available_at, priority, created_at);
워커가 잡을 집어오는 쿼리는 SKIP LOCKED로 경합을 줄입니다.
with next_job as (
select id
from ai_job
where status = 'queued'
and available_at <= now()
order by priority desc, created_at asc
for update skip locked
limit 1
)
update ai_job
set status = 'running', updated_at = now()
where id in (select id from next_job)
returning *;
워커에서 529를 만나면 “재큐잉”
재시도를 워커 내부에서 하되, 중요한 차이는 “잠깐 sleep”이 아니라 available_at을 미래로 밀어 큐로 되돌리는 것입니다. 이렇게 하면 워커 스레드/프로세스가 대기 상태로 낭비되지 않습니다.
function computeBackoffMs(attempt: number) {
const base = 500;
const cap = 15000;
const exp = Math.min(cap, base * Math.pow(2, attempt));
return Math.floor(Math.random() * exp);
}
// 의사코드
async function handleJob(job: any, claude: any, db: any) {
try {
const result = await claude.call(job.payload.prompt, { maxRetries: 1 });
await db.query(
"update ai_job set status='succeeded', updated_at=now(), payload = payload || $2::jsonb where id=$1",
[job.id, JSON.stringify({ result })]
);
} catch (e: any) {
const isOverload = String(e?.message ?? "").includes("status=529");
if (isOverload && job.attempt < 10) {
const delay = computeBackoffMs(job.attempt);
await db.query(
"update ai_job set status='queued', attempt=attempt+1, available_at=now()+($2||' milliseconds')::interval, updated_at=now() where id=$1",
[job.id, String(delay)]
);
return;
}
await db.query(
"update ai_job set status='failed', updated_at=now() where id=$1",
[job.id]
);
}
}
이 패턴의 장점:
- 워커 리소스를 “대기”에 쓰지 않음
- 백오프가 큐에 반영되어 전체 처리율이 안정화
- 인스턴스 수를 늘리면 처리량이 선형적으로 증가
서킷 브레이커: 529가 지속될 때 빠르게 차단
“재시도 + 큐잉”이 있어도, 공급자 측 장애나 계정/리전 이슈로 529가 장시간 지속될 수 있습니다. 이때는 서킷 브레이커로 짧은 시간 호출 자체를 중단하고, 다음 중 하나로 전환합니다.
- 캐시된 결과/요약 제공
- 더 작은 모델 또는 다른 제공자 fallback
- 사용자에게 지연 안내 + 비동기 완료 알림
서킷 브레이커의 트리거 예:
- 최근 1분간 Claude 호출 중
529비율이 30% 이상 - 연속
529가 N회 이상
차단 기간 예:
- 5초, 15초, 30초처럼 점진 증가
중요한 점은 “장애 전파 차단”입니다. 외부 의존성이 흔들릴 때 내부까지 함께 흔들리지 않게 해야 합니다.
우선순위와 공정성: 테넌트별 쿼터
B2B/멀티테넌트 환경에서는 한 고객의 폭주가 다른 고객을 망치지 않게 해야 합니다.
- 테넌트별 동시성 제한(예: 테넌트당 2)
- 테넌트별 초당 처리량 제한
- 우선순위 큐(유료 플랜 우선)
큐 테이블에 tenant_id 컬럼을 추가하고, 워커가 테넌트별로 라운드 로빈 픽업을 하는 방식도 실무에서 자주 씁니다.
관측(Observability): 529 대응은 “측정”이 반이다
최소한 아래 지표는 반드시 봐야 합니다.
claude_requests_total{status}: 상태코드별 카운트claude_529_rate: 529 비율claude_latency_ms_p95,p99retry_attempts_histogram: 재시도 횟수 분포queue_depth: 큐 길이queue_wait_ms_p95: 큐 대기 시간job_success_rate,job_dead_letter_count
사용자 체감 지연이 급락하는 상황에서는 Long Task나 이벤트루프 블로킹도 함께 의심해야 합니다. 특히 프론트에서 폴링/SSE 처리로 메인 스레드가 막히면 체감이 크게 나빠질 수 있어, 성능 추적 루틴을 갖추는 게 좋습니다. 관련해서는 Chrome INP 점수 급락 - Long Task 5분 추적법도 함께 참고할 만합니다.
운영 팁: 자주 놓치는 디테일
Idempotency key는 필수
재시도/재큐잉을 하면 “같은 작업이 두 번 실행”될 수 있습니다. 특히 네트워크 타임아웃은 서버에서 성공했는데 클라이언트만 실패로 인지할 수 있습니다.
- 요청 단위
idempotency_key를 만들고 - DB에 유니크 인덱스를 걸어
- 동일 키면 기존 결과를 반환
이것이 없으면 529 상황에서 중복 비용이 폭발합니다.
Dead Letter Queue(DLQ) 또는 실패 보관
- 시도 횟수 초과
- deadline 초과
- 입력 자체가 잘못된 작업
이런 작업은 별도 테이블/토픽으로 분리해 운영자가 재처리할 수 있게 합니다.
큐가 길어질 때의 사용자 경험
- 예상 대기시간을 노출하거나
- “완료 시 알림”으로 전환하거나
- 저우선 작업은 자동 취소
기술적으로는 성공했지만 UX가 망가지면 장애로 인식됩니다.
추천 아키텍처 조합(실무용)
- 동기 API(채팅/즉시 응답)
- 인스턴스 동시성 제한
529지수 백오프 + 풀 지터, 최대 2~4회- 서킷 브레이커로 빠른 실패 + fallback
- 비동기 API(생성/요약/배치)
- 큐잉(우선순위/테넌트 공정성)
- 워커에서 재큐잉 기반 백오프
- DLQ + 재처리 도구
이 둘을 분리하면 529가 와도 “즉시 응답” 경로가 배치 트래픽에 의해 잠기지 않습니다.
마무리
Claude API의 529는 단순한 에러가 아니라 “지금은 천천히 보내라”는 신호입니다. 안정적인 시스템은 이 신호를 받아서
- 재시도는 짧고 분산되게,
- 동시성은 명시적으로 제한하고,
- 큐잉으로 부하를 평탄화하며,
- 서킷 브레이커로 장애 전파를 차단합니다.
이 4가지만 갖춰도 529는 ‘장애’가 아니라 ‘일시적 지연’으로 격하되고, 운영자는 지표를 보며 처리율을 튜닝할 수 있게 됩니다.