Published on

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

Authors
Binance registration banner

서버에서 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를 “재시도로 덮는” 단계에서 “흐름 제어로 안정화”하는 단계로 끌어올릴 수 있습니다.