Published on

Claude API 529 과부하 대응 - 재시도·큐잉 설계

Authors

서버가 안정적으로 보이는데도 Claude 호출만 몰리면 간헐적으로 529가 터지는 경우가 있습니다. 이때 무작정 재시도를 늘리면 더 큰 폭주(thundering herd)를 만들고, 사용자 체감 지연은 길어지며, 결국 전체 시스템이 느려지는 악순환이 생깁니다.

이 글은 Anthropic Claude API의 529 과부하 상황을 전제로, 재시도 정책을 “안전하게” 설계하고, 큐잉으로 트래픽을 평탄화하며, 관측(Observability)과 장애 격리까지 포함한 실무 패턴을 정리합니다.

529는 무엇이고, 왜 위험한가

529는 보통 “Overloaded” 계열로 해석되는 응답입니다. 즉, 클라이언트 요청이 틀린 것이 아니라 서버가 일시적으로 처리 여력이 부족하다는 신호입니다.

이 신호를 잘못 다루면 다음 문제가 터집니다.

  • 재시도 폭주: 모든 요청이 동시에 재시도하면 부하가 더 커져 회복이 늦어짐
  • 꼬리 지연 증가: 일부 요청이 수십 초~수분까지 늘어져 사용자 경험 악화
  • 리소스 고갈: 애플리케이션 워커/스레드/이벤트루프가 대기 요청으로 잠김
  • 비용 폭증: 실패한 호출도 토큰/네트워크/인프라 비용을 유발

따라서 핵심은 “성공률을 올리는 재시도”가 아니라, 전체 시스템을 안정화하는 재시도·큐잉·차단의 조합입니다.

대응 전략의 큰 그림

실무에서 권장하는 우선순위는 다음과 같습니다.

  1. 클라이언트 측 재시도는 제한적으로: 지수 백오프 + 지터 + 최대 시도 수 + 전체 타임아웃
  2. 동시성 제한(Concurrency limit): 프로세스/인스턴스 단위로 Claude 호출을 제한
  3. 큐잉(Queueing)으로 평탄화: 들어오는 요청을 흡수하고, 워커가 일정 속도로 처리
  4. 서킷 브레이커(Circuit breaker): 529가 일정 비율 이상이면 짧게 차단하고 빠르게 실패/대체
  5. 우선순위/등급별 정책: 유료/핵심 플로우는 더 잘 살리고, 배치/저우선은 과감히 지연
  6. 관측과 자동 튜닝: 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 Acceptedjob_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, p99
  • retry_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는 ‘장애’가 아니라 ‘일시적 지연’으로 격하되고, 운영자는 지표를 보며 처리율을 튜닝할 수 있게 됩니다.