Published on

OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기

Authors

서버가 멀쩡한데도 OpenAI API 호출이 갑자기 Rate limit exceeded (429)로 쏟아지면, 대부분의 팀은 두 가지 중 하나를 선택합니다. (1) 재시도 횟수를 늘려 “언젠간 되겠지”를 기대하거나, (2) 동시성을 낮춰 처리량을 희생합니다. 둘 다 장기적으로는 비용과 지연을 동시에 악화시키는 지름길입니다.

429는 단순한 에러가 아니라 시스템이 현재의 호출 패턴을 감당할 수 없다는 신호입니다. 따라서 해결책도 “재시도” 하나로 끝나지 않습니다. 이 글에서는 현업에서 바로 적용 가능한 형태로 지수 백오프 + 중앙 큐잉 + 토큰 버짓(예산) 기반 스케줄링을 조합해, **비용(불필요한 재시도/초과 토큰)과 지연(대기열 폭증)**을 함께 잡는 Python 설계를 소개합니다.

> 목표 > >- 429가 발생해도 시스템이 스스로 안정화(autosmoothing)되게 만들기 >- 실패-재시도 폭탄을 막고, 처리량을 예측 가능하게 유지하기 >- 토큰 사용량을 “예산”으로 관리해 비용을 통제하기

429가 터지는 진짜 이유를 먼저 분해하자

OpenAI API의 rate limit은 보통 다음 축으로 걸립니다(계정/모델/조직 정책에 따라 다름).

  • RPS(요청/초) 또는 RPM(요청/분) 제한
  • TPM(토큰/분) 제한(입력+출력 토큰 합산)
  • 동시 요청 수 제한(간접적으로 429/5xx로 드러나기도 함)

여기서 중요한 포인트는 “요청 수 제한”과 “토큰 제한”은 다른 병목이라는 겁니다.

  • 요청 수는 낮지만 프롬프트가 길고 max_output_tokens가 크면 → TPM 초과로 429
  • 프롬프트는 짧지만 동시 호출이 많으면 → RPM/RPS 초과로 429

따라서 해결도 둘로 나뉩니다.

  • 지수 백오프: 순간적인 폭주(버스트)를 완화
  • 큐잉/스케줄링: 호출을 중앙에서 제어해 “안정적인 처리율”로 평탄화
  • 토큰 버짓: 요청 단위의 비용을 미리 추정해, TPM 병목을 예방

안티패턴 단순 재시도 루프가 429 폭탄을 키운다

많이 보이는 코드는 대략 이런 형태입니다.

import time
from openai import OpenAI

client = OpenAI()

def call(prompt: str) -> str:
    for _ in range(10):
        try:
            r = client.responses.create(
                model="gpt-4.1-mini",
                input=prompt,
            )
            return r.output_text
        except Exception:
            time.sleep(0.1)
    raise RuntimeError("failed")

문제점:

  1. 고정 sleep은 동시 호출이 많을수록 재시도가 “동기화”되어 한꺼번에 몰립니다(재시도 스탬피드).
  2. 예외를 뭉뚱그리면 429/5xx/네트워크 오류를 같은 정책으로 처리하게 됩니다.
  3. 토큰 비용을 고려하지 않아 TPM 초과를 계속 반복합니다.

결과적으로 429가 발생하면 재시도가 더 많은 요청을 만들어 오히려 rate limit을 더 초과합니다.

설계 청사진 지수 백오프 + 지터 + 재시도 예산

핵심 규칙

  • 429/503 같은 일시적 오류에만 재시도
  • Retry-After가 있으면 그 값을 우선
  • 지수 백오프에는 반드시 jitter(무작위 흔들기) 를 넣어 동기화 재시도를 방지
  • 재시도는 “횟수”가 아니라 총 대기시간(예산) 으로 제한(꼬리 지연 방지)

Python 예제 Tenacity로 안전한 백오프

import random
import time
from typing import Optional

from openai import OpenAI
from openai import RateLimitError, APIError, APITimeoutError

client = OpenAI()


def _sleep_seconds(attempt: int, retry_after: Optional[float] = None) -> float:
    # 서버가 Retry-After를 줬다면 우선
    if retry_after is not None:
        return max(0.0, retry_after)

    # Full jitter exponential backoff
    cap = 10.0
    base = 0.5
    exp = min(cap, base * (2 ** attempt))
    return random.uniform(0, exp)


def call_with_backoff(prompt: str, *, max_wait_s: float = 20.0) -> str:
    start = time.time()
    attempt = 0

    while True:
        try:
            r = client.responses.create(
                model="gpt-4.1-mini",
                input=prompt,
                # 출력 상한은 비용/TPM에도 직결
                max_output_tokens=300,
            )
            return r.output_text

        except RateLimitError as e:
            # 일부 SDK/환경에서는 headers 접근이 다를 수 있음
            retry_after = None
            try:
                retry_after = float(e.response.headers.get("retry-after"))
            except Exception:
                pass

            sleep_s = _sleep_seconds(attempt, retry_after)

        except (APITimeoutError, APIError) as e:
            # 5xx/timeout은 짧게 재시도하되 지터 적용
            sleep_s = _sleep_seconds(attempt)

        except Exception:
            # 영구 오류(인증/파라미터 등) 가능성이 높으니 즉시 실패
            raise

        elapsed = time.time() - start
        if elapsed + sleep_s > max_wait_s:
            raise TimeoutError(f"retry budget exceeded after {elapsed:.2f}s")

        time.sleep(sleep_s)
        attempt += 1

이 코드만으로도 “재시도 폭탄”의 상당 부분은 줄일 수 있습니다. 하지만 트래픽이 큰 서비스라면 백오프만으로는 부족합니다. 다음 단계는 중앙 큐잉입니다.

중앙 큐잉으로 동시성 제어를 서비스 밖으로 끌어내기

백오프는 “이미 터진” 429를 완화합니다. 반면 큐잉은 429가 터지기 전에 요청을 일정한 속도로 흘려보냅니다.

왜 큐잉이 필요한가

  • 웹 서버/워커가 여러 대면 각 인스턴스가 제각각 재시도 → 전체적으로는 여전히 폭주
  • 특정 사용자/테넌트의 요청이 전체를 잠식 → 공정성 붕괴
  • 배치 작업이 실시간 트래픽을 밀어냄 → SLA 붕괴

중앙 큐(예: Redis, SQS, RabbitMQ, Kafka 등)에 “LLM 호출 작업”을 넣고, 제한된 수의 소비자(worker)가 토큰/요청 예산에 맞춰 처리하는 구조가 가장 안정적입니다.

최소 구현 asyncio 큐 + 세마포어

단일 프로세스에서라도 효과를 볼 수 있는 패턴입니다.

import asyncio
from dataclasses import dataclass
from openai import OpenAI

client = OpenAI()

@dataclass
class Job:
    job_id: str
    prompt: str
    max_output_tokens: int

async def worker(name: str, q: asyncio.Queue, sem: asyncio.Semaphore):
    while True:
        job = await q.get()
        try:
            async with sem:
                # 동시 호출 수 제한
                r = await asyncio.to_thread(
                    client.responses.create,
                    model="gpt-4.1-mini",
                    input=job.prompt,
                    max_output_tokens=job.max_output_tokens,
                )
                text = r.output_text
                # TODO: 결과 저장/콜백
        finally:
            q.task_done()

async def main():
    q: asyncio.Queue[Job] = asyncio.Queue(maxsize=1000)
    sem = asyncio.Semaphore(5)  # 동시성 상한

    # worker 5개
    for i in range(5):
        asyncio.create_task(worker(f"w{i}", q, sem))

    # producer 예시
    for n in range(100):
        await q.put(Job(str(n), f"summarize: doc {n}", 200))

    await q.join()

asyncio.run(main())

여기서 더 나아가려면 “동시성”뿐 아니라 분당 토큰(TPM) 을 제어해야 합니다. 그게 바로 토큰 버짓입니다.

토큰 버짓으로 TPM을 선제적으로 관리하기

토큰 버짓이 필요한 이유

TPM 제한은 “요청 수”가 아니라 “텍스트 길이 + 출력 길이”에 의해 결정됩니다. 즉, 같은 RPM이라도 어떤 요청은 가볍고 어떤 요청은 무겁습니다.

  • 긴 문서 요약, RAG 컨텍스트가 큰 질의, 코드 리뷰 → 입력 토큰이 큼
  • max_output_tokens를 크게 주는 생성 작업 → 출력 토큰이 큼

따라서 작업을 큐에 넣을 때부터 예상 토큰 비용을 추정하고, 분당 예산 내에서만 실행되도록 해야 429가 줄어듭니다.

간단한 토큰 추정과 토큰 버킷(rate limiter)

정확한 토큰 계산은 모델별 토크나이저가 필요하지만, 운영에서는 “대략의 상한”만 잡아도 충분히 효과가 있습니다.

  • 입력 토큰 추정: len(text) / 4(영문 기준) 같은 휴리스틱 또는 토크나이저 사용
  • 출력 토큰: max_output_tokens를 상한으로 간주

아래는 분당 토큰 예산을 토큰 버킷으로 구현한 예시입니다.

import time
import threading

class TokenBucket:
    def __init__(self, tokens_per_minute: int):
        self.capacity = tokens_per_minute
        self.tokens = tokens_per_minute
        self.refill_rate_per_sec = tokens_per_minute / 60.0
        self.last = time.time()
        self.lock = threading.Lock()

    def _refill(self):
        now = time.time()
        delta = now - self.last
        self.tokens = min(self.capacity, self.tokens + delta * self.refill_rate_per_sec)
        self.last = now

    def acquire(self, cost: int):
        # cost만큼 토큰이 찰 때까지 대기
        while True:
            with self.lock:
                self._refill()
                if self.tokens >= cost:
                    self.tokens -= cost
                    return
                need = cost - self.tokens
                wait_s = need / self.refill_rate_per_sec
            time.sleep(min(wait_s, 1.0))


def estimate_tokens(text: str) -> int:
    # 매우 단순한 휴리스틱(운영에서는 토크나이저 권장)
    return max(1, len(text) // 4)


def guarded_call(prompt: str, max_output_tokens: int, bucket: TokenBucket) -> str:
    in_tok = estimate_tokens(prompt)
    cost = in_tok + max_output_tokens
    bucket.acquire(cost)

    from openai import OpenAI
    client = OpenAI()
    r = client.responses.create(
        model="gpt-4.1-mini",
        input=prompt,
        max_output_tokens=max_output_tokens,
    )
    return r.output_text

이제 큐 워커에서 API를 호출하기 전에 bucket.acquire(cost)를 호출하면, TPM 초과로 인한 429를 사전에 줄일 수 있습니다.

토큰 버짓 운영 팁

  • max_output_tokens를 “요청 타입별”로 보수적으로 설정하세요. 기본값을 크게 두면 TPM을 쉽게 잡아먹습니다.
  • RAG라면 컨텍스트를 무작정 많이 붙이지 말고, Top-K를 동적으로 줄이거나 요약 캐시를 두세요.
  • 배치 작업은 별도 큐/별도 버짓(분리된 버킷)으로 분리해 실시간 트래픽을 보호하세요.

종합 아키텍처 추천 안정적인 LLM 호출 파이프라인

현업에서 많이 쓰는 형태는 아래 조합입니다.

  1. API Gateway / App: 사용자 요청 수신
  2. Job Queue: LLM 호출을 작업으로 적재(우선순위/테넌트 분리 가능)
  3. Worker Pool: 제한된 동시성 + 토큰 버짓으로 실행
  4. Retry Layer: worker 내부에서 429/5xx만 지수 백오프 재시도
  5. Cache: 동일 프롬프트/동일 문서 요약 결과 캐시(비용 절감)
  6. Observability: 429 발생률, 평균 대기시간, 토큰 소모량, 재시도 횟수

특히 3번과 4번을 분리해서 생각해야 합니다.

  • 큐/버짓은 “안 터지게”
  • 백오프는 “터져도 회복되게”

트러블슈팅 체크리스트 429가 계속 뜬다면

1 Retry-After를 무시하고 있지 않은가

가능하면 서버가 준 Retry-After를 우선 적용하세요. 고정 백오프보다 훨씬 효율적입니다.

2 max_output_tokens가 과도하지 않은가

429의 상당수는 TPM에서 옵니다. “대답 길면 좋지”는 운영에서 독입니다.

  • 요약: 150~300
  • 분류/추출: 50~150
  • 코드 생성: 필요 최소치로 시작 후, 부족하면 2단계로 추가 생성

3 동시 요청이 인스턴스 수만큼 곱해지고 있지 않은가

서버를 오토스케일링하면 각 인스턴스가 동시에 LLM을 두드려 총 동시성이 폭발합니다. 중앙 큐/분산 rate limiter가 없으면 429는 더 늘어납니다.

4 배치와 실시간이 같은 레인에 있지 않은가

배치가 실시간을 잡아먹는 순간, 실시간 요청이 429/timeout으로 무너집니다. 큐를 분리하거나 우선순위를 두세요.

5 재시도 자체가 비용을 폭증시키고 있지 않은가

재시도는 토큰 비용을 다시 발생시킬 수 있습니다(특히 요청이 서버에 도달해 처리되었는데 응답만 실패한 케이스). 다음을 고려하세요.

  • 멱등성 키(가능한 범위에서 요청 dedupe)
  • 결과 저장 후 재요청 시 캐시 반환
  • 재시도 예산(총 대기시간) 제한

Best Practice 운영에서 바로 먹히는 규칙들

  • 429는 경고등: “재시도”가 아니라 “흐름 제어” 문제로 보세요.
  • 지수 백오프 + full jitter는 기본값으로.
  • Retry budget(예: 20초)으로 꼬리 지연을 억제.
  • 토큰 버짓을 1급 시민으로: 요청 생성 단계에서 비용을 추정하고 스케줄링.
  • 큐를 중앙화하고, 배치/실시간/테넌트를 분리.
  • 관측 지표: 429 비율, 평균/95p 지연, 분당 토큰, 재시도 횟수, 큐 길이.

추가로, LLM 호출을 표준화할 때는 사내에서 공통 클라이언트 모듈을 만들어두면 좋습니다. 예를 들어 Python에서 OpenAI API 호출을 표준화하는 방법 같은 형태로, 재시도/로깅/메트릭을 한 곳에 모아두면 서비스별로 중복 구현을 줄일 수 있습니다.

또한 RAG 기반 서비스라면 컨텍스트 길이가 TPM을 크게 좌우하니, RAG 컨텍스트 최적화 전략처럼 “넣을 것만 넣는” 최적화가 429와 비용을 동시에 줄이는 지름길입니다.

마지막으로 운영 단계에서 장애 대응을 체계화하려면 API 레이트 리밋 모니터링과 알림 설계처럼 429를 단순 에러가 아니라 SLO 지표로 다루는 것이 효과적입니다.

결론 429를 없애는 게 아니라 시스템을 429에 강하게 만들자

429는 피할 수 없는 순간이 있습니다. 중요한 건 그때 시스템이 어떻게 반응하느냐입니다.

  • 지수 백오프(지터 포함)로 재시도 스탬피드를 막고
  • 중앙 큐잉으로 호출을 평탄화하며
  • 토큰 버짓으로 TPM 병목을 선제적으로 관리하면

처리량을 크게 희생하지 않으면서도 429 폭탄, 비용 폭증, 꼬리 지연을 동시에 줄일 수 있습니다.

지금 운영 중인 코드에서 먼저 (1) max_output_tokens 상한 재정의, (2) 재시도 예산 도입, (3) 토큰 버킷 기반 rate limiter부터 적용해보세요. 429 그래프가 꺾이기 시작하면, 그 다음은 큐 분리와 우선순위로 확장하는 단계입니다.