Published on

Claude 3 API 529/503 과부하 재시도·백오프 설계

Authors

서버가 바쁜 시간대에 Claude 3 API를 호출하면 529 또는 503을 만나기 쉽습니다. 이 둘은 대개 “요청이 잘못됐다”가 아니라 “지금은 처리 여력이 없다”에 가깝기 때문에, 클라이언트가 재시도 전략을 제대로 갖추면 성공률과 지연 시간을 동시에 개선할 수 있습니다.

문제는 많은 구현이 다음 두 가지 실수를 한다는 점입니다.

  • 모든 실패를 동일하게 재시도한다
  • 즉시 재시도하거나, 고정 간격으로 재시도해서 트래픽을 더 악화시킨다

이 글에서는 529/503 과부하를 중심으로, 지수 백오프 + 지터, Retry-After 준수, 동시성 제한, 서킷 브레이커, 관측 지표 설계까지 한 번에 정리합니다.

529와 503을 “같은 재시도”로 보면 안 되는 이유

Claude 3 API에서 관측되는 과부하 계열 응답은 크게 두 축으로 나뉩니다.

  • 503 Service Unavailable: 일시적인 장애, 유지보수, 업스트림 문제 등 “서비스가 현재 요청을 처리할 수 없음”
  • 529 (Overloaded 계열로 사용되는 경우가 많음): 용량 초과, 순간적인 폭주, 내부 큐 적체 등 “지금은 너무 바쁨”

둘 다 재시도 대상인 경우가 많지만, 운영 관점에서 다음을 분리하는 게 좋습니다.

  • 즉시 실패로 처리할 케이스: 400 계열 스키마 오류, 인증 실패, 툴 스키마 오류 등
  • 재시도 후보: 429(레이트리밋), 503, 529, 네트워크 타임아웃

특히 툴 사용을 붙인 상태에서 400이 나면 재시도해도 계속 실패합니다. 이런 경우는 먼저 스키마를 고쳐야 합니다. 관련해서는 Claude Tool Use 400 invalid_tool_schema 해결 가이드도 같이 참고하면 좋습니다.

재시도 설계의 핵심: “재시도는 트래픽을 늘린다”

재시도는 성공률을 올려주지만, 동시에 추가 트래픽입니다. 과부하 상태에서 무분별한 재시도는 다음 현상을 만들 수 있습니다.

  • 서버 큐가 더 길어짐
  • 동일 요청이 여러 번 실행되어 비용 증가
  • 지연 시간이 폭증하고 타임아웃이 연쇄적으로 발생

따라서 재시도는 “몇 번 더 던져보자”가 아니라, 서버가 회복할 시간을 주면서, 동시에 클라이언트도 버티는 형태여야 합니다.

권장 정책 요약

아래 정책 조합이 실무에서 가장 안정적입니다.

  • 503/529/429 및 네트워크 오류만 재시도
  • Retry-After 헤더가 있으면 최우선으로 존중
  • 지수 백오프(Exponential Backoff) + 지터(Jitter)
  • 최대 재시도 횟수 + 최대 대기 시간 상한
  • 요청 단위 타임아웃(예: 60초)과 재시도 전체 예산(예: 2분)을 분리
  • 동시성 제한(큐잉)과 서킷 브레이커로 폭주 차단
  • 멱등성 키 또는 요청 해시로 중복 실행을 방지(가능한 범위에서)

Node.js/TypeScript 재시도·백오프 구현 예시

아래 코드는 다음 요구를 만족합니다.

  • Retry-After가 있으면 그대로 대기
  • 없으면 지수 백오프 + Full Jitter
  • 재시도 가능한 상태 코드만 재시도
  • 전체 재시도 예산을 초과하면 중단

주의: MDX에서 부등호 문자가 노출되면 빌드 에러가 날 수 있으니, 코드 블록 밖에서는 < > 또는 인라인 코드로 처리합니다.

type RetryableStatus = 429 | 503 | 529;

function isRetryableStatus(status: number): status is RetryableStatus {
  return status === 429 || status === 503 || status === 529;
}

function parseRetryAfterMs(value: string | null): number | null {
  if (!value) return null;

  // Retry-After는 초 또는 HTTP-date일 수 있음
  const asSeconds = Number(value);
  if (Number.isFinite(asSeconds)) return Math.max(0, asSeconds * 1000);

  const asDate = Date.parse(value);
  if (!Number.isNaN(asDate)) return Math.max(0, asDate - Date.now());

  return null;
}

function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, ms));
}

function fullJitterDelayMs(baseMs: number, capMs: number, attempt: number) {
  const exp = Math.min(capMs, baseMs * 2 ** attempt);
  return Math.floor(Math.random() * exp);
}

type FetchLikeResponse = {
  ok: boolean;
  status: number;
  headers: { get(name: string): string | null };
  text(): Promise<string>;
  json(): Promise<unknown>;
};

type FetchLike = (input: string, init?: Record<string, unknown>) => Promise<FetchLikeResponse>;

export async function callWithRetry<T>(
  fetchFn: FetchLike,
  url: string,
  init: Record<string, unknown>,
  parse: (res: FetchLikeResponse) => Promise<T>,
  opts?: {
    maxAttempts?: number;
    baseDelayMs?: number;
    maxDelayMs?: number;
    totalBudgetMs?: number;
  }
): Promise<T> {
  const maxAttempts = opts?.maxAttempts ?? 6;
  const baseDelayMs = opts?.baseDelayMs ?? 400;
  const maxDelayMs = opts?.maxDelayMs ?? 10_000;
  const totalBudgetMs = opts?.totalBudgetMs ?? 60_000;

  const startedAt = Date.now();
  let lastErr: unknown;

  for (let attempt = 0; attempt <= maxAttempts; attempt++) {
    const elapsed = Date.now() - startedAt;
    if (elapsed > totalBudgetMs) {
      throw new Error(`retry budget exceeded after ${elapsed}ms`);
    }

    try {
      const res = await fetchFn(url, init);

      if (res.ok) return await parse(res);

      if (!isRetryableStatus(res.status)) {
        const body = await res.text().catch(() => "");
        throw new Error(`non-retryable status=${res.status} body=${body}`);
      }

      // retryable
      const retryAfter = parseRetryAfterMs(res.headers.get("retry-after"));
      const delay = retryAfter ?? fullJitterDelayMs(baseDelayMs, maxDelayMs, attempt);

      // 마지막 시도면 종료
      if (attempt === maxAttempts) {
        const body = await res.text().catch(() => "");
        throw new Error(`retry exhausted status=${res.status} body=${body}`);
      }

      await sleep(delay);
      continue;
    } catch (e) {
      lastErr = e;

      // 네트워크 오류도 재시도하되, 마지막이면 throw
      if (attempt === maxAttempts) throw e;

      const delay = fullJitterDelayMs(baseDelayMs, maxDelayMs, attempt);
      await sleep(delay);
    }
  }

  throw lastErr instanceof Error ? lastErr : new Error("unknown error");
}

왜 Full Jitter가 좋은가

지수 백오프만 쓰면 많은 클라이언트가 비슷한 타이밍에 함께 재시도해서 “재폭주”가 생깁니다. Full Jitter는 대기 시간을 0부터 상한까지 랜덤으로 분산시켜 동시 재시도 파도를 줄입니다.

동시성 제한: 재시도보다 먼저 해야 할 일

과부하 상황에서 가장 먼저 해야 할 것은 “재시도”가 아니라 동시 요청 수를 줄이는 것입니다.

  • 한 프로세스에서 Claude 호출을 무제한으로 날리면, 529가 늘고 비용도 튑니다.
  • 제한된 워커 수로 큐잉하면, 성공률이 오르고 P95 지연이 안정됩니다.

간단한 패턴은 p-limit 같은 라이브러리로 동시성을 제한하는 겁니다.

import pLimit from "p-limit";

const limit = pLimit(5); // 동시에 5개만 Claude 호출

async function runBatch(inputs: string[]) {
  return await Promise.all(
    inputs.map((x) =>
      limit(async () => {
        // callWithRetry로 감싼 Claude 호출
        return await doClaudeRequest(x);
      })
    )
  );
}

운영에서는 이 “동시성 5” 같은 숫자를 고정값으로 박지 말고, 다음 신호에 따라 조절하는 게 좋습니다.

  • 529 비율이 증가하면 동시성 감소
  • 평균 지연이 낮고 529가 거의 없으면 동시성 증가

서킷 브레이커: 실패가 일정 수준을 넘으면 잠깐 멈추기

과부하가 심할 때는 “계속 두드리기”보다 “잠깐 멈추고 회복을 기다리기”가 전체 성공률을 올립니다.

서킷 브레이커의 기본 규칙은 간단합니다.

  • 최근 N건 중 실패율이 임계치 이상이면 Open
  • Open 상태에서는 즉시 실패(또는 빠른 폴백)
  • 일정 시간이 지나면 Half-Open으로 소량만 통과
  • 통과 성공률이 회복되면 Close

Node.js에서는 opossum 같은 구현체를 쓰거나, 간단한 인메모리 상태로도 시작할 수 있습니다.

type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";

class SimpleCircuitBreaker {
  private state: CircuitState = "CLOSED";
  private openedAt = 0;
  private failCount = 0;
  private successCount = 0;

  constructor(
    private readonly openAfterFails: number,
    private readonly coolDownMs: number
  ) {}

  canPass() {
    if (this.state === "CLOSED") return true;

    if (this.state === "OPEN") {
      if (Date.now() - this.openedAt >= this.coolDownMs) {
        this.state = "HALF_OPEN";
        this.failCount = 0;
        this.successCount = 0;
        return true;
      }
      return false;
    }

    // HALF_OPEN: 1~소량만 허용하는 정책이 이상적이지만, 여기선 단순화
    return true;
  }

  onSuccess() {
    if (this.state === "HALF_OPEN") {
      this.successCount++;
      if (this.successCount >= 3) this.state = "CLOSED";
    }
  }

  onFailure() {
    this.failCount++;
    if (this.state === "HALF_OPEN" || this.failCount >= this.openAfterFails) {
      this.state = "OPEN";
      this.openedAt = Date.now();
    }
  }
}

이걸 Claude 호출 앞단에 붙이면, 과부하가 길게 이어질 때도 시스템 전체가 “천천히” 실패하고, 다운스트림(큐, DB, 웹 서버)이 같이 무너지는 걸 막을 수 있습니다.

타임아웃과 재시도 예산: 사용자 지연을 통제하기

재시도를 넣으면 사용자 관점 지연이 늘어납니다. 그래서 다음 두 개를 분리해야 합니다.

  • 요청 1회의 타임아웃: 예를 들어 60초
  • 재시도 전체 예산: 예를 들어 2분

이렇게 하면 “한 번이 너무 오래 걸려서” 재시도 기회 자체가 사라지는 문제를 줄이고, 동시에 “재시도 때문에 무한 대기”하는 UX도 막습니다.

또한 스트리밍 응답을 쓰는 경우, 첫 토큰이 늦어지는 상황과 네트워크 끊김을 구분해야 합니다. 첫 토큰 타임아웃(예: 10초) 같은 별도 기준을 두면 운영이 쉬워집니다.

멱등성: 재시도로 인한 중복 실행을 줄이는 방법

LLM 호출은 전통적인 결제 API처럼 “완전한 멱등성”을 보장하기 어렵지만, 그래도 다음을 적용하면 피해를 크게 줄일 수 있습니다.

  • 동일 입력에 대해 requestId를 부여하고, 결과를 캐시
  • 백엔드에서 “같은 requestId면 같은 결과를 반환”하도록 저장
  • 작업 큐를 쓴다면 deduplication key를 사용

특히 “LLM 결과를 기반으로 결제/주문/권한 변경” 같은 부작용이 있는 작업을 한다면, LLM 호출 실패보다 더 무서운 게 중복 실행입니다. 이런 종류의 중복 방지는 Outbox 패턴과도 연결됩니다. 결제 같은 영역을 다룬다면 MSA 사가 실패로 중복결제 터질 때 Outbox로 막기 관점으로 같이 설계를 점검하는 걸 권합니다.

관측(Observability): 529/503을 “수치”로 다루기

재시도 로직이 제대로 동작하는지 확인하려면 지표가 필요합니다. 최소한 아래는 찍어야 합니다.

  • 상태 코드별 비율: status=200/429/503/529 카운트
  • 재시도 횟수 분포: attempt 0..N
  • 최종 성공률: “재시도 포함 성공”과 “1회차 성공”을 분리
  • 지연: P50/P95/P99, 그리고 “첫 토큰까지 시간”(스트리밍일 때)
  • 큐 길이 및 동시성(세마포어) 사용률

추가로, 쿠버네티스에서 워커가 과부하로 죽었다 살아나면 재시도 폭풍이 더 커질 수 있습니다. 파드가 재시작 루프를 타는 상황이라면 애플리케이션 레벨 재시도 이전에 인프라 상태부터 확인해야 합니다. Kubernetes CrashLoopBackOff 10가지 원인과 15분 진단 같은 체크리스트가 도움이 됩니다.

운영 체크리스트

마지막으로 529/503 대응을 배포 전에 점검할 수 있는 체크리스트입니다.

  • 재시도 대상이 503/529/429 및 네트워크 오류로 제한되어 있는가
  • Retry-After를 우선 적용하는가
  • 지수 백오프 + 지터가 적용되어 재시도 타이밍이 분산되는가
  • 최대 재시도 횟수와 전체 예산이 존재하는가
  • 동시성 제한이 있는가(무제한 Promise.all 금지)
  • 서킷 브레이커 또는 폴백 경로가 있는가
  • 멱등성 키 또는 결과 캐시로 중복 실행을 줄였는가
  • 상태 코드, 재시도 횟수, 지연 지표가 대시보드에 있는가

정리

Claude 3 API의 529/503은 “클라이언트가 더 똑똑해져야 하는 신호”입니다. 핵심은 재시도를 하되, 서버가 회복할 시간을 주고(백오프), 재시도 트래픽을 분산시키고(지터), 애초에 폭주를 만들지 않는 것(동시성 제한), 그리고 장기 장애에서 빠르게 포기하는 것(서킷 브레이커)입니다.

이 4가지를 갖추면 과부하 시간대에도 성공률이 유의미하게 개선되고, 비용과 지연이 함께 안정화됩니다.