Published on

Claude API 529 과부하·429 제한 재시도 설계

Authors

운영 환경에서 Claude API를 붙이다 보면 간헐적으로 529(overloaded)나 429(rate limit) 같은 응답을 만나게 됩니다. 문제는 여기서 “그냥 몇 번 더 호출하면 되겠지”라는 단순 재시도가 오히려 장애를 키운다는 점입니다. 동시 요청이 계속 쌓이고, 재시도 폭풍이 발생하며, 결국 사용자 지연과 비용이 함께 상승합니다.

이 글은 529429를 구분해 해석하고, 응답 헤더를 기반으로 한 재시도 정책, 지수 백오프와 지터, 큐잉과 동시성 제어, 서킷 브레이커, 멱등성 키까지 포함한 재시도 설계를 한 번에 정리합니다.

비슷한 관점으로 “외부 LLM API에서 게이트웨이/일시 장애를 어떻게 다룰 것인가”는 아래 글도 함께 참고하면 좋습니다.

1) 529와 429: 의미가 비슷해 보여도 대응은 다르다

1.1 429 Rate Limit: “당신(또는 당신의 키)이 너무 빠르다”

  • 보통 계정/키 단위의 초당 요청 수(RPS), 분당 토큰, 동시 요청 수 등의 한도를 초과했을 때 발생합니다.
  • 재시도는 가능하지만, “현재 속도를 낮추는” 방향(클라이언트 측 스로틀링)이 핵심입니다.
  • 서버가 Retry-After 같은 힌트를 주면 그 값을 최우선으로 따르는 것이 가장 안전합니다.

1.2 529 Overloaded: “서버(또는 특정 구간)가 바쁘다”

  • 플랫폼이 일시적으로 과부하 상태라 요청을 처리하지 못하는 경우입니다.
  • 이때 공격적으로 재시도하면 서버 부담을 더 키워 회복 시간을 늘릴 수 있습니다.
  • 더 큰 지터, 더 보수적인 백오프, 그리고 빠른 폴백(예: 캐시/간소화 응답)이 중요합니다.

핵심 요약은 다음과 같습니다.

  • 429: 클라이언트가 속도를 줄여야 한다(스로틀링/큐/동시성 제한)
  • 529: 서버가 회복할 시간을 줘야 한다(보수적 백오프/폴백/서킷 브레이커)

2) 재시도 설계의 목표: 성공률이 아니라 “안정성”

재시도 로직을 설계할 때 목표를 “성공률 최대화”로만 잡으면, 트래픽이 몰릴 때 재시도가 트래픽을 증폭시키는 구조가 됩니다. 운영에서의 목표는 대체로 아래 순서가 현실적입니다.

  1. 시스템 전체 안정성(폭주 방지)
  2. 사용자 체감 지연 상한(최대 대기 시간)
  3. 성공률(가능하면)
  4. 비용 최적화(토큰/요청)

따라서 재시도에는 반드시 다음 요소들이 함께 들어가야 합니다.

  • 최대 재시도 횟수
  • 최대 총 대기 시간(전체 데드라인)
  • 지수 백오프 + 지터
  • Retry-After 우선 적용
  • 동시성 제한(세마포어) 또는 큐
  • 서킷 브레이커(연속 실패 시 빠른 실패)
  • 멱등성/중복 방지(특히 비동기 작업)

3) Retry-After가 있으면 무조건 우선한다

많은 API는 429에 대해 Retry-After(초 단위 또는 HTTP-date)를 제공합니다. 제공된다면 클라이언트의 계산보다 서버 힌트가 더 정확합니다.

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

  1. Retry-After가 있으면 그 값을 사용
  2. 없으면 상태 코드별 기본 백오프 정책 사용
  3. 그래도 불확실하면 보수적으로(특히 529)

주의할 점은 Retry-After가 “초”인지 “날짜 문자열”인지 모두 처리해야 한다는 것입니다.

4) 백오프는 지수 + 지터가 정석이다

4.1 왜 지터가 필수인가

지수 백오프만 쓰면 여러 인스턴스가 같은 타이밍에 재시도하면서 동기화된 파도(Thundering Herd)가 생깁니다. 지터는 이를 흩뜨려 서버 회복을 돕고, 클라이언트도 서로 덜 경쟁하게 합니다.

4.2 추천 전략: Full Jitter

AWS 아키텍처 가이드에서도 자주 등장하는 방식으로, 계산된 백오프 상한 내에서 랜덤 값을 뽑습니다.

  • sleep = random(0, min(cap, base * 2^attempt))

529에는 cap을 더 크게, attempt 상한도 더 보수적으로 잡는 편이 안전합니다.

5) Node.js 예제: 429/529 재시도 유틸리티

아래 예시는 Next.js/Node 환경에서 재사용 가능한 형태로 작성한 재시도 래퍼입니다.

  • Retry-After 파싱
  • 상태 코드별 정책
  • 전체 데드라인
  • Full Jitter
type RetryOptions = {
  maxAttempts: number;          // 총 시도 횟수(최초 포함)
  baseDelayMs: number;          // 기본 지연
  capDelayMs: number;           // 지연 상한
  totalTimeoutMs: number;       // 전체 데드라인
};

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

  // 1) seconds
  const asSeconds = Number(value);
  if (Number.isFinite(asSeconds) && asSeconds >= 0) {
    return Math.floor(asSeconds * 1000);
  }

  // 2) HTTP-date
  const asDate = Date.parse(value);
  if (!Number.isNaN(asDate)) {
    const delta = asDate - Date.now();
    return delta > 0 ? delta : 0;
  }

  return null;
}

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

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

export async function withRetry<T>(
  fn: () => Promise<T>,
  shouldRetry: (err: any) => { retry: boolean; retryAfterMs?: number; kind?: string },
  opts: RetryOptions
): Promise<T> {
  const startedAt = Date.now();

  let lastErr: any;
  for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
    const elapsed = Date.now() - startedAt;
    if (elapsed > opts.totalTimeoutMs) {
      throw new Error(`retry deadline exceeded after ${elapsed}ms`);
    }

    try {
      return await fn();
    } catch (err: any) {
      lastErr = err;

      const decision = shouldRetry(err);
      if (!decision.retry || attempt === opts.maxAttempts - 1) {
        throw err;
      }

      const remain = opts.totalTimeoutMs - (Date.now() - startedAt);
      const retryAfter = decision.retryAfterMs;

      // Retry-After 우선, 없으면 지수 백오프 + 지터
      const computed = fullJitterDelayMs(opts.baseDelayMs, opts.capDelayMs, attempt);
      const delay = Math.min(remain, Math.max(0, retryAfter ?? computed));

      await sleep(delay);
    }
  }

  throw lastErr;
}

shouldRetry는 Claude SDK/HTTP 클라이언트에 맞춰 상태 코드와 헤더를 꺼내도록 구현하면 됩니다.

예시(의사 코드):

function shouldRetryClaude(err: any) {
  const status = err?.status ?? err?.response?.status;
  const retryAfterHeader = err?.response?.headers?.get?.('retry-after')
    ?? err?.response?.headers?.['retry-after']
    ?? null;

  const retryAfterMs = parseRetryAfterMs(retryAfterHeader);

  if (status === 429) {
    return { retry: true, retryAfterMs, kind: 'rate-limit' };
  }

  if (status === 529) {
    // 과부하는 더 보수적으로 재시도하되, 너무 오래 붙잡지 않게 데드라인 필수
    return { retry: true, retryAfterMs, kind: 'overloaded' };
  }

  // 네트워크 타임아웃/일시 오류도 여기에 포함 가능
  return { retry: false };
}

운영 팁:

  • 429capDelayMs를 상대적으로 작게 두고, 대신 “동시성 제한”을 강하게 거는 편이 낫습니다.
  • 529capDelayMs를 키우고, 재시도 횟수는 줄이되 폴백을 준비하는 편이 낫습니다.

6) 동시성 제한이 없으면 재시도는 독이 된다

재시도는 결국 “추가 요청”입니다. 따라서 동시성 제한(세마포어) 또는 큐가 없으면, 트래픽 피크에서 재시도가 폭발적으로 늘어납니다.

6.1 간단한 세마포어로 동시 호출 제한

class Semaphore {
  private available: number;
  private queue: Array<() => void> = [];

  constructor(count: number) {
    this.available = count;
  }

  async acquire(): Promise<() => void> {
    if (this.available > 0) {
      this.available--;
      return () => this.release();
    }

    await new Promise<void>((resolve) => this.queue.push(resolve));
    this.available--;
    return () => this.release();
  }

  private release() {
    this.available++;
    const next = this.queue.shift();
    if (next) next();
  }
}

const claudeSemaphore = new Semaphore(8); // 동시 요청 8개로 제한

async function callClaudeSafely<T>(fn: () => Promise<T>): Promise<T> {
  const release = await claudeSemaphore.acquire();
  try {
    return await fn();
  } finally {
    release();
  }
}

이 세마포어를 withRetry와 함께 쓰면 “재시도도 동시성 제한을 공유”하게 되어 폭주를 크게 줄일 수 있습니다.

7) 서킷 브레이커: 연속 529면 빨리 포기하고 회복을 기다린다

529가 연속으로 발생하는 상황은 “지금은 호출해봤자 실패할 확률이 매우 높다”는 신호입니다. 이때는 재시도를 계속하는 대신, 일정 시간 빠르게 실패시키고(또는 폴백) 회복 시간을 주는 것이 전체적으로 유리합니다.

간단한 정책 예:

  • 최근 30초 동안 529 비율이 50%를 넘으면 서킷 오픈
  • 오픈 상태에서는 10초 동안 즉시 실패(또는 캐시 응답)
  • 10초 후 half-open에서 소수 요청만 통과시켜 상태 확인

서킷 브레이커를 넣으면 사용자 경험이 “무한 대기”에서 “빠른 실패 + 대체 경로”로 바뀌고, 시스템도 안정됩니다.

8) 멱등성: 재시도로 인해 중복 작업이 생기지 않게

LLM 호출은 겉으로 보기엔 읽기처럼 보이지만, 실제로는 다음과 같은 부작용이 생길 수 있습니다.

  • 비동기 작업 생성(예: 작업 큐에 메시지 발행)
  • DB에 결과 저장
  • 외부 시스템에 웹훅 발송

재시도가 이런 부작용을 중복 실행하면 장애가 더 커집니다. 따라서 다음 중 하나는 반드시 고려하세요.

  • 요청 단위 idempotency key를 만들어 서버/DB에서 중복 처리 방지
  • “생성”과 “조회”를 분리(먼저 job을 만들고, 이후 job 결과를 폴링)
  • 결과 캐시(같은 입력이면 같은 출력이 허용되는 경우)

9) 관측 가능성: 재시도는 반드시 메트릭으로 관리한다

재시도는 숨기면 안 됩니다. 다음 지표를 기본으로 권장합니다.

  • 상태 코드별 카운트: 429, 529, 기타 5xx
  • 재시도 횟수 분포: attempt 히스토그램
  • 최종 실패율 및 원인
  • 대기 시간(백오프) 총합
  • 동시성 대기 큐 길이(세마포어 대기)

로그에는 최소한 아래 필드를 남기면 트러블슈팅이 빨라집니다.

  • request id(내부)
  • provider status code
  • attempt 번호
  • 적용된 delay ms
  • Retry-After 원문 값
  • 총 경과 시간

장애 분석 관점에서 “원인은 다른데 증상은 재시도 폭증”인 경우가 많습니다. 예를 들어 인프라 레벨에서 프로세스가 재시작 루프를 돌면 외부 호출도 불안정해지고, 결국 529/429가 더 자주 보일 수 있습니다. 이런 케이스는 아래 글처럼 시스템 레벨 진단이 필요합니다.

10) 권장 프리셋(현실적인 기본값)

서비스 성격마다 다르지만, 출발점으로 쓸 만한 프리셋을 제안합니다.

10.1 사용자 동기 요청(채팅 UI 등)

  • 전체 데드라인: 8초 ~ 15초
  • 429: 최대 2~3회 재시도, baseDelayMs 200, capDelayMs 2000
  • 529: 최대 1~2회 재시도, baseDelayMs 500, capDelayMs 5000
  • 동시성 제한: 강하게(예: 인스턴스당 4~16)
  • 폴백: “잠시 후 다시 시도” + 이전 대화 요약 캐시

10.2 백그라운드 비동기 작업(요약 배치 등)

  • 전체 데드라인: 작업 SLA에 맞게(수 분까지 가능)
  • 429: 큐 기반 스로틀링 + 느린 재시도
  • 529: 서킷 브레이커 적극 사용
  • 멱등성 키: 필수

11) 체크리스트: 운영에 넣기 전 마지막 점검

  • Retry-After를 파싱해 최우선 적용하는가
  • 지수 백오프에 지터가 포함되어 있는가
  • 최대 재시도 횟수뿐 아니라 전체 데드라인이 있는가
  • 재시도가 동시성 제한/큐를 공유하는가
  • 529에 대해 서킷 브레이커 또는 폴백이 있는가
  • 멱등성(중복 처리 방지)이 보장되는가
  • 재시도 관련 메트릭/로그가 있는가

마무리

429529는 “재시도하면 언젠가 되겠지”로 접근하면 곧바로 비용과 지연, 그리고 장애 확산으로 돌아옵니다. 429는 클라이언트 속도 제어 문제로 보고 스로틀링/큐/동시성 제한을 우선 적용하고, 529는 서버 회복을 돕는 보수적 백오프와 서킷 브레이커, 폴백을 중심으로 설계하는 것이 핵심입니다.

재시도는 기능이 아니라 아키텍처입니다. 위의 구성 요소들을 한 덩어리로 넣어두면, Claude API뿐 아니라 어떤 외부 API를 붙이더라도 장애 내성이 확연히 좋아집니다.