Published on

OpenAI Responses API 429 레이트리밋 토큰버킷으로 끝내기

Authors

서론

OpenAI Responses API를 붙인 뒤 트래픽이 조금만 늘어도 429 Rate Limit이 터지기 시작하면, 대부분의 팀이 제일 먼저 하는 처방은 “재시도(backoff) 추가”입니다. 하지만 이 방식은 이미 한도를 초과한 상태에서 더 많은 요청을 쌓아 지연을 키우고, 스트리밍/타임아웃/취소 오류까지 연쇄로 만들기 쉽습니다.

현업에서 429를 “근본적으로” 줄이려면 핵심은 하나입니다.

  • 요청을 보내기 전에 속도를 제한(Admission Control)하고
  • 요청 수(RPM)와 토큰 수(TPM) 두 축을 동시에 관리하며
  • 분산 환경(멀티 워커/멀티 파드)에서도 일관되게 동작해야 합니다.

이 글에서는 가장 실용적인 해법인 토큰버킷(Token Bucket) 으로 OpenAI Responses API 429를 안정화하는 방법을, 코드와 함께 정리합니다.

429 Rate Limit이 “재시도만으로” 안 풀리는 이유

1) 429는 실패가 아니라 “과속 경고”다

429는 서버가 “너무 빨리 온다”고 말하는 신호입니다. 즉, 요청 생성 속도 자체를 낮추지 않으면 재시도는 같은 문제를 반복합니다.

  • 재시도는 큐를 더 쌓음 → p95/p99 지연 증가
  • 동시성 높은 서비스는 동기화되지 않은 재시도로 버스트가 더 커짐

2) OpenAI는 보통 RPM/TPM을 함께 본다

실제 운영에서는 “요청 수는 적지만 프롬프트가 길어서” 429가 나거나, 반대로 “토큰은 적지만 동시 요청이 많아서” 429가 납니다.

따라서 RPM만 제한하거나 동시성만 제한하면 반쪽짜리 해결이 됩니다.

3) 스트리밍/타임아웃과 연쇄된다

429로 인해 재시도가 늘면, 대기 시간이 길어지고 결국 408/스트리밍 끊김/클라이언트 취소(499)로 이어지기 쉽습니다. 스트리밍 안정화는 별도 튜닝도 필요합니다.

토큰버킷(Token Bucket)으로 푸는 전략

토큰버킷은 “일정 속도로 토큰이 채워지는 버킷”을 두고, 요청이 들어올 때 토큰을 소비하는 방식입니다.

  • 버킷 용량(capacity): 순간 버스트를 허용하는 최대치
  • 리필 속도(refill rate): 초당/분당 허용량

이걸 OpenAI에 적용하면 보통 2개의 버킷을 둡니다.

  1. 요청 버킷(RPM): 요청 1개당 1 토큰 소비
  2. 토큰 버킷(TPM): 요청 1개당 “예상 토큰 수”만큼 소비

요청을 보내기 전에 두 버킷 모두에서 토큰을 확보(acquire)하면, 429를 “사전에” 줄일 수 있습니다.

설계 체크리스트: 운영에서 중요한 포인트

1) 버스트(capacity)를 너무 작게 잡지 마라

  • capacity가 0에 가까우면 작은 스파이크에도 대기열이 길어짐
  • 일반적으로 capacity = rate * 1~5초치 정도부터 시작

예: 600 RPM(=10 RPS)이면 capacity를 20~50 정도로 두면 짧은 버스트를 흡수합니다.

2) TPM은 “예상치”가 필요하다

응답 토큰은 호출 전 정확히 알 수 없습니다. 그래서 실전에서는:

  • 입력 토큰은 실제로 계산(가능하면)
  • 출력 토큰은 max_output_tokens 또는 과거 통계 기반 p95 예상치 사용

그리고 응답이 끝난 뒤 실제 사용량을 받아서 보정하는 방식이 가장 안정적입니다.

3) 분산 환경이면 중앙 집중형(예: Redis)으로

Gunicorn 멀티 워커나 Kubernetes 멀티 파드에서 프로세스 로컬 버킷을 쓰면, 각 인스턴스가 한도만큼 또 쏘기 때문에 전체로는 초과합니다.

  • 단일 인스턴스/배치 작업: 로컬 버킷도 충분
  • 멀티 워커/멀티 파드: Redis 같은 공유 스토어 기반 버킷 추천

Python 구현 1: 단일 프로세스 asyncio 토큰버킷

아래는 프로세스 로컬에서 동작하는 간단하고 빠른 구현입니다(테스트/배치/단일 서버에 적합).

import asyncio
import time
from dataclasses import dataclass

@dataclass
class TokenBucket:
    capacity: float
    refill_per_sec: float
    tokens: float = 0.0
    updated_at: float = 0.0

    def __post_init__(self):
        self.tokens = self.capacity
        self.updated_at = time.monotonic()

    def _refill(self):
        now = time.monotonic()
        elapsed = now - self.updated_at
        if elapsed > 0:
            self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_per_sec)
            self.updated_at = now

    async def acquire(self, amount: float = 1.0):
        while True:
            self._refill()
            if self.tokens >= amount:
                self.tokens -= amount
                return
            # 부족한 토큰을 채우는 데 필요한 시간만큼 sleep
            deficit = amount - self.tokens
            wait_sec = deficit / self.refill_per_sec
            await asyncio.sleep(min(wait_sec, 0.25))


class DualLimiter:
    def __init__(self, rpm: int, tpm: int, burst_seconds: float = 3.0):
        # RPM -> 초당 리필
        rps = rpm / 60.0
        tps = tpm / 60.0
        self.req_bucket = TokenBucket(capacity=rps * burst_seconds, refill_per_sec=rps)
        self.tok_bucket = TokenBucket(capacity=tps * burst_seconds, refill_per_sec=tps)

    async def acquire(self, estimated_tokens: int):
        # 두 버킷 모두 확보해야 요청 전송
        await self.req_bucket.acquire(1)
        await self.tok_bucket.acquire(estimated_tokens)

사용 예(Responses API 호출 전 게이트):

from openai import AsyncOpenAI

client = AsyncOpenAI()
limiter = DualLimiter(rpm=600, tpm=120_000, burst_seconds=5)

def estimate_tokens(prompt: str, max_output_tokens: int) -> int:
    # 실전에서는 tiktoken 등으로 입력 토큰을 계산하고,
    # 출력은 max_output_tokens 또는 p95 추정치를 사용
    return int(len(prompt) / 4) + max_output_tokens

async def call_responses(prompt: str):
    est = estimate_tokens(prompt, max_output_tokens=800)
    await limiter.acquire(est)

    return await client.responses.create(
        model="gpt-4.1-mini",
        input=prompt,
        max_output_tokens=800,
    )

이 방식만으로도 “동시 요청이 폭주하는 순간 429가 터지는 문제”는 크게 줄어듭니다.

Python 구현 2: Redis 기반 분산 토큰버킷(멀티 파드 대응)

Kubernetes/Gunicorn 멀티 워커라면 Redis + Lua로 원자적(acquire를 한 번에) 처리하는 게 안전합니다.

핵심 요구사항

  • 여러 프로세스가 동시에 acquire해도 경쟁 조건 없이 토큰 차감
  • 리필 계산도 서버 시간 기준으로 일관

아래 Lua는 “현재 토큰/마지막 리필 시각”을 저장하고, 필요한 양이 있으면 차감 후 성공(1), 아니면 실패(0)와 대기 시간(ms)을 반환합니다.

-- KEYS[1] = bucket key
-- ARGV[1] = capacity
-- ARGV[2] = refill_per_ms
-- ARGV[3] = amount
-- ARGV[4] = now_ms

local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_per_ms = tonumber(ARGV[2])
local amount = tonumber(ARGV[3])
local now = tonumber(ARGV[4])

local data = redis.call('HMGET', key, 'tokens', 'updated_at')
local tokens = tonumber(data[1])
local updated_at = tonumber(data[2])

if tokens == nil then
  tokens = capacity
  updated_at = now
end

local elapsed = now - updated_at
if elapsed > 0 then
  tokens = math.min(capacity, tokens + elapsed * refill_per_ms)
  updated_at = now
end

if tokens >= amount then
  tokens = tokens - amount
  redis.call('HMSET', key, 'tokens', tokens, 'updated_at', updated_at)
  redis.call('PEXPIRE', key, 600000)
  return {1, 0}
else
  local deficit = amount - tokens
  local wait_ms = math.ceil(deficit / refill_per_ms)
  redis.call('HMSET', key, 'tokens', tokens, 'updated_at', updated_at)
  redis.call('PEXPIRE', key, 600000)
  return {0, wait_ms}
end

Python에서 호출:

import asyncio
import time
import redis.asyncio as redis

LUA = open("bucket.lua", "r", encoding="utf-8").read()

class RedisTokenBucket:
    def __init__(self, r: redis.Redis, key: str, capacity: float, refill_per_sec: float):
        self.r = r
        self.key = key
        self.capacity = capacity
        self.refill_per_ms = refill_per_sec / 1000.0
        self.sha = None

    async def load(self):
        self.sha = await self.r.script_load(LUA)

    async def acquire(self, amount: float):
        assert self.sha is not None
        while True:
            now_ms = int(time.time() * 1000)
            ok, wait_ms = await self.r.evalsha(
                self.sha,
                1,
                self.key,
                self.capacity,
                self.refill_per_ms,
                amount,
                now_ms,
            )
            if int(ok) == 1:
                return
            await asyncio.sleep(min(float(wait_ms) / 1000.0, 0.25))


class RedisDualLimiter:
    def __init__(self, r: redis.Redis, prefix: str, rpm: int, tpm: int, burst_seconds: float = 5.0):
        rps = rpm / 60.0
        tps = tpm / 60.0
        self.req = RedisTokenBucket(r, f"{prefix}:req", capacity=rps * burst_seconds, refill_per_sec=rps)
        self.tok = RedisTokenBucket(r, f"{prefix}:tok", capacity=tps * burst_seconds, refill_per_sec=tps)

    async def load(self):
        await self.req.load()
        await self.tok.load()

    async def acquire(self, estimated_tokens: int):
        # 순서 중요: req 먼저 잡고 tok 잡는 방식은 tok에서 대기할 때 req 토큰을 점유할 수 있음
        # 실전에서는 둘 다 동시에 확보하거나, 실패 시 롤백이 가능한 설계를 고려
        await self.req.acquire(1)
        await self.tok.acquire(estimated_tokens)

실전 팁: “둘 다 확보”의 원자성

위 예시는 단순화를 위해 두 번 acquire합니다. 하지만 고부하에서 다음 문제가 생길 수 있습니다.

  • req는 확보했는데 tok에서 대기 → req 토큰이 잠깐 낭비

해결책은:

  • 하나의 Lua에서 req/tok를 함께 처리(권장)
  • 또는 req 확보 후 tok 실패 시 req를 반환(롤백)하는 로직

운영 트래픽이 크면 반드시 “한 번의 원자적 연산”으로 합치는 쪽이 안전합니다.

429 트러블슈팅: 토큰버킷을 넣었는데도 터질 때

1) 모델/프로젝트별 제한이 다르다

같은 API라도 모델, 조직, 프로젝트 설정에 따라 제한이 다를 수 있습니다.

  • 한도를 코드에 하드코딩하지 말고 환경변수/설정 서버로 주입
  • 운영 중 한도 변경에 대응(릴리즈 없이 조정)

2) 추정 토큰이 현실보다 작다

TPM 버킷을 쓰는데도 429가 나면, 대부분 출력 토큰을 과소 추정한 겁니다.

  • max_output_tokens를 너무 크게 잡았다면 → 모델이 실제로 많이 뱉는 구간에서 429
  • 반대로 너무 작게 추정하면 → 버킷은 통과했는데 실제 사용량이 더 커서 429

대응:

  • 응답의 usage(입출력 토큰)를 수집해 슬라이딩 윈도우 p95로 추정치를 업데이트
  • 긴 문서 요약/분석 요청은 별도 큐로 분리(heavy lane)

3) 스트리밍은 “열린 연결”도 자원이다

스트리밍 응답이 길어지면 동시 연결이 늘고, 업스트림/프록시에서 병목이 생깁니다. 429가 아니라도 499/502/timeout으로 보일 수 있습니다.

4) asyncio 태스크 누수로 “숨은 동시성”이 생긴다

대기/재시도 로직이 꼬이면 태스크가 쌓여 실제 동시 요청이 증가합니다.

Best Practice: 429를 “관리 가능한 이벤트”로 만들기

1) 서버 앞단에서 큐잉하고, 사용자에게는 빠르게 응답하라

  • 동기 요청을 무작정 대기시키면 타임아웃이 늘어납니다.
  • 가능하면 “요청 접수 → 작업 큐 → 결과 폴링/웹훅” 패턴으로 전환하세요.

2) 레이트리미터는 관측 가능해야 한다

최소한 아래 지표를 찍어야 튜닝이 됩니다.

  • acquire 대기 시간(ms) p50/p95
  • 분당 소비 토큰/요청 수
  • 429 발생률(리미터 도입 후 0에 가까워져야 정상)

3) 실패 시 재시도는 “소량 + 지터 + 상한”

토큰버킷이 있어도 네트워크/일시 장애로 429가 날 수 있습니다.

  • exponential backoff + jitter
  • 재시도 횟수 상한(예: 2~3회)
  • 멱등성 고려(특히 스트리밍/툴 호출 포함 시)

결론

OpenAI Responses API의 429는 “조금 기다리면 된다” 수준의 문제가 아니라, 요청 생성 속도를 제어하지 않으면 계속 재발하는 구조적 병목입니다. 토큰버킷을 적용하면:

  • 요청 전 단계에서 과속을 막고
  • RPM/TPM을 동시에 안정적으로 관리하며
  • 분산 환경에서도 일관된 한도 준수가 가능합니다.

지금 운영 중인 서비스에서 429가 반복된다면, 재시도 로직을 더 복잡하게 만들기 전에 토큰버킷 기반 Admission Control을 먼저 넣고, usage 기반으로 TPM 추정치를 보정하는 것부터 시작하세요.