포스트

429가 “가끔”이 아니라 “구조적으로” 터지는 시대: 2026년 7월 LLM API Rate Limit 대응 Retry/Backoff 패턴 심층 분석

429가 “가끔”이 아니라 “구조적으로” 터지는 시대: 2026년 7월 LLM API Rate Limit 대응 Retry/Backoff 패턴 심층 분석

들어가며

LLM API를 프로덕션에서 돌리다 보면 429(Too Many Requests / RESOURCE_EXHAUSTED)은 “내가 분당 한도를 넘겼다” 수준의 단순 문제가 아닙니다. 짧은 버스트(burst)로 인한 quantization, 토큰 기반 제한(TPM/ITPM/OTPM)과 요청 기반 제한(RPM)의 동시 작동, 조직/워크스페이스 단위 bucket, 스펜드(spend) 기반 제한까지 겹치면서 “대시보드상 여유가 있어도 429가 나는” 상황이 흔합니다. OpenAI는 한도가 더 짧은 구간으로 쪼개져 집행될 수 있음을 명시하고, 해결책으로 exponential backoff를 권장합니다. (help.openai.com) Anthropic은 token bucket 기반임을 문서에 적고, 429에 retry-after가 포함될 수 있음을 명시합니다. (platform.claude.com) Google Gemini도 429/503에 대해 exponential backoff(+jitter)를 권장하고, SDK에 기본 retry가 들어있다고 안내합니다. (ai.google.dev)

언제 쓰면 좋은가

  • 멀티 워커/멀티 스레드로 LLM 호출이 몰리는 서비스(에이전트, 배치, 검색+생성 파이프라인)
  • 429/503이 “간헐적”이고, 기다리면 대부분 회복되는 트래픽 패턴
  • 비용/지연을 약간 늘려서라도 성공률을 끌어올려야 하는 워크로드

언제 쓰면 안 되는가(혹은 retry를 매우 제한해야 하는가)

  • 사용자 인터랙션이 강한 요청(예: 1~2초 SLA)에서 무제한 retry로 tail latency가 폭발하는 경우
  • 400/401/403처럼 영구 실패(permanent failure) 인데 무의미하게 재시도하는 경우(키/권한/파라미터 문제)
  • “이미 시스템이 과부하”인데 동시 재시도 폭풍으로 더 악화시키는 구조(특히 jitter 없는 backoff)

🔧 핵심 개념

1) Rate limit은 “한 가지 숫자”가 아니다

2026년 기준 주요 LLM API는 보통 요청 수(RPM)토큰(입력/출력 TPM) 을 동시에 제한합니다. Anthropic은 이를 명시적으로 RPM/ITPM/OTPM으로 설명하고, 내부적으로 token bucket 알고리즘으로 집행한다고 밝힙니다. (platform.claude.com) OpenAI도 분당 한도라도 더 짧은 시간 단위로 “quantized enforcement” 될 수 있어 순간 버스트에 취약함을 적고 있습니다. (help.openai.com)

즉, “분당 60RPM”이어도 실제로는 “초당 1RPS”처럼 동작할 수 있고, 여기에 토큰 제한이 겹치면 요청 수는 괜찮은데 토큰이 먼저 바닥나서 429가 나기도 합니다.

2) 429의 핵심은 “언제 다시 보내면 되나”다: Retry-After

가장 좋은 클라이언트는 429를 받았을 때 (1) 서버가 주는 retry-after를 최우선으로 존중하고, 없으면 (2) 지수 backoff + jitter로 “군집 재시도(thundering herd)”를 피합니다. Anthropic 문서에는 429에 retry-after가 포함될 수 있고 “그 전에 재시도하면 실패한다”고 명확히 적혀 있습니다. (platform.claude.com)
Gemini도 429/503에 대해 exponential backoff(+jitter)를 권장합니다. (ai.google.dev)

여기서 중요한 차이:

  • Retry-After 우선 패턴: “정확히 언제 capacity가 돌아오는지” 서버가 힌트를 주는 경우 최적
  • Exponential backoff 패턴: 서버 힌트가 없거나, 분산 환경에서 동시 재시도를 완화하는 일반 해법
  • Jitter: backoff의 필수 구성요소(특히 다중 워커일수록). 같은 수식으로 재시도하면 모두가 같은 타이밍에 다시 몰립니다.

3) “재시도”만으로는 부족하다: 클라이언트 측 페이싱(pacing) + 동시성 제어

실전에서 안정화는 보통 2단입니다.

1) 사전 페이싱: 전역 token bucket / leaky bucket으로 호출을 고르게 분산
2) 사후 복구: 429/503 발생 시 retry-after 기반 sleep + exp backoff + jitter

OpenAI가 말하는 “짧은 버스트가 한도 에러를 만든다”는 문제는, retry만으로는 근본 해결이 안 됩니다(계속 버스트를 만들면 계속 맞습니다). (help.openai.com)


💻 실전 코드

아래 예시는 “현실적인” 시나리오로, (a) 큐에 쌓이는 작업(b) 다중 워커가 처리하되, (c) 전역 페이싱(d) 429/503에 대한 retry-after 존중 + exp backoff + jitter를 함께 적용합니다.

  • 언어: Python (FastAPI/worker 어디든 이식 가능)
  • 외부 의존성: httpx, tenacity
  • 가정: LLM 공급자는 429에 Retry-After를 줄 수도/안 줄 수도 있음(Anthropic은 준다고 명시, Gemini/OpenAI는 상황별) (platform.claude.com)

1) 설치/실행

1
2
3
python -m venv .venv
source .venv/bin/activate
pip install httpx tenacity

2) “전역 페이서 + 재시도” 워커 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
import asyncio
import random
import time
from dataclasses import dataclass
from typing import Optional, Dict, Any

import httpx
from tenacity import retry, retry_if_exception_type, stop_after_attempt

# --------- Exceptions ---------
class RetryableLLMError(Exception):
    def __init__(self, status_code: int, retry_after_s: Optional[float], body: str):
        super().__init__(f"retryable llm error status={status_code} retry_after={retry_after_s} body={body[:200]}")
        self.status_code = status_code
        self.retry_after_s = retry_after_s
        self.body = body


# --------- Global pacer (token bucket-ish, simplified) ---------
class GlobalPacer:
    """
    전역 요청 속도를 "부드럽게" 제한하는 페이서.
    - RPM 기반: min_interval = 60 / rpm
    - 여러 워커가 공유할 때도 burst를 줄이기 위해 락으로 직렬화
    """
    def __init__(self, rpm: float):
        self.min_interval = 60.0 / rpm
        self._lock = asyncio.Lock()
        self._next_ts = 0.0

    async def acquire(self):
        async with self._lock:
            now = time.monotonic()
            if now < self._next_ts:
                await asyncio.sleep(self._next_ts - now)
            # 약간의 랜덤 지터로 동기화 현상 완화(동일 배포 시점/동일 워커 수에서 특히 유효)
            jitter = random.uniform(0.0, 0.05)
            self._next_ts = max(self._next_ts, time.monotonic()) + self.min_interval + jitter


# --------- Backoff policy ---------
@dataclass
class BackoffPolicy:
    base: float = 0.5       # 초기 backoff
    cap: float = 30.0       # 최대 backoff
    jitter: float = 0.25    # +/- 지터 비율
    max_attempts: int = 6   # 너무 길게 늘어지지 않도록 상한


def compute_backoff(policy: BackoffPolicy, attempt_idx: int) -> float:
    # exp backoff: base * 2^attempt
    delay = min(policy.cap, policy.base * (2 ** attempt_idx))
    # jitter: [delay*(1-j), delay*(1+j)]
    j = policy.jitter
    return random.uniform(delay * (1 - j), delay * (1 + j))


# --------- LLM call with retry-after preference ---------
def parse_retry_after(headers: httpx.Headers) -> Optional[float]:
    ra = headers.get("retry-after")
    if not ra:
        return None
    try:
        return float(ra)
    except ValueError:
        # HTTP-date 형태는 여기선 단순화: 필요하면 parsedate_to_datetime로 처리
        return None


async def call_llm_raw(client: httpx.AsyncClient, url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
    r = await client.post(url, json=payload, timeout=60.0)
    if r.status_code in (429, 503, 408) or 500 <= r.status_code <= 599:
        raise RetryableLLMError(
            status_code=r.status_code,
            retry_after_s=parse_retry_after(r.headers),
            body=r.text,
        )
    r.raise_for_status()
    return r.json()


def make_retry_decorator(policy: BackoffPolicy):
    async def sleep_fn(retry_state):
        exc = retry_state.outcome.exception()
        attempt = retry_state.attempt_number - 1  # 0-index
        if isinstance(exc, RetryableLLMError) and exc.retry_after_s is not None:
            # 서버가 준 retry-after를 최우선(여기에 작은 지터 추가)
            delay = exc.retry_after_s + random.uniform(0.0, 0.2)
        else:
            delay = compute_backoff(policy, attempt)
        await asyncio.sleep(delay)

    return retry(
        retry=retry_if_exception_type(RetryableLLMError),
        stop=stop_after_attempt(policy.max_attempts),
        reraise=True,
        sleep=sleep_fn,
    )


# --------- Realistic worker ---------
async def worker(name: str, queue: asyncio.Queue, pacer: GlobalPacer, client: httpx.AsyncClient, url: str):
    policy = BackoffPolicy(base=0.7, cap=45.0, jitter=0.3, max_attempts=6)
    retry_call = make_retry_decorator(policy)

    while True:
        job = await queue.get()
        try:
            # 1) 사전 페이싱: 버스트 억제 (OpenAI의 quantization 이슈를 실무에서 가장 많이 줄여줌) ([help.openai.com](https://help.openai.com/en/articles/6891753-api-rate-limit-advice?utm_source=openai))
            await pacer.acquire()

            payload = {
                "input": job["prompt"],
                # 현실 포인트: max tokens를 과하게 잡으면 "예상 사용량"이 커져 rate limit에 불리할 수 있음(OpenAI 권고) ([help.openai.com](https://help.openai.com/en/articles/6891753-api-rate-limit-advice?utm_source=openai))
                "max_output_tokens": job.get("max_output_tokens", 512),
                "temperature": job.get("temperature", 0.2),
                "metadata": {"job_id": job["id"], "worker": name},
            }

            # 2) 사후 복구: 429/503면 retry-after 우선 + exp backoff + jitter (Gemini 권고) ([ai.google.dev](https://ai.google.dev/gemini-api/docs/troubleshooting?utm_source=openai))
            @retry_call
            async def _do():
                return await call_llm_raw(client, url, payload)

            result = await _do()
            print(f"[{name}] done job={job['id']} => keys={list(result.keys())}")

        except RetryableLLMError as e:
            # max_attempts 초과
            print(f"[{name}] FAILED job={job['id']} after retries: {e}")
        except Exception as e:
            # 400/401/403 등은 보통 retry하면 안 됨
            print(f"[{name}] NON-RETRY error job={job['id']}: {e}")
        finally:
            queue.task_done()


async def main():
    # 예: 조직 전체로 120 RPM 정도가 안전한 상황이라고 가정
    pacer = GlobalPacer(rpm=120)

    # 현실 시나리오: 검색/요약/분류 등 비동기 작업이 큐로 쌓임
    queue: asyncio.Queue = asyncio.Queue()
    for i in range(50):
        await queue.put({
            "id": f"doc-{i}",
            "prompt": f"Summarize incident report #{i} with action items and owner.",
            "max_output_tokens": 350,
            "temperature": 0.1
        })

    # LLM endpoint는 예시 URL(프로바이더별로 바꿔 끼우는 구조 권장)
    url = "https://example-llm-provider.com/v1/generate"

    async with httpx.AsyncClient(headers={"Authorization": "Bearer YOUR_KEY"}) as client:
        workers = [
            asyncio.create_task(worker(f"w{i}", queue, pacer, client, url))
            for i in range(6)
        ]
        await queue.join()
        for t in workers:
            t.cancel()

if __name__ == "__main__":
    asyncio.run(main())

3) 예상 출력(예시)

1
2
3
4
[w0] done job=doc-0 => keys=['output', 'usage', 'id']
[w3] done job=doc-1 => keys=['output', 'usage', 'id']
[w2] FAILED job=doc-7 after retries: retryable llm error status=429 retry_after=2.0 body=...
...

이 코드의 핵심은 “재시도”가 아니라:

  • 버스트를 먼저 줄이고(GlobalPacer)
  • 429가 나면 Retry-After를 가장 먼저 신뢰하고(Anthropic이 명시) (platform.claude.com)
  • 없으면 지수 backoff + jitter로 분산시키는 점입니다(Gemini 권고). (ai.google.dev)

⚡ 실전 팁 & 함정

Best Practice 1) “Retry-After 우선, 없으면 exp backoff”를 표준화하라

  • Anthropic은 429에 retry-after가 오며 “그 전에 재시도하면 실패한다”고 적습니다. (platform.claude.com)
  • 공급자별로 헤더가 없거나 불완전한 경우가 있어, fallback backoff가 필요합니다.

판단 기준: retry-after가 있으면 “내가 계산한 backoff”보다 서버가 더 정확합니다(특히 token bucket reset 타이밍).

Best Practice 2) 전역(프로세스/클러스터) 단위로 “페이싱”을 걸어라

OpenAI는 분당 한도라도 짧은 구간으로 쪼개 집행되어 버스트에 의해 429가 날 수 있다고 명시합니다. (help.openai.com)
따라서 “429 뜨면 재시도”만으로는 부족하고, 애초에 호출을 고르게 분산해야 합니다.

현실 팁

  • 단일 인스턴스: 위 예시처럼 락 기반 pacer
  • 다중 인스턴스: Redis 기반 전역 token bucket(슬라이딩 윈도우/리키 버킷)로 확장

Best Practice 3) max_output_tokens(혹은 max_tokens)를 “필요한 만큼만” 잡아라

OpenAI는 max tokens 값이 “예상 사용량”에 영향을 주고, 과도하게 잡으면 rate limit에 불리해질 수 있다고 조언합니다. (help.openai.com)
실무에서는 “대부분 200~400 토큰 나오는데 2000으로 열어둔” 설정이 흔한데, 이게 429 체감 빈도를 올립니다.


흔한 함정/안티패턴

1) jitter 없는 exponential backoff

  • 모든 워커가 1s, 2s, 4s…로 동시에 깨어나 재시도 스파이크를 만듭니다.
  • Gemini도 jitter 추가를 권장합니다. (ai.google.dev)

2) 429를 무시하고 “즉시 다른 워커로 재시도”

  • 분산 시스템에서 이 패턴은 rate limit을 더 악화시키고, 결국 전체 성공률이 떨어집니다.
  • 해결: 같은 job에 대해 “단일 플라이트(single-flight)” + 중앙 재시도 정책

3) 재시도 대상 오류를 넓게 잡기

  • 400/401/403 같은 클라이언트 오류는 retry로 해결되지 않습니다.
  • 429/408/5xx 위주로 제한하고, 공급자 문서가 명시한 코드만 허용하는 편이 안전합니다(Gemini 가이드도 “특정 에러만 재시도” 권장). (ai.google.dev)

비용/성능/안정성 트레이드오프

  • 안정성↑: 페이싱 + backoff + jitter + retry-after 준수
  • 지연(latency)↑: 특히 429 구간에서 tail latency가 늘어납니다(사용자 요청 경로면 더 치명적)
  • 비용↑/↓ 둘 다 가능
    • 재시도 자체는 호출 수를 늘려 비용↑
    • 반대로 “불필요한 max tokens 축소”로 토큰 비용↓ 가능(OpenAI 조언) (help.openai.com)

실무적으로는 “대화형(온라인) 경로”는 재시도를 1~2회로 제한하고, “비동기 배치/큐”에서 충분히 재시도하는 경로 분리가 가장 안전합니다.


🚀 마무리

정리하면 2026년 7월 기준 LLM API 안정화의 핵심은 “429가 나면 잠깐 기다리자”가 아니라:

1) 버스트를 없애는 전역 페이싱(quantization/짧은 구간 집행 대응) (help.openai.com)
2) Retry-After가 있으면 최우선 준수(특히 Anthropic은 명시) (platform.claude.com)
3) 없으면 exponential backoff + jitter(Gemini 포함 다수 가이드가 권장) (ai.google.dev)
4) max tokens를 현실적으로 줄여 토큰 기반 제한과 비용을 같이 관리 (help.openai.com)

도입 판단 기준

  • 동시성(워커 수)과 트래픽 버스트가 존재한다 → “retry만” 말고 “pacer+retry”로 가야 합니다.
  • 429가 하루 종일 지속된다/즉시 429가 난다 → 단순 backoff로는 해결이 안 될 수 있으니, 키/조직/티어/스펜드 제한/엔드포인트 특이 제한을 함께 점검해야 합니다(Gemini는 spend 기반 429도 문서화). (ai.google.dev)

다음 학습 추천

  • 공급자별 rate limit 헤더/Limit API를 읽고(Anthropic은 Rate Limits API와 헤더를 문서화) (platform.claude.com), 내 서비스에 맞는 “전역 페이싱(분산 레이트리미터)”를 설계해보세요.
  • 장기적으로는 “클라이언트 측 제어 알고리즘” 연구도 흥미롭습니다(백오프 대비 429를 크게 줄이는 접근을 다루는 최근 연구가 나와 있습니다). (arxiv.org)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.