모든 AI API에는 Rate Limit(속도 제한)이 있습니다. 개발 중에 이를 마주하면 짜증나는 정도지만, 프로덕션 환경에서 발생하면 사용자는 패턴을 파악하기 전까지는 무작위로 보이는 에러, 끊긴 스트림, 그리고 타임아웃을 겪게 됩니다.
핵심적인 실수는 Rate Limiting을 단일 문제로 취급하는 것입니다. 보통 동일한 429 에러 뒤에는 다음과 같은 네 가지 서로 다른 문제가 숨어 있습니다.
- requests per minute (분당 요청 수)
- tokens per minute (분당 토큰 수)
- concurrent in-flight requests (동시 실행 중인 요청 수)
- account-level or project-level quota exhaustion (계정 또는 프로젝트 수준의 할당량 소진)
이 중 하나에 대해서만 대비한다면, 나머지 요소들이 여전히 문제를 일으킬 것입니다.
만약 아직 제공업체 마이그레이션 단계에 있다면, 마이그레이션 가이드를 먼저 읽어보세요. 게이트웨이가 폴백(fallback) 및 운영 오버헤드에 도움이 되는지 평가 중이라면 OpenRouter 비교 글이 좋은 참고가 될 것입니다.
Rate Limit의 실제 의미
Request limits (요청 제한)
가장 명확한 제한입니다. 짧은 시간 내에 너무 많은 요청을 보냈을 때 발생합니다.
Token limits (토큰 제한)
많은 팀이 과소평가하는 부분입니다. 단 하나의 긴 프롬프트가 여러 개의 작은 요청만큼의 예산을 소모할 수 있습니다. 갑자기 20 KB의 시스템 프롬프트를 추가하면, 요청 횟수는 안정적으로 보여도 토큰 예산은 이미 바닥날 수 있습니다.
Concurrency limits (동시성 제한)
일부 제공업체와 게이트웨이는 분당 평균 수치는 허용하지만, 한꺼번에 50개의 스트림을 여는 것과 같은 버스트(burst) 형태의 요청에는 제한을 겁니다. 요금제 플랜은 문제가 없어도 요청의 형태가 문제가 되는 경우입니다.
Quota or balance exhaustion (할당량 또는 잔액 소진)
운영 결과가 동일하기 때문에 대시보드에서는 종종 "Rate Limit" 증상으로 나타납니다. 즉, 호출이 더 이상 성공하지 않습니다. 하지만 해결 방법은 다릅니다. 잔액이 0원이라면 Backoff(재시도 대기)는 아무런 소용이 없습니다.
제공업체별 일반적인 제한 방식
정확한 수치는 시간이 지남에 따라 변하므로, 애플리케이션 문서에 공개 가격표 스타일의 차트를 하드코딩하는 것은 좋지 않습니다. 일반적인 패턴은 다음과 같습니다.
- OpenAI 스타일의 제공업체는 대개 요청 및 토큰 헤더를 노출하며, 계정 이력이나 사용 티어에 따라 한도를 조정합니다.
- Anthropic 스타일의 제공업체는 대개 분당 처리량과 더불어 특히 고성능 모델에 대해 광범위한 프로젝트 제한을 적용합니다.
- Google 스타일의 제공업체는 종종 무료 티어와 유료 티어의 동작을 분리하며, 모델군에 따라 제한 수치를 크게 달리합니다.
- Aggregator(애그리게이터)는 업스트림 제약 위에 한 층의 제한을 더 추가하지만, 그 대가로 하나의 업스트림이 일시적으로 포화 상태일 때 다른 채널로 라우팅할 수 있는 기능을 제공합니다.
제공업체의 제한 수치를 상수가 아닌 유동적인 설정값으로 취급하세요.
Rate Limit 헤더 읽기
모든 주요 제공업체는 응답 헤더에 Rate Limit 정보를 반환합니다.
x-ratelimit-limit-requests: 500
x-ratelimit-remaining-requests: 499
x-ratelimit-reset-requests: 60s
x-ratelimit-limit-tokens: 200000
x-ratelimit-remaining-tokens: 199500
이러한 헤더를 선제적으로 사용하세요. 429 에러가 발생할 때까지 기다렸다가 속도를 줄이지 마세요.
권장되는 운영 습관은 간단합니다.
- 실패했을 때뿐만 아니라 성공했을 때도 헤더를 로깅하세요.
- 남은 용량이 임계값 아래로 떨어지면 알림을 보내세요.
- 다음 요청이 한도를 넘기 전에 트래픽을 조절(shaping)하세요.
실패한 후에야 헤더를 확인한다면 이미 늦은 것입니다.
재시도 로직(Retry Logic) 구축하기
잘못된 방식
# 이렇게 하지 마세요
import time
def call_api(messages):
while True:
try:
return client.chat.completions.create(
model="gpt-4.1",
messages=messages
)
except Exception:
time.sleep(1) # 고정된 지연 시간, Backoff 없음, 모든 에러를 캡처함
문제점: 지수 백오프(exponential backoff) 없음, 재시도 불가능한 에러까지 캡처함, 최대 재시도 횟수 제한 없음, 지터(jitter) 없음.
올바른 방식
import time
import random
from openai import RateLimitError, APIError, APIConnectionError
def call_with_retry(messages, model="gpt-4.1", max_retries=3):
"""지수 백오프와 지터를 사용하여 재시도합니다."""
for attempt in range(max_retries + 1):
try:
return client.chat.completions.create(
model=model,
messages=messages
)
except RateLimitError as e:
if attempt == max_retries:
raise
# 응답에 retry_after가 있다면 사용
wait = getattr(e, 'retry_after', None)
if wait is None:
wait = (2 ** attempt) + random.uniform(0, 1)
print(f"Rate limited. Waiting {wait:.1f}s (attempt {attempt + 1})")
time.sleep(wait)
except APIConnectionError:
if attempt == max_retries:
raise
wait = (2 ** attempt) + random.uniform(0, 1)
time.sleep(wait)
except APIError as e:
# 클라이언트 에러(400, 401, 403)는 재시도하지 않음
if e.status_code and 400 <= e.status_code < 500:
raise
if attempt == max_retries:
raise
time.sleep((2 ** attempt) + random.uniform(0, 1))
핵심 원칙:
- 지수 백오프(Exponential backoff): 1초, 2초, 4초, 8초와 같이 대기 시간을 늘림
- 지터(Jitter): 'Thundering herd(동시 재시도 폭주)' 현상을 방지하기 위해 0~1초의 무작위 시간 추가
- 제공된 경우
retry_after헤더 존중 - 클라이언트 에러(잘못된 요청, 인증 실패 등)는 재시도하지 않음
- 최대 재시도 횟수 설정
프로덕션 환경에서의 두 가지 추가 규칙:
- 스트리밍 엔드포인트에서 무한히 재시도하지 말 것
- 작업이 멱등성(idempotent)을 갖지 않는 한, 이미 사용자에게 보이는 부수 효과(side effect)와 연결된 요청은 재시도하지 말 것
Chat completions는 보통 재시도해도 안전합니다. 하지만 Tool(도구) 호출로 유발되는 부수 효과는 그렇지 않은 경우가 많습니다.
Async 버전
import asyncio
import random
from openai import AsyncOpenAI, RateLimitError
async_client = AsyncOpenAI(
api_key="sk-lemon-xxx",
base_url="https://api.lemondata.cc/v1"
)
async def call_with_retry_async(messages, model="gpt-4.1", max_retries=3):
for attempt in range(max_retries + 1):
try:
return await async_client.chat.completions.create(
model=model,
messages=messages
)
except RateLimitError:
if attempt == max_retries:
raise
wait = (2 ** attempt) + random.uniform(0, 1)
await asyncio.sleep(wait)
재시도 폭풍이 되기 전에 트래픽 조절하기
재시도 로직은 해결책의 절반일 뿐입니다. 업스트림이 이미 과부하 상태라면, 재시도는 일시적인 폭주를 스스로 초래한 서비스 중단으로 바꿀 수 있습니다.
다음 세 가지 제어 장치가 차이를 만듭니다.
1. 테넌트(Tenant) 또는 사용자별 큐(Queue)
한 고객이 대규모 배치 작업을 시작했을 때, 다른 모든 고객이 그 피해를 입게 해서는 안 됩니다.
2. 동시 스트림 제한
스트리밍 엔드포인트는 각 요청이 "저렴해" 보이지만 오랫동안 열려 있기 때문에 과소평가하기 쉽습니다.
3. 전송 전 프롬프트 다듬기
토큰 제한이 실제 병목인 경우가 많습니다. 프롬프트 길이가 두 배가 되면 안전한 처리량은 대략 절반으로 줄어듭니다.
클라이언트 측 토큰 버킷(Token Bucket)
처리량이 많은 애플리케이션의 경우, 서버 제한에 걸리지 않도록 클라이언트 측 Rate Limiting을 구현하세요.
import time
import asyncio
class TokenBucket:
def __init__(self, rate: float, capacity: int):
self.rate = rate # 초당 토큰 수
self.capacity = capacity # 최대 버스트 크기
self.tokens = capacity
self.last_refill = time.monotonic()
async def acquire(self, tokens: int = 1):
while True:
now = time.monotonic()
elapsed = now - self.last_refill
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.rate
)
self.last_refill = now
if self.tokens >= tokens:
self.tokens -= tokens
return
# 토큰이 충분해질 때까지 대기
wait = (tokens - self.tokens) / self.rate
await asyncio.sleep(wait)
# 분당 500개 요청 = 초당 약 8.3개
limiter = TokenBucket(rate=8.0, capacity=20)
async def rate_limited_call(messages, model="gpt-4.1"):
await limiter.acquire()
return await async_client.chat.completions.create(
model=model,
messages=messages
)
토큰 버킷은 한도를 알고 있을 때 유용합니다. 하드코딩된 추측치 대신 관찰된 헤더 데이터를 기반으로 튜닝하면 더욱 효과적입니다.
Rate Limit 발생 시 모델 폴백(Fallback)
주 모델이 Rate Limit에 걸리면 대안 모델로 전환하세요.
FALLBACK_CHAIN = [
"claude-sonnet-4-6",
"gpt-4.1",
"gpt-4.1-mini",
]
async def call_with_fallback(messages):
for model in FALLBACK_CHAIN:
try:
return await async_client.chat.completions.create(
model=model,
messages=messages
)
except RateLimitError:
continue
raise Exception("All models rate limited")
이 지점에서 모델 게이트웨이가 도움이 되지만, 폴백은 신중하게 설계되어야 합니다. 사용자 경험에 대한 고려 없이 프리미엄 추론 모델에서 저가형 모델로 조용히 넘어가서는 안 됩니다.
합리적인 폴백 체인은 다음과 같습니다.
- 동일 제공업체의 더 작은 형제 모델
- 다른 제공업체의 동급 모델 제품군
- 그 이후에만 더 저렴하거나 컨텍스트가 작은 모델
"가용성을 위한 폴백"과 "비용을 위한 폴백"을 한 단계에서 섞으면 디버깅이 매우 복잡해집니다.
Rate Limit 사용량 모니터링
사용자에게 영향을 미치기 전에 문제를 파악할 수 있도록 Rate Limit 소비량을 추적하세요.
import logging
def log_rate_limits(response):
headers = response.headers
remaining = headers.get("x-ratelimit-remaining-requests")
limit = headers.get("x-ratelimit-limit-requests")
if remaining and int(remaining) < int(limit) * 0.1:
logging.warning(
f"Rate limit warning: {remaining}/{limit} requests remaining"
)
남은 용량이 10% 미만으로 떨어지면 알림을 설정하세요. 이렇게 하면 사용자가 429 에러를 보기 전에 스로틀링(throttling)을 구현할 시간을 벌 수 있습니다.
또한 다음 항목들을 로깅해야 합니다.
- request ID
- model
- input size estimate (예상 입력 크기)
- stream duration (스트림 지속 시간)
- retry count (재시도 횟수)
- final outcome (최종 결과:
success,rate_limited,network_error,quota_exhausted)
이러한 필드 없이는 Rate Limit 사고 대응이 추측에 의존하게 됩니다.
간단한 프로덕션 체크리스트
챗봇이나 에이전트가 "Rate Limit에 안전하다"고 말하기 전에 다음 다섯 가지 항목을 확인하세요.
- 동기 및 비동기 경로 모두에 제한된(bounded) 재시도 정책이 있는가?
- 성공적인 응답에 대해 Rate Limit 헤더를 로깅하는가?
- 업스트림 호출 전에 사용자별 또는 테넌트별 트래픽 조절이 이루어지는가?
- 최소 하나 이상의 검증된 폴백 모델이 존재하는가?
- 프론트엔드가 멈춘 스트림 대신 깔끔한 에러 상태를 전달받는가?
단순한 재시도 기본형을 넘어 전체 애플리케이션을 구축 중이라면, one-key 챗봇 가이드에서 이러한 요소들이 실제 FastAPI 서비스에 어떻게 통합되는지 확인할 수 있습니다.
요약
Rate Limiting은 예외적인 상황이 아닙니다. 실제 사용자가 있는 모든 AI 제품의 정상적인 운영 조건입니다. 이를 잘 처리하는 팀은 마법 같은 높은 한도를 가진 것이 아닙니다. 그들은 처음부터 처리량, 재시도, 폴백을 애플리케이션 설계의 일부로 취급합니다.
LemonData에서 API 키를 생성하고, 실제 트래픽이 발생하기 전에 재시도 경로를 테스트하며, 다음 429 에러가 발생하기 전에 미리 대비하세요.
| 전략 | 사용 시점 |
|---|---|
| 지수 백오프 (Exponential backoff) | 항상 (기본) |
| 클라이언트 측 Rate Limiter | 높은 처리량의 앱 (>100 RPM) |
| 모델 폴백 (Model fallback) | SLA 요구사항이 있는 프로덕션 앱 |
| 선제적 모니터링 | 모든 프로덕션 배포 |
| Batch API | 실시간이 아닌 워크로드 |
목표는 Rate Limit을 완전히 피하는 것이 아닙니다. 사용자가 전혀 눈치채지 못하도록 우아하게 처리하는 것입니다.
회복탄력성 있는 AI 애플리케이션 구축: lemondata.cc는 업스트림 Rate Limit을 자동으로 처리하는 멀티 채널 라우팅을 제공합니다. 하나의 API 키로 300개 이상의 모델을 사용하세요.
