Published on

Anthropic Claude 429 레이트리밋 재시도 설계법

Authors

서버에서 LLM 호출을 운영하다 보면 가장 흔하게 마주치는 장애가 429 Too Many Requests입니다. 특히 트래픽이 스파이크치거나 배치 작업이 겹치는 순간, “재시도 몇 번 하면 되겠지”라는 접근은 오히려 더 큰 폭주를 만들고, 지연(latency)과 비용(cost)을 동시에 악화시킵니다.

이 글에서는 Anthropic Claude에서 429를 만났을 때 재시도 로직을 어떻게 ‘제품 수준’으로 설계할지 다룹니다. 핵심은 다음 3가지입니다.

  • 재시도는 마지막 수단: 먼저 동시성/큐/버짓(예산)으로 ‘들어오는 양’을 통제
  • 백오프는 지터(jitter) 포함: 동시 재시도로 인한 thundering herd 방지
  • 관측 가능성: 429 자체보다 “왜 429가 났는지”를 추적 가능하게

> 참고: LLM 호출은 네트워크/인증 이슈도 자주 섞입니다. 429 대응을 하다가 SSL 검증 실패 같은 문제를 함께 만나면 원인 분리가 어려워집니다. 관련해서는 Python SSL CERTIFICATE_VERIFY_FAILED 10분 해결도 같이 점검해두면 운영이 편해집니다.

429는 ‘에러’가 아니라 ‘흐름 제어 신호’다

429는 “요청이 잘못됐다”가 아니라 “현재 이 속도로는 처리 못 한다”는 신호입니다. 따라서 클라이언트는 다음을 해야 합니다.

  1. 즉시 실패 처리할 요청기다렸다가 재시도할 요청을 구분
  2. 재시도를 하더라도 동시성/속도를 낮춰 시스템이 회복할 시간을 제공
  3. 재시도 중에도 사용자 경험을 보호(타임아웃, 부분 결과, 비동기 전환)

실무에서 429는 보통 아래 원인으로 발생합니다.

  • 짧은 시간에 요청이 몰림(버스트)
  • 워커 수/동시성이 과도함(예: K8s HPA가 급격히 scale-out)
  • 동일 사용자/테넌트가 과도하게 사용
  • 긴 응답(토큰 사용량↑)으로 처리 시간이 늘어 실질 처리량이 감소

Kubernetes에서 워커가 늘어나는 상황은 429를 더 쉽게 유발합니다. HPA가 빠르게 확장되면 외부 API로의 동시 호출이 폭증하기 때문입니다. HPA/종료 윈도우 등 운영 관점은 Kubernetes HPA가 0으로 안 줄 때 - PDB·윈도우·종료처럼 “스케일 정책이 실제 트래픽을 어떻게 증폭시키는지”를 함께 보는 것이 좋습니다.

재시도 설계의 목표: 성공률이 아니라 ‘안정성’

많은 팀이 재시도 목표를 “성공률 99.9%”로 잡습니다. 하지만 429에서는 성공률만 올리려는 재시도가 다음 문제를 만듭니다.

  • 요청 폭주: 실패한 요청이 다시 몰리며 더 큰 429 유발
  • 꼬리 지연 증가: 일부 요청이 수십 초~수분까지 늘어남
  • 비용 증가: 중복 요청, 타임아웃 후 재시도 등으로 토큰/요금 낭비

따라서 목표를 이렇게 바꾸는 게 좋습니다.

  • 시스템이 과부하일 때 자동으로 스스로 속도를 줄이는가
  • 사용자에게는 예측 가능한 응답 시간(상한)이 있는가
  • 테넌트/사용자 간 공정성(fairness)이 있는가

429 대응의 기본 구성요소 6가지

1) 요청 단위의 Idempotency(멱등성)

재시도는 “같은 요청을 다시 보내는 것”입니다. 서버 측에서 멱등 키를 지원하면 가장 좋고, 클라이언트에서도 최소한 중복 실행을 감지해야 합니다.

  • 내부적으로 request_id를 생성해 로그/트레이싱 키로 사용
  • 동일 입력/동일 목적의 요청이 중복 실행되면 캐시/결과 재사용 고려

2) 타임아웃 예산(Deadline Budget)

재시도는 무한정 하면 안 됩니다. “총 예산”을 정해두고 그 안에서만 재시도합니다.

  • 전체 deadline: 예) 8초
  • 1차 시도: 2초
  • 재시도 포함 총합이 deadline을 넘으면 즉시 실패/비동기로 전환

3) 백오프(Exponential Backoff) + 지터(Jitter)

지터 없는 백오프는 동시 요청이 같은 리듬으로 재시도하게 만들어 동조화된 폭주를 유발합니다.

권장 패턴:

  • base delay: 200~500ms
  • factor: 2.0
  • max delay: 10~30s
  • jitter: full jitter 또는 decorrelated jitter

4) Retry-After/레이트리밋 힌트 존중

응답 헤더/바디에 “언제 다시 시도하라”는 힌트가 있다면 그것이 최우선입니다. 없다면 클라이언트 정책으로 결정합니다.

5) 동시성 제한(Concurrency Limiter)

재시도는 “요청 수를 늘리는 행위”입니다. 그래서 재시도 설계에서 가장 중요한 것은 사실 동시성 제한입니다.

  • 프로세스/파드 단위 세마포어
  • 테넌트별 동시성 제한
  • 엔드포인트(모델/작업 유형)별 동시성 제한

6) 서킷 브레이커(Circuit Breaker)

429가 일정 비율 이상이면 “지금은 보내봤자 또 429”일 가능성이 큽니다. 일정 시간 호출 자체를 멈추고(오픈), 천천히 재개(하프오픈)합니다.

Python 예제: 429 재시도 + 지터 + 동시성 제한

아래 예시는 (1) 동시성 제한, (2) deadline 예산, (3) 429/5xx만 재시도, (4) full jitter 백오프를 포함합니다.

> SDK 버전/메서드명은 환경에 따라 다를 수 있으니, 핵심은 “정책”을 코드로 고정하는 것입니다.

import os
import time
import random
import threading
from dataclasses import dataclass

# 예: anthropic SDK를 쓴다고 가정
# pip install anthropic
from anthropic import Anthropic

client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

# 프로세스 내 동시성 제한 (파드/프로세스 단위)
CLAUDE_MAX_CONCURRENCY = int(os.getenv("CLAUDE_MAX_CONCURRENCY", "8"))
_sema = threading.Semaphore(CLAUDE_MAX_CONCURRENCY)

@dataclass
class RetryPolicy:
    max_attempts: int = 6
    base_delay_s: float = 0.3
    max_delay_s: float = 12.0
    factor: float = 2.0
    total_deadline_s: float = 8.0

class RateLimitError(Exception):
    pass

class RetryBudgetExceeded(Exception):
    pass


def _full_jitter_delay(cap: float) -> float:
    # AWS architecture blog에서 자주 권장하는 full jitter
    return random.uniform(0, cap)


def call_claude_with_retry(prompt: str, policy: RetryPolicy = RetryPolicy()):
    start = time.monotonic()
    attempt = 0
    cap = policy.base_delay_s

    while True:
        attempt += 1
        elapsed = time.monotonic() - start
        if elapsed > policy.total_deadline_s:
            raise RetryBudgetExceeded(f"deadline exceeded after {elapsed:.2f}s")

        with _sema:
            try:
                # 예시: Messages API 형태(개념)
                resp = client.messages.create(
                    model="claude-3-5-sonnet-latest",
                    max_tokens=512,
                    temperature=0.2,
                    messages=[{"role": "user", "content": prompt}],
                )
                return resp

            except Exception as e:
                # 실제로는 SDK가 제공하는 예외 타입(예: RateLimitError 등)로 분기 권장
                msg = str(e).lower()
                is_429 = "429" in msg or "rate limit" in msg
                is_retryable_5xx = any(code in msg for code in ["500", "502", "503", "504"])

                if not (is_429 or is_retryable_5xx):
                    raise

                if attempt >= policy.max_attempts:
                    raise RateLimitError(f"max attempts reached: {attempt}") from e

        # 세마포어 밖에서 대기(중요: 대기 중에 동시성 슬롯을 점유하지 않음)
        cap = min(policy.max_delay_s, cap * policy.factor)
        sleep_s = _full_jitter_delay(cap)

        # deadline을 넘기지 않도록 마지막에 클램프
        remaining = policy.total_deadline_s - (time.monotonic() - start)
        if remaining <= 0:
            raise RetryBudgetExceeded("no remaining budget")
        time.sleep(min(sleep_s, remaining))

포인트 해설

  • 대기는 세마포어 밖에서: 동시성 슬롯을 쥔 채로 잠들면 전체 처리량이 급락합니다.
  • deadline 기반: “몇 번 재시도”보다 “총 시간 예산”이 사용자 경험에 더 직결됩니다.
  • 재시도 대상 최소화: 429/5xx만 재시도하고, 4xx(권한/요청 오류)는 즉시 실패.

Node.js/TypeScript 예제: 큐 기반(버스트 흡수) + 재시도

서버가 동시에 많은 요청을 받는다면, 애플리케이션 레벨에서 작업 큐(Queue)로 버스트를 흡수하는 게 효과적입니다. 아래는 간단한 인메모리 큐 형태의 예시입니다(프로덕션은 Redis/BullMQ/SQS 등을 권장).

type Task<T> = () => Promise<T>;

class ConcurrencyQueue {
  private running = 0;
  private queue: Array<() => void> = [];

  constructor(private readonly concurrency: number) {}

  async run<T>(task: Task<T>): Promise<T> {
    if (this.running >= this.concurrency) {
      await new Promise<void>((resolve) => this.queue.push(resolve));
    }

    this.running++;
    try {
      return await task();
    } finally {
      this.running--;
      const next = this.queue.shift();
      if (next) next();
    }
  }
}

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

function fullJitterDelay(capMs: number) {
  return Math.floor(Math.random() * capMs);
}

async function withRetry<T>(fn: () => Promise<T>) {
  const maxAttempts = 6;
  const deadlineMs = 8000;
  const baseMs = 300;
  const maxMs = 12000;
  let capMs = baseMs;

  const start = Date.now();

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      const msg = String(err?.message ?? err).toLowerCase();
      const is429 = msg.includes("429") || msg.includes("rate limit");
      const is5xx = ["500", "502", "503", "504"].some((c) => msg.includes(c));

      if (!(is429 || is5xx)) throw err;
      if (attempt === maxAttempts) throw err;

      capMs = Math.min(maxMs, capMs * 2);
      const delay = fullJitterDelay(capMs);
      const remaining = deadlineMs - (Date.now() - start);
      if (remaining <= 0) throw err;
      await sleep(Math.min(delay, remaining));
    }
  }

  throw new Error("unreachable");
}

// 사용 예: Claude 호출을 큐로 감싸기
const q = new ConcurrencyQueue(Number(process.env.CLAUDE_MAX_CONCURRENCY ?? 8));

async function callClaude(prompt: string) {
  return q.run(() =>
    withRetry(async () => {
      // 여기에 실제 Claude SDK 호출을 넣는다.
      // 예: await anthropic.messages.create(...)
      return { ok: true, prompt };
    })
  );
}

이 패턴의 장점은 단순합니다.

  • 큐가 버스트를 흡수해서 외부 API에 전달되는 순간 동시성이 제한됨
  • 재시도는 하되, 전체 트래픽을 더 키우지 않음

재시도보다 먼저 해야 할 것: 토큰/요청량 자체를 줄이기

429를 ‘재시도’로만 풀면 근본 원인이 남습니다. 아래는 429를 줄이는 실전 체크리스트입니다.

프롬프트/출력 토큰 최적화

  • 불필요한 시스템 프롬프트 반복 제거(템플릿화)
  • 출력 max_tokens를 보수적으로 설정
  • 대화 히스토리 요약/압축

RAG를 쓰는 경우, “무조건 많이 넣기”는 비용과 레이트리밋을 동시에 악화시킵니다. 구조화 출력과 검증을 통해 재질문/재시도를 줄이는 전략은 RAG 환각을 줄이는 JSON Schema 강제 출력법처럼 출력 품질을 올려 재호출을 줄이는 방향이 장기적으로 효과가 큽니다.

요청 병합/중복 제거

  • 동일 사용자 행동으로 여러 번 호출되는지(프론트 더블 클릭, 재전송)
  • 동일 문서 요약을 여러 워커가 동시에 수행하는지(락/캐시 필요)

우선순위 큐(Priority Queue)

모든 요청이 똑같이 중요하지 않습니다.

  • 실시간 사용자 요청: 높은 우선순위, 짧은 deadline
  • 배치/오프라인 작업: 낮은 우선순위, 긴 deadline

429 상황에서는 배치를 자동으로 늦추는 것만으로도 사용자 경험을 지킬 수 있습니다.

운영 관측(Observability): 429를 ‘수치’로 관리하기

재시도 설계는 지표 없이는 최적화할 수 없습니다. 최소한 아래를 수집하세요.

  • rate_limit.count (429 발생 수)
  • rate_limit.retry_count (재시도 횟수)
  • request.latency_p50/p95/p99
  • queue.depth (큐 적재량)
  • token.in/out (입출력 토큰)
  • 테넌트/사용자별 상위 N개 사용량

또한 로그에는 다음 필드를 강제하는 것이 좋습니다.

  • request_id, user_id/tenant_id
  • attempt, backoff_ms, deadline_remaining_ms
  • 모델명, max_tokens, 입력 크기(대략)

실전 권장 아키텍처: “즉시 재시도”가 아니라 “조절된 재처리”

정리하면, Claude 429 대응의 이상적인 구조는 다음 흐름입니다.

  1. API Gateway/Backend에서 사용자별/테넌트별 쿼터 적용
  2. 앱 내부에서 동시성 제한 + 큐로 외부 호출량을 평탄화
  3. 429 발생 시 짧은 백오프 + 지터로 제한된 횟수만 재시도
  4. 그래도 실패하면
    • 사용자 요청: 빠르게 실패/대체 응답(“잠시 후 다시 시도”)
    • 배치 작업: 큐에 재적재(지연 실행)
  5. 지표 기반으로 동시성/토큰/프롬프트를 조정

이렇게 하면 “429가 떠도 서비스가 무너지지 않는” 방향으로 수렴합니다. 재시도는 그중 일부일 뿐이고, 핵심은 트래픽을 통제하고, 재시도를 동조화시키지 않고, 예산 내에서만 수행하는 것입니다.

체크리스트(바로 적용용)

  • 429/5xx만 재시도하고 나머지 4xx는 즉시 실패
  • 지터 포함 백오프 적용(full jitter 권장)
  • 총 deadline 예산 기반으로 재시도 제한
  • 세마포어/큐로 동시성 제한(파드 수가 늘어도 상한 유지)
  • 사용자/테넌트별 공정성(별도 큐 또는 토큰 버킷)
  • 429 비율이 높으면 서킷 브레이커로 호출 자체를 감속
  • 토큰/프롬프트 최적화로 호출량 자체를 줄이기

이 체크리스트를 기준으로 현재 시스템의 429를 “재시도로 덮는” 단계에서 “흐름 제어로 안정화”하는 단계로 끌어올릴 수 있습니다.