Published on

OpenAI API 429 폭주 해결 - LangChain 백오프·큐

Authors

서버가 바쁘면 느려지는 게 아니라, 보통은 먼저 무너집니다. 특히 LLM 호출은 요청당 비용이 크고 지연도 길어, 트래픽이 순간적으로 몰리면 429 Too Many Requests가 연쇄적으로 터지며 재시도 폭주가 발생합니다. 이 글은 OpenAI API에서 429가 발생하는 구조를 정리하고, LangChain을 사용하는 서비스에서 백오프(Backoff) + 동시성 제한 + 큐잉(Queueing) 으로 안정화하는 구현 패턴을 다룹니다.

또한 운영 관점에서 “429를 없애는 것”이 아니라 “429가 나와도 시스템이 무너지지 않게” 만드는 것이 목표입니다.

429가 나는 진짜 이유: 단순 요청 수가 아니다

OpenAI 계열 API의 429는 단순히 초당 요청 수만의 문제가 아니라, 다음이 복합적으로 작동할 때 발생합니다.

  • RPM 제한: Requests Per Minute
  • TPM 제한: Tokens Per Minute (입력+출력 토큰 합)
  • 동시성(Concurrency): 동시에 처리 중인 요청 수가 많으면 지연이 늘고, 재시도가 겹치면 더 악화
  • 클라이언트 재시도 폭주: 여러 워커가 동시에 실패하고 동시에 재시도하면 스파이크가 증폭

즉, “요청 수를 줄인다”가 아니라,

  • 재시도를 분산하고
  • 동시 실행을 제어하고
  • 초과 요청을 버퍼링하는

3단계 방어선을 깔아야 합니다.

증상 체크리스트: 429 폭주가 일어나는 전형적인 패턴

다음 중 2개 이상이면, 백오프만으로는 해결이 잘 안 됩니다.

  1. 실패 로그에 429가 연속으로 찍히고, 성공/실패가 파도처럼 반복됨
  2. 워커 수를 늘릴수록 성공률이 오히려 떨어짐
  3. 타임아웃도 같이 증가함 (지연 증가로 재시도가 더 겹침)
  4. 사용자 요청이 몰리는 이벤트 시점에만 폭발함
  5. “재시도”를 애플리케이션 여러 레이어에서 중복으로 하고 있음 (SDK 재시도 + LangChain 재시도 + 앱 재시도)

이 패턴은 DB에서 데드락이 났을 때 무작정 재시도를 걸면 더 악화되는 현상과 유사합니다. 재시도는 필요하지만, 질서 있게 해야 합니다. 참고로 유사한 “폭주-재시도-악화” 문제를 다룬 글로 MySQL 8.0 InnoDB 데드락 원인추적·해결 실전도 함께 보면 사고방식에 도움이 됩니다.

해결 전략 요약: 백오프·동시성 제한·큐

아래 순서대로 적용하는 것을 권장합니다.

  1. 백오프(지수 백오프 + 지터): 재시도 간격을 늘리고, 여러 인스턴스의 재시도 타이밍을 흩뿌림
  2. 동시성 제한(세마포어/레이트리미터): 한 프로세스 안에서 동시에 날리는 LLM 호출 수를 고정
  3. 큐(버퍼): 순간 트래픽을 흡수하고, 워커가 일정 속도로 처리

핵심은 “즉시 처리” 대신 “수용 후 지연 처리”를 받아들이는 설계입니다.

1) LangChain에서 429 안전한 백오프 구성

LangChain 자체도 재시도 유틸이 있지만, 실제 운영에서는 다음 원칙을 지키는 게 중요합니다.

  • 지수 백오프: base * 2^n
  • 지터: 랜덤 요소를 섞어 동시 재시도 폭주를 방지
  • Retry-After 헤더가 있으면 우선 존중
  • 재시도 횟수 상한과 전체 타임아웃 상한을 둠

아래는 Python에서 tenacity로 “429만 선별 재시도”를 구성하는 예시입니다. (LangChain 체인 호출을 감싸는 방식)

import random
import time
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception

class RateLimitError(Exception):
    pass

def is_rate_limit_error(exc: Exception) -> bool:
    # 실제로는 openai SDK 예외 타입 또는 status code를 확인하세요.
    return isinstance(exc, RateLimitError)

def jitter(seconds: float) -> float:
    return seconds * (0.5 + random.random())

@retry(
    retry=retry_if_exception(is_rate_limit_error),
    wait=wait_exponential(multiplier=1, min=1, max=30),
    stop=stop_after_attempt(6),
    reraise=True,
)
def call_chain_with_backoff(chain, inputs: dict):
    try:
        return chain.invoke(inputs)
    except RateLimitError as e:
        # 여기에 `Retry-After`를 파싱해 우선 적용하는 로직을 추가할 수 있습니다.
        # tenacity 기본 wait에 지터를 섞고 싶다면, 커스텀 wait 전략을 쓰거나
        # 아래처럼 간단히 sleep을 추가할 수도 있습니다.
        time.sleep(jitter(1.0))
        raise

백오프만으로 부족한 이유

백오프는 “실패 후 재시도”에만 반응합니다. 하지만 429 폭주는 보통 “동시 요청이 너무 많다”에서 시작하므로, 실패를 줄이려면 애초에 동시성을 제한해야 합니다.

2) 동시성 제한: 세마포어로 LLM 호출 수를 고정

가장 즉효가 큰 방법은 프로세스(또는 워커) 단위로 LLM 호출 동시성을 제한하는 것입니다.

아래는 asyncio.Semaphore로 LangChain 호출을 감싸는 패턴입니다.

import asyncio

class LLMGate:
    def __init__(self, max_concurrency: int):
        self._sem = asyncio.Semaphore(max_concurrency)

    async def run(self, coro):
        async with self._sem:
            return await coro

# 사용 예
# gate = LLMGate(max_concurrency=5)
# result = await gate.run(chain.ainvoke({"question": "..."}))

이 방식의 장점은 단순합니다.

  • 외부 API 제한이 빡빡해도 내부 폭주를 막음
  • 워커 수를 늘려도 “각 워커당 동시성”이 고정되어 전체 트래픽 예측이 쉬움

주의할 점은, 비동기 코드에서 컨텍스트 매니저/세마포어를 잘못 사용하면 릭이 나거나 영원히 락이 풀리지 않는 실수가 생긴다는 겁니다. 관련해서는 Python async Context Manager 실수 7가지 같은 체크리스트를 참고하면 좋습니다.

동시성 제한은 어디에 걸어야 하나

  • 웹 요청 핸들러 레벨에서 바로 걸기: 가장 단순, 하지만 사용자 경험에 지연이 직접 반영됨
  • “LLM 호출 함수” 공통 레이어에 걸기: 재사용성 높음
  • 큐 워커에서만 걸기: 웹은 빠르게 수락하고, 백그라운드에서 처리

대부분의 서비스는 최종적으로 “큐”로 갑니다.

3) 큐로 폭주 흡수: 즉시 처리 대신 수용 후 처리

트래픽이 순간적으로 튀는 서비스라면, 429를 근본적으로 줄이는 방법은 큐잉입니다.

  • 웹/API 서버는 요청을 받으면 빠르게 job을 큐에 넣고 202 Accepted 또는 작업 ID를 반환
  • 워커는 정해진 동시성으로 큐를 소비하며 LLM 호출
  • 결과는 DB/캐시/오브젝트 스토리지 등에 저장하고, 클라이언트는 폴링 또는 웹훅으로 수신

Redis 기반 간단 큐(예: RQ) 패턴

아래는 Python RQ로 “LLM 작업을 큐에 넣고 워커가 처리”하는 매우 단순한 예시입니다.

# producer.py
from rq import Queue
from redis import Redis

redis_conn = Redis(host="localhost", port=6379)
q = Queue("llm", connection=redis_conn)

def enqueue_llm_job(user_id: str, prompt: str):
    job = q.enqueue("worker.process_llm", user_id, prompt, job_timeout=300)
    return job.id
# worker.py
import asyncio
from redis import Redis
from rq import Worker, Queue, Connection

# 여기서는 동시성 제한을 워커 내부에서 구현한다고 가정
from tenacity import retry, stop_after_attempt, wait_exponential

async def llm_call(prompt: str) -> str:
    # 실제로는 LangChain chain.ainvoke 등을 호출
    await asyncio.sleep(0.1)
    return "ok"

@retry(wait=wait_exponential(min=1, max=20), stop=stop_after_attempt(5), reraise=True)
def process_llm(user_id: str, prompt: str):
    # RQ는 기본이 sync이므로 내부에서 event loop를 돌리는 방식은 주의가 필요합니다.
    # 운영에서는 Celery, Dramatiq, 또는 완전 async 워커를 권장합니다.
    return asyncio.run(llm_call(prompt))

if __name__ == "__main__":
    redis_conn = Redis(host="localhost", port=6379)
    with Connection(redis_conn):
        worker = Worker([Queue("llm")])
        worker.work()

위 예시는 개념 전달용이며, 실제 운영에서는 다음을 꼭 추가하세요.

  • 워커 프로세스 수와 워커 내부 동시성의 곱이 전체 동시성이 됨
  • 재시도는 “큐 재시도”와 “API 재시도”가 중복되지 않게 한 곳에서만 책임지기
  • 작업 중복 방지: 동일 요청이 여러 번 enqueue 되지 않게 idempotency key 설계

LangChain에서 특히 흔한 429 유발 지점

LangChain을 쓰면 “한 번 호출”이라고 생각했는데 실제로는 여러 번 API를 칠 수 있습니다.

  • Retriever가 여러 문서를 가져오며 추가 호출
  • Map-Reduce 요약 체인: 문서 조각 수만큼 호출
  • Agent: 툴 호출 루프에서 다회 호출
  • Streaming 응답에서 클라이언트 중단 시 재시도 로직이 꼬이면 중복 호출

따라서 429를 줄이려면 체인/에이전트가 한 요청에서 최대 몇 번 호출하는지를 먼저 계측해야 합니다.

운영 설계: 429를 “장애”로 만들지 않는 방법

1) 에러 분류: 재시도 가능한 429 vs 즉시 실패해야 하는 429

  • 짧은 스파이크로 인한 429: 백오프 후 재시도 가치가 큼
  • 이미 큐가 꽉 찬 상태에서의 429: 재시도는 더 큰 폭주를 만듦, 빠르게 실패하고 사용자에게 지연/혼잡을 알리는 편이 낫습니다.

2) 서킷 브레이커와 드롭 정책

큐 길이가 임계치를 넘으면 다음 중 하나를 선택합니다.

  • 신규 요청을 429 또는 503으로 빠르게 거절
  • 저우선 작업(예: 배치 요약, 추천 재계산)을 드롭
  • degrade: 더 작은 모델, 더 짧은 max tokens, 캐시된 요약 반환

이 사고방식은 인프라에서 CrashLoopBackOff가 나는 상황에 “무한 재시작”을 막는 것과 유사합니다. 트래픽/리소스가 한계일 때는 빨리 차단하고 회복해야 합니다. 관련 진단 관점은 systemd 서비스가 자꾸 재시작될 때 7단계 진단도 참고할 만합니다.

3) 캐시로 토큰을 아낀다

429가 TPM에서 기인한다면, 캐시는 곧바로 효과가 납니다.

  • 동일 프롬프트/동일 컨텍스트 결과 캐시
  • 임베딩 캐시
  • Retriever 결과 캐시

캐시 키는 단순 문자열 전체를 쓰기보다, 정규화 후 해시를 권장합니다.

import hashlib
import json

def cache_key(model: str, messages: list[dict], extra: dict | None = None) -> str:
    payload = {"model": model, "messages": messages, "extra": extra or {}}
    raw = json.dumps(payload, ensure_ascii=False, sort_keys=True)
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()

실전 권장 아키텍처: 웹은 얇게, LLM은 워커로

정리하면, 429 폭주를 가장 안정적으로 잡는 구성은 다음입니다.

  • API 서버
    • 요청 검증, 인증
    • 작업 enqueue
    • 작업 ID 반환
  • 큐(예: Redis, SQS)
    • 버퍼 역할
  • 워커
    • 동시성 제한
    • 429에 대한 지수 백오프 + 지터
    • 결과 저장
  • 결과 조회 API 또는 웹훅

이 구조로 가면 트래픽이 튀어도 “큐가 늘어날 뿐”이며, 외부 API 제한 내에서 일정 속도로 처리됩니다.

체크리스트: 적용 순서대로 점검

  1. 429가 RPM인지 TPM인지 로그로 구분했는가
  2. 재시도가 중복으로 걸려 있지 않은가 (SDK, LangChain, 앱, 큐)
  3. 지수 백오프 + 지터가 있는가
  4. 프로세스 단위 동시성 제한이 있는가
  5. 순간 폭주를 흡수할 큐가 있는가
  6. 큐 길이 임계치에서 드롭/서킷 브레이커가 있는가
  7. 캐시로 토큰 사용량을 줄였는가

마무리

429 Too Many Requests는 “요청이 많다”는 신호이기도 하지만, 더 정확히는 “현재 설계가 스파이크를 감당하지 못한다”는 신호입니다. LangChain 기반 서비스는 내부적으로 다회 호출이 쉽게 발생하므로, 백오프만으로는 한계가 빠르게 옵니다.

  • 백오프는 재시도 폭주를 막고
  • 동시성 제한은 실패 자체를 줄이며
  • 큐는 시스템을 탄력적으로 만들어

결국 429를 장애가 아닌 “통제 가능한 혼잡”으로 바꿉니다.