429 한 번에 서비스가 무너진다: LLM API rate limit 안정화(retry/backoff) 패턴 심층 분석
LLM API를 “프로덕션 트래픽”으로 돌리기 시작하면, 기능 버그보다 먼저 터지는 게 rate limit(429) + 일시 장애(5xx) + 재시도 폭풍(retry storm) 입니다.
들어가며
LLM API를 “프로덕션 트래픽”으로 돌리기 시작하면, 기능 버그보다 먼저 터지는 게 rate limit(429) + 일시 장애(5xx) + 재시도 폭풍(retry storm) 입니다. 특히 배치/에이전트/워크플로우처럼 동시성이 자연스럽게 올라가는 구조에서는, 재시도 로직이 빈약하면 “조금 느려지는” 수준이 아니라 전체 큐가 막히고 비용만 새는 상황으로 이어집니다.
이 글이 해결하는 문제는 딱 두 가지입니다.
- 429/5xx가 나와도 전체 파이프라인을 안정적으로 완주하게 만들기
- 재시도가 오히려 트래픽을 증폭시키는 thundering herd(동시 재시도)와 비용 폭탄을 막기
언제 쓰면 좋나?
- 워커/큐 기반 비동기 처리, agent fan-out, 다수 사용자 동시 호출, 멀티 모델/멀티 벤더 라우팅
- “가끔 429가 난다”가 아니라, 피크 시에 반드시 429가 나는 구조(= 설계상 정상)
언제 쓰면 안 되나?
- 단일 요청-응답의 짧은 흐름에서 “무조건 재시도”는 UX를 망칩니다. (예: 사용자 대기시간이 SLA인 채팅)
- 4xx(잘못된 파라미터/권한/정책 위반)를 재시도하는 건 낭비입니다. OpenAI/Gemini/Anthropic 모두 “재시도 대상 선별”을 강조합니다.1
🔧 핵심 개념
1) LLM rate limit은 “요청 수”가 아니라 “복수 버킷”이다
요즘 LLM API rate limit은 대개 단일 RPM이 아니라 여러 차원의 버킷으로 구성됩니다. Anthropic은 예를 들어 RPM / Input TPM / Output TPM 같은 3축을 명시하고, 초과 시 429와 함께 retry-after를 내려줍니다.2
즉 “요청 수를 줄였는데도 429가 난다”가 흔합니다. 토큰 폭증(긴 prompt, 큰 max_tokens, 예상보다 긴 출력)이 원인이 될 수 있습니다. OpenAI도 max tokens 설정이 rate limit 추정에 영향을 줄 수 있다고 안내합니다.1
실무 결론
- retry/backoff는 “마지막 방어선”이고, 그 전에 요청 크기/동시성/토큰 예산으로 1차 제어를 해야 합니다.
2) 2026년형 retry 우선순위: Retry-After → Reset 헤더 → backoff(+jitter)
여러 벤더의 공식 문서가 공통으로 암시하는 바는:
- 서버가 기다리라고 알려주면(Retry-After) 그걸 최우선으로 따르는 게 가장 효율적입니다. Anthropic은 429에서
retry-after를 제공한다고 명확히 말합니다.2 - Gemini도 429/5xx 같은 transient 에러에 대해 exponential backoff + jitter를 권장하고, 공식 SDK가 기본 retry를 수행한다고 안내합니다.3
- OpenAI는 429 대응으로 exponential backoff를 공식 베스트 프랙티스로 안내합니다.1
그리고 2026년 들어 “단순 backoff”만으로는 부족하다는 분석도 많습니다. 멀티 프로바이더 라우팅/페일오버 환경에서는 비동기 exponential backoff + jitter가 없으면 fallback API에 재시도 폭풍이 몰려 연쇄 장애가 난다는 시스템 연구도 나옵니다.4
정리하면, 지켜야 할 순서는:
- Retry-After가 있으면 그 시간(+작은 jitter)만큼 대기
- Retry-After가 없지만 reset 계열 헤더가 있으면 reset 시각까지 대기
- 아무 힌트가 없으면 exponential backoff + jitter
- 재시도 횟수/총 대기시간 상한을 둬서 “영원히 재시도”를 막기
3) 왜 jitter가 필수인가 (단순 “권장”이 아니라 “구조적 필요”)
동시에 100개 워커가 429를 맞으면, 모두가 1초 후에 동시에 재시도합니다. 그러면 다시 429 → 다시 동시 재시도… 이게 retry storm입니다.
Gemini 공식 가이드는 jitter를 넣어 “동시에 재시도하지 않게” 하라고 명시합니다.3
실무 결론
- “exponential backoff”는 최소 요건이고, jitter 없는 backoff는 거의 안 한 것과 비슷한 상황이 흔합니다(특히 큐/배치).
💻 실전 코드
아래 예제는 “toy”가 아니라, 실제로 운영에서 쓰는 형태를 그대로 가져온 패턴입니다.
- Python + asyncio 기반 워커
- 공통 retry 래퍼(429/5xx만 재시도)
Retry-After우선- 동시성 제한(세마포어) + per-host 커넥션 풀
- idempotency key(가능할 때)로 중복 과금/중복 작업 위험 완화
- 관측(재시도 횟수/대기시간)을 로그로 남김
1) 셋업
1
2
3
python -m venv .venv
source .venv/bin/activate
pip install httpx tenacity
2) 프로덕션 지향 retry/backoff 래퍼 + 워커 풀
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
import asyncio
import random
import time
from dataclasses import dataclass
from typing import Any, Optional
import httpx
RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
@dataclass
class RetryPolicy:
max_attempts: int = 6
base_delay_s: float = 0.8
max_delay_s: float = 30.0
# 전체 요청에 대해 "무한 대기" 방지용 (SLA/큐 적체 방지)
max_total_sleep_s: float = 60.0
def _parse_retry_after_seconds(resp: httpx.Response) -> Optional[float]:
ra = resp.headers.get("retry-after")
if not ra:
return None
try:
return float(ra)
except ValueError:
# HTTP-date 형식인 경우도 있으나, LLM 벤더들은 대체로 seconds를 씀.
return None
def _full_jitter_delay(policy: RetryPolicy, attempt_idx: int) -> float:
# Full Jitter: min(cap, base * 2^n) 범위에서 랜덤
cap = min(policy.max_delay_s, policy.base_delay_s * (2 ** attempt_idx))
return random.random() * cap
async def call_llm_with_retry(
client: httpx.AsyncClient,
url: str,
headers: dict[str, str],
payload: dict[str, Any],
policy: RetryPolicy,
) -> dict[str, Any]:
slept = 0.0
for attempt in range(policy.max_attempts):
t0 = time.time()
try:
resp = await client.post(url, headers=headers, json=payload)
except (httpx.ReadTimeout, httpx.ConnectTimeout, httpx.RemoteProtocolError) as e:
# 네트워크/타임아웃은 transient로 간주
resp = None
err = e
else:
err = None
# 성공
if resp is not None and 200 <= resp.status_code < 300:
return resp.json()
# 재시도 불가(대부분 4xx)
if resp is not None and resp.status_code not in RETRYABLE_STATUS:
raise RuntimeError(f"Non-retryable error {resp.status_code}: {resp.text[:500]}")
# 마지막 시도면 종료
if attempt == policy.max_attempts - 1:
if resp is None:
raise RuntimeError(f"Failed after retries (network): {err}")
raise RuntimeError(f"Failed after retries ({resp.status_code}): {resp.text[:500]}")
# 1순위: Retry-After 존중
delay = None
if resp is not None and resp.status_code == 429:
ra = _parse_retry_after_seconds(resp)
if ra is not None:
# 작은 jitter로 동시성 파동 완화
delay = ra + random.random() * 0.25
# 2~3순위: 없으면 지터 포함 exponential backoff
if delay is None:
delay = _full_jitter_delay(policy, attempt)
# 총 sleep 상한
if slept + delay > policy.max_total_sleep_s:
raise RuntimeError(
f"Retry budget exceeded: slept={slept:.2f}s, next_delay={delay:.2f}s"
)
dt = time.time() - t0
status = "network_error" if resp is None else str(resp.status_code)
print(
f"[retry] attempt={attempt+1}/{policy.max_attempts} "
f"status={status} req_time={dt:.2f}s sleep={delay:.2f}s"
)
await asyncio.sleep(delay)
slept += delay
raise AssertionError("unreachable")
async def worker(name: str, sem: asyncio.Semaphore, client: httpx.AsyncClient, job: dict[str, Any]):
async with sem:
url = job["url"]
headers = job["headers"]
payload = job["payload"]
# 가능하면 idempotency key를 넣어 “재시도 중복 처리” 위험을 줄입니다.
# (벤더/엔드포인트가 지원할 때만 의미가 있음)
headers = dict(headers)
headers.setdefault("Idempotency-Key", job["idempotency_key"])
data = await call_llm_with_retry(
client=client,
url=url,
headers=headers,
payload=payload,
policy=RetryPolicy(),
)
# 현실적인 후처리: 결과 저장/큐 ack 등을 여기서 수행
return {"job_id": job["job_id"], "output": data}
async def main():
# 동시성은 “내 서버” 기준이 아니라 “벤더 rate limit + 토큰 예산” 기준으로 잡아야 함
concurrency = 8
sem = asyncio.Semaphore(concurrency)
timeout = httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0)
limits = httpx.Limits(max_connections=50, max_keepalive_connections=20)
async with httpx.AsyncClient(timeout=timeout, limits=limits) as client:
jobs = []
for i in range(50):
jobs.append(
{
"job_id": f"batch-{i}",
"idempotency_key": f"batch-{i}-v1",
"url": "https://api.vendor.example/v1/chat/completions",
"headers": {
"Authorization": "Bearer $TOKEN",
"Content-Type": "application/json",
},
"payload": {
"model": "your-model",
"messages": [
{"role": "system", "content": "You are a coding assistant."},
{"role": "user", "content": f"Summarize log chunk #{i} and extract error causes."},
],
# max_tokens는 “예상 출력”과 맞추는 게 rate limit 안정화에 도움
# (불필요하게 크게 잡으면 rate limit 추정/버킷에 불리)
"max_tokens": 350,
},
}
)
results = await asyncio.gather(*(worker("w", sem, client, j) for j in jobs), return_exceptions=True)
ok = sum(1 for r in results if not isinstance(r, Exception))
fail = len(results) - ok
print(f"done: ok={ok}, fail={fail}")
if __name__ == "__main__":
asyncio.run(main())
예상 출력(예시)
- 429가 발생하면
Retry-After기반으로 대기하거나, 없으면 jittered backoff로 점진 증가 - 과도한 재시도로 총 대기 예산을 넘기면 빠르게 실패 처리(큐 재스케줄 등으로 넘기는 게 일반적으로 낫습니다)
⚡ 실전 팁 & 함정
Best Practice 1) “재시도”보다 “사전 스로틀링”이 더 싸고 안정적
OpenAI는 429 회피를 위해 max tokens를 현실적으로 잡고(=불필요한 토큰 예산 제거), 필요하면 usage tier/limits 자체를 올리라고 안내합니다.1
Anthropic은 rate limit이 다중 버킷(RPM/ITPM/OTPM)임을 명시합니다.2
→ 즉, 운영에서는 (1) 동시성 캡 + (2) 토큰 예산 캡 + (3) 큐 페이싱이 1차 방어선이고, retry/backoff는 2차 방어선입니다.
Best Practice 2) 429는 “내가 잘못”과 “서버가 바쁨”이 섞여 있다
Gemini/Anthropic 문서 모두 429를 transient로 보고 재시도를 권장하지만, 429의 원인은:
- 진짜 quota/rate limit 초과
- 특정 엔드포인트/모델의 일시적인 capacity 문제 처럼 다를 수 있습니다. 그래서 429가 계속 나면 “재시도만 늘리기”보다:
- 동시성/토큰을 더 줄이고
- 헤더가 주는 신호(
retry-after)를 최우선으로 따르고2 - 필요하면 다른 모델/리전/벤더로 failover(단, failover에도 backoff+jitter 필수)4 로 접근해야 합니다.
함정 1) linear backoff / 고정 sleep
1초 고정 sleep은 피크 구간에서 동기화 파동을 만들기 쉽습니다. Gemini는 jitter를 넣으라고 명시합니다.3
→ 최소한 Full Jitter(위 코드) 또는 Decorrelated Jitter를 쓰세요.
함정 2) “모든 에러 재시도”
400/401/403 같은 클라이언트 에러를 재시도하면 비용만 낭비합니다. Gemini 가이드는 transient 에러(429/5xx 등)만 재시도하라고 권장합니다.3
→ “retryable classification”을 코드에 박아 넣고, 로깅/메트릭으로 관측하세요.
트레이드오프) 안정성 vs 지연 vs 비용
- 재시도를 늘리면 성공률은 오르지만 지연(latency) 과 비용(중복 요청) 이 증가합니다.
- 그래서 “최대 시도 횟수”보다 더 중요한 건 retry budget(총 대기/총 시도 상한) 입니다.
- 대규모 배치에서는 “한 작업을 오래 붙잡고 재시도”하기보다 빠르게 실패 → 큐에 재투입(지수적 재예약)이 전체 처리량에 유리한 경우가 많습니다.
🚀 마무리
핵심은 하나입니다. LLM API 안정화는 ‘retry 코드’가 아니라 ‘트래픽 형태를 다듬는 설계’입니다.
도입 판단 기준(체크리스트):
- 429/5xx가 월 1회라도 나오면: Retry-After 우선 + backoff+jitter + retry budget은 바로 넣을 가치가 있습니다.1
- 동시 워커/배치/agent fan-out이 있다면: jitter 없는 backoff는 사실상 미도입에 가깝습니다.
- 멀티 벤더 failover를 고려한다면: backoff+jitter 없이는 연쇄 retry storm 위험이 커집니다.4
다음 학습 추천: