Published on

OpenAI API 429 RateLimit 재시도와 큐 설계

Authors

서버에서 OpenAI API를 호출하다 보면 결국 429를 마주칩니다. 문제는 429 자체가 아니라, 그 다음에 흔히 하는 “그냥 몇 번 더 재시도”가 트래픽을 더 몰아넣어 자기증폭(thundering herd) 을 만들고, 사용자 체감은 더 나빠진다는 점입니다.

이 글은 429 RateLimit정상 상태로 흡수하기 위한 실전 설계를 다룹니다.

  • 재시도는 어디까지나 “부분 실패”를 회복하는 도구이고, 레이트 리밋은 시스템 설계 문제로 봐야 합니다.
  • 동기 요청 경로에서 무한 재시도하지 말고, 큐로 분리해 호출량을 평탄화해야 합니다.
  • 재시도에는 지수 백오프 + 지터 + 상한 + 예산(최대 대기시간) 이 필요합니다.

비슷한 맥락의 “연결/호출 불안정성”을 다루는 글로는 gRPC 스트리밍 끊김 대응 - Retry·Circuit Breaker 설계도 함께 참고하면 좋습니다.

429는 왜 발생하나: 원인 분해

OpenAI API의 429는 보통 아래 중 하나입니다.

  1. 요청 수 제한(RPM) 초과
  2. 토큰 처리량 제한(TPM) 초과
  3. 동시성 폭주로 순간적으로 제한을 넘김(짧은 버스트)
  4. 모델별/프로젝트별 제한이 다른데 이를 무시하고 동일 정책으로 호출

여기서 중요한 포인트는 “현재 요청이 실패했다”가 아니라 “지금은 더 보내면 안 된다”라는 흐름 제어 신호라는 점입니다.

따라서 해결은 두 축입니다.

  • 호출 전: 보내는 속도를 조절(레이트 리미터, 큐)
  • 호출 후: 실패를 안전하게 재시도(백오프, 지터, 데드레터)

안티패턴: 동기 API에서 즉시 재시도 루프

가장 흔한 실수는 웹 요청 핸들러에서 다음을 하는 것입니다.

  • 429가 뜨면 sleep 후 재시도
  • 성공할 때까지 반복

이 구조는

  • 요청 스레드를 점유해서 웹 서버를 막고
  • 동시에 들어온 요청들이 같은 타이밍에 깨어나면서
  • 더 큰 버스트를 만들어

결국 429가 더 늘어납니다.

동기 경로에서는 원칙적으로 다음 중 하나를 선택해야 합니다.

  • 빠르게 실패하고 503 또는 사용자 메시지로 반환
  • 작업을 큐에 넣고 202 Accepted로 반환(권장)

재시도 기본기: 지수 백오프 + 지터 + 상한

재시도는 “몇 번”이 아니라 “어떤 간격으로, 어떤 상한으로”가 핵심입니다.

권장 요소:

  • 지수 백오프: base * 2^attempt
  • 지터: 대기시간에 랜덤을 섞어 동시 재시도를 분산
  • 상한(max delay): 너무 길어지지 않게 제한
  • 총 예산(max elapsed): 사용자 경험/업무 요구에 맞춘 최대 대기

Node.js 예시: 429에만 재시도하고, 지터를 넣기

아래 코드는 fetch 기반 예시입니다. retry-after 헤더가 있다면 우선 적용하고, 없으면 지수 백오프를 적용합니다.

type RetryOptions = {
  maxAttempts: number;
  baseDelayMs: number;
  maxDelayMs: number;
  maxElapsedMs: number;
};

function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, ms));
}

function jitter(ms: number) {
  // full jitter: 0..ms
  return Math.floor(Math.random() * ms);
}

function parseRetryAfterSeconds(h: string | null): number | null {
  if (!h) return null;
  const n = Number(h);
  return Number.isFinite(n) ? n : null;
}

export async function callWithRetry(
  url: string,
  init: RequestInit,
  opt: RetryOptions
) {
  const started = Date.now();

  for (let attempt = 0; attempt < opt.maxAttempts; attempt++) {
    const res = await fetch(url, init);

    if (res.status !== 429 && res.status < 500) {
      return res;
    }

    // 5xx는 일시 장애일 수 있어 재시도 대상에 포함할지 정책으로 결정
    const retryable = res.status === 429 || res.status >= 500;
    if (!retryable) return res;

    const elapsed = Date.now() - started;
    if (elapsed >= opt.maxElapsedMs) return res;

    const ra = parseRetryAfterSeconds(res.headers.get("retry-after"));
    const exp = opt.baseDelayMs * Math.pow(2, attempt);
    const capped = Math.min(opt.maxDelayMs, exp);
    const delay = ra != null ? ra * 1000 : jitter(capped);

    await sleep(delay);
  }

  // 마지막 시도 후에도 실패했다면 마지막 응답을 호출부에서 처리
  return fetch(url, init);
}

핵심은 다음입니다.

  • 429는 무조건 재시도하기보다, 총 대기 예산을 넘기면 빠르게 반환
  • 지터로 동시 재시도를 분산
  • retry-after가 있으면 그 값을 우선

레이트 리미터: “재시도”보다 먼저 해야 하는 것

재시도는 사후 처리입니다. 하지만 429의 본질은 “사전 흐름 제어 실패”이므로, 호출 전단에 글로벌 레이트 리미터를 두는 것이 1순위입니다.

토큰 버킷(Token Bucket) vs 리키 버킷(Leaky Bucket)

  • Token Bucket: 버스트 허용, 평균 속도 제한
  • Leaky Bucket: 일정 속도로 배출, 버스트 억제에 강함

OpenAI 호출은 “버스트가 문제”인 경우가 많아서, 실무에서는

  • 앞단: Leaky Bucket처럼 일정 속도로 빼주고
  • 내부적으로: Token Bucket으로 약간의 버스트를 허용

같은 혼합 접근을 자주 씁니다.

Redis 기반 글로벌 리미터(간단 Lua)

여러 인스턴스에서 동시에 호출하면 인메모리 리미터는 깨집니다. Redis로 전역 제한을 잡는 편이 안전합니다.

아래는 고정 윈도우 카운터의 단순 예시입니다(정밀도는 떨어지지만 구현이 쉽습니다).

-- KEYS[1] = key
-- ARGV[1] = limit
-- ARGV[2] = windowSeconds

local current = redis.call('INCR', KEYS[1])
if current == 1 then
  redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
end

if current > tonumber(ARGV[1]) then
  return 0
end
return 1

이 방식은 경계 시점에 버스트가 생길 수 있으니, 트래픽이 크면 다음을 고려하세요.

  • 슬라이딩 윈도우
  • 토큰 버킷(시간 기반 토큰 적립)
  • 호출량뿐 아니라 TPM 기준 제어(요청 전에 예상 토큰을 추정해야 함)

큐 설계: 429를 “사용자 오류”가 아니라 “백엔드 흡수”로 바꾸기

동기 요청에서 OpenAI 호출을 직접 수행하면, 사용자 트래픽 패턴이 그대로 모델 호출 패턴이 됩니다. 즉, 피크 시간에 429가 폭발합니다.

해결은 비동기 큐로 평탄화하는 것입니다.

권장 아키텍처

  • API 서버: 요청을 검증하고 작업을 큐에 넣은 뒤 202 반환
  • 워커: 큐에서 작업을 가져와 레이트 리미터를 통과한 만큼만 OpenAI 호출
  • 저장소: 결과를 DB/캐시에 저장
  • 조회 API: 작업 상태 및 결과 조회

이 구조의 장점:

  • 사용자 요청 피크를 큐가 흡수
  • 워커의 동시성으로 처리량을 제어
  • 실패 재시도, 데드레터, 중복 방지 등을 체계화 가능

큐 기반 재시도에서 특히 중요한 디버깅 포인트는 “무한 재시도와 중복 실행”입니다. Redis 기반 워커를 쓴다면 Redis 기반 Celery 유령 작업 근절하기에서 다루는 개념(가시성 타임아웃, 중복 실행 조건)이 그대로 적용됩니다.

재시도 정책을 “큐 레벨”로 끌어올리기

동기 호출에서의 재시도는 사용자 응답 시간을 늘립니다. 큐에서는 다음처럼 정책을 바꿀 수 있습니다.

  • 재시도는 워커가 수행
  • 429는 “지금 처리 불가”이므로 지연 재큐잉(delayed retry)
  • 재시도 횟수 초과는 DLQ(Dead Letter Queue) 로 보내고 알림

상태 머신 예시

  • PENDING : 큐 적재
  • RUNNING : 워커 처리 중
  • SUCCEEDED : 결과 저장
  • RETRY_SCHEDULED : 429 등으로 지연 재시도 예약
  • FAILED : 비재시도 오류
  • DEAD : 재시도 예산 초과

이렇게 상태를 명시하면 운영 중에 “왜 느린가”를 추적하기 쉬워집니다.

중복 방지: Idempotency Key는 선택이 아니라 필수

큐 기반에서는 최소 한 번(at-least-once) 전달이 흔합니다. 워커가 처리 중 죽거나, ack 타이밍이 꼬이면 같은 작업이 재실행될 수 있습니다.

따라서 다음 중 하나는 반드시 해야 합니다.

  • 작업 생성 시 idempotencyKey를 발급하고, 결과 저장을 UPSERT로 처리
  • 동일 키로 처리 중이면 중복 실행을 막는 분산 락

PostgreSQL 예시: 결과 테이블을 멱등하게

CREATE TABLE ai_jobs (
  job_id TEXT PRIMARY KEY,
  idempotency_key TEXT UNIQUE NOT NULL,
  status TEXT NOT NULL,
  result_json JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 멱등 삽입: 같은 idempotency_key면 기존 job_id를 재사용하거나
-- 상태만 업데이트하는 전략을 선택

동시성 제어: 워커 개수보다 중요한 것은 “인플라이트 제한”

워커를 50개 띄우면 처리량이 늘 것 같지만, OpenAI 호출이 병목이면 50개가 동시에 때려서 429만 늘릴 수 있습니다.

권장 접근:

  • 워커 프로세스 수는 운영 편의/장애 격리를 위한 단위
  • 실제 호출 동시성은 별도의 세마포어로 제한
  • 전역 레이트 리미터와 함께 사용

Python 예시: asyncio 세마포어로 인플라이트 제한

import asyncio
import random

SEM = asyncio.Semaphore(5)  # 동시에 5개만 OpenAI 호출

async def call_openai(job):
    async with SEM:
        # 실제로는 OpenAI SDK 호출
        await asyncio.sleep(random.uniform(0.2, 0.6))
        return {"job": job, "ok": True}

async def worker_loop(queue: asyncio.Queue):
    while True:
        job = await queue.get()
        try:
            result = await call_openai(job)
            # DB 저장 등
        finally:
            queue.task_done()

이렇게 하면 워커가 100개여도 실제 외부 호출은 5개만 동시에 나갑니다.

429 처리의 디테일: 무엇을 재시도하고 무엇을 즉시 실패할까

실무에서는 다음처럼 분류하는 것이 운영이 편합니다.

  • 재시도 권장
    • 429 (RateLimit)
    • 500 502 503 504
    • 네트워크 타임아웃
  • 즉시 실패(또는 사용자 입력 수정 요구)
    • 400 (잘못된 파라미터)
    • 401 403 (인증/권한)
    • 404 (모델/리소스)

단, 400도 “일시적”일 수 있는 케이스가 간혹 있습니다. 예를 들어 툴 호출 스키마 불일치 같은 경우는 코드 배포로만 해결되므로 재시도는 낭비가 됩니다. 비슷한 유형의 진단 접근은 Claude Tool Use 400 오류 - schema·tool_result 해결에서의 분류 방식이 참고됩니다.

토큰 기반 제한(TPM)을 고려한 큐 스케줄링

RPM만 제어하면 여전히 429가 날 수 있습니다. 특히 긴 프롬프트, 긴 출력이 섞이면 TPM이 먼저 터집니다.

실전 팁:

  • 작업 생성 시 estimated_input_tokens를 계산(대략치라도)
  • 모델별 max_output_tokens 정책을 잡아 최악 케이스를 제한
  • 스케줄러에서 “큰 작업”을 연속으로 처리하지 않게 섞기

예를 들어 큐를 두 개로 나눌 수 있습니다.

  • small 큐: 짧은 요청(빠른 응답)
  • large 큐: 긴 문서 요약/분석(처리량 제한)

그리고 워커가 small에 가중치를 더 주면 대기열이 길어져도 사용자 체감이 좋아집니다.

관측 가능성: 429는 로그 한 줄로 끝내면 안 된다

운영에서 필요한 지표는 “에러율”보다 “흐름 제어가 잘 되고 있는가”입니다.

권장 메트릭:

  • openai_requests_total{status}
  • openai_429_total
  • openai_retry_attempts_histogram
  • queue_depth
  • job_latency_seconds (생성부터 완료까지)
  • inflight_requests
  • rate_limiter_denied_total

로그에는 최소한 다음을 남기세요.

  • job_id idempotency_key
  • 모델명
  • 시도 횟수
  • 적용된 대기시간(retry-after 또는 백오프)
  • 최종 상태(성공, DLQ)

운영 전략: “지금 당장” 429를 줄이는 체크리스트

  1. 동기 경로에서 429 재시도 루프 제거, 가능하면 202 + 큐로 전환
  2. 워커 인플라이트 제한(세마포어) 추가
  3. 전역 레이트 리미터를 Redis 등으로 통합
  4. 재시도에 지터 적용, maxElapsed 예산 설정
  5. 멱등 키로 중복 실행 방지
  6. DLQ와 알림으로 “영구 실패”를 격리

마무리: 429는 실패가 아니라 설계 신호

429 RateLimit은 “OpenAI가 불안정하다”의 신호가 아니라, 대개 “우리 시스템이 외부 제한을 고려하지 않고 밀어 넣고 있다”는 신호입니다.

  • 재시도는 반드시 지수 백오프 + 지터 + 상한 + 예산으로
  • 호출량 제어는 레이트 리미터로 사전에
  • 사용자 트래픽 피크는 큐로 평탄화
  • 중복 실행은 멱등성으로 제거

이 네 가지를 갖추면 429는 장애가 아니라, 예측 가능한 백프레셔(backpressure)로 바뀌고 시스템은 훨씬 안정적으로 운영됩니다.