Published on

OpenAI 429·Rate Limit - 백오프·큐잉 실전

Authors

운영에서 OpenAI API를 붙이면 가장 먼저 부딪히는 오류가 429 Too Many Requests 입니다. 개발 환경에서는 “잠깐 기다렸다 다시 호출”로 넘어가도, 트래픽이 몰리는 프로덕션에서는 재시도가 오히려 트래픽을 더 키워 장애를 증폭시키는 경우가 많습니다.

이 글은 429를 단순 예외 처리로 끝내지 않고, 백오프(Backoff) + 큐잉(Queueing) + 토큰 버짓(Token budget) 을 묶어 지연과 비용, 실패율을 함께 낮추는 실전 패턴을 다룹니다. Node.js 기준 예제를 포함하지만, 개념은 어떤 런타임에도 그대로 적용됩니다.

관련해서 더 깊게 파고든 장문 가이드는 아래 글도 함께 참고하면 좋습니다.

429가 의미하는 것: “너무 많이”의 정체

429는 단순히 QPS가 높다는 뜻만은 아닙니다. OpenAI 계열 API에서 흔히 엮이는 제한은 다음처럼 여러 축이 있습니다.

  • 요청 수 제한: 분당 요청 수(RPM) 혹은 초당 요청 수
  • 토큰 처리량 제한: 분당 토큰(TPM) 또는 모델별 처리량
  • 동시성 제한: 동시에 처리 가능한 in-flight 요청 수
  • 조직/프로젝트 단위 제한: 여러 서비스가 같은 키를 공유하면 합산되어 터짐

즉, 같은 429라도 원인이 다릅니다. 그래서 “몇 초 쉬고 재시도”만으로는 해결이 안 되고, 재시도 정책 + 동시성 제어 + 입력 크기(토큰) 관리가 같이 가야 합니다.

재시도는 필수지만, ‘무작정’은 금물

나쁜 재시도 패턴

  • 모든 429를 즉시 재시도
  • 고정 딜레이(sleep(1s))로 재시도
  • 여러 워커가 동시에 같은 딜레이로 재시도(동기화 폭주)

이 패턴은 서버가 숨 돌릴 시간을 주지 못하고, 클라이언트들이 같은 타이밍에 다시 몰려 thundering herd 가 발생합니다.

좋은 재시도 패턴의 3요소

  • 지수 백오프: 실패할수록 대기 시간을 기하급수로 증가
  • 지터(jitter): 대기 시간을 랜덤화해 재시도 타이밍 분산
  • 상한(cap) + 총 시도 제한: 무한 재시도 방지

실전 백오프 설계: full jitter가 기본값

가장 흔히 권장되는 방식은 full jitter 입니다.

  • 기본 지수 백오프: base * 2^attempt
  • 여기에 지터: random(0, backoff)
  • 최대 대기 상한: cap

아래는 Node.js에서 재사용하기 좋은 유틸입니다.

// backoff.js
export function computeBackoffMs({
  attempt,
  baseMs = 200,
  capMs = 10_000,
}) {
  const exp = Math.min(capMs, baseMs * (2 ** attempt));
  return Math.floor(Math.random() * exp); // full jitter
}

export function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

그리고 429 및 일시 장애에만 재시도를 적용합니다. 네트워크 타임아웃, 502, 503 등도 보통 재시도 대상입니다. 단, 400 계열 입력 오류는 재시도해도 소용이 없으므로 제외합니다.

// retry.js
import { computeBackoffMs, sleep } from './backoff.js';

export async function withRetry(fn, {
  maxAttempts = 6,
  baseMs = 200,
  capMs = 10_000,
  shouldRetry = (err) => {
    const status = err?.status ?? err?.response?.status;
    return status === 429 || status === 502 || status === 503 || status === 504;
  },
} = {}) {
  let lastErr;
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn({ attempt });
    } catch (err) {
      lastErr = err;
      if (!shouldRetry(err) || attempt === maxAttempts - 1) throw err;
      const waitMs = computeBackoffMs({ attempt, baseMs, capMs });
      await sleep(waitMs);
    }
  }
  throw lastErr;
}

여기까지만 해도 “개별 요청”의 안정성은 좋아집니다. 하지만 트래픽이 몰리면 여전히 429가 반복됩니다. 이유는 간단합니다. 재시도는 수요를 줄이지 못하기 때문입니다.

429를 ‘구조적으로’ 줄이는 핵심: 큐잉과 동시성 제한

429는 결국 “처리 가능한 속도보다 더 빨리 넣고 있다”는 신호입니다. 이때 필요한 건 재시도가 아니라 유입을 조절하는 밸브입니다.

목표

  • API 호출을 즉시 실행하지 않고 큐에 적재
  • 워커가 정해진 동시성으로만 처리
  • 실패 시 재시도는 워커 내부에서 수행

Node.js에서는 p-queue 같은 라이브러리가 가장 간단합니다.

npm i p-queue
// queue.js
import PQueue from 'p-queue';

export const openaiQueue = new PQueue({
  concurrency: 3,         // 동시에 3개만 날리기
  interval: 1000,         // 1초 단위로
  intervalCap: 10,        // 1초에 최대 10개만 시작
});

이제 실제 호출은 항상 큐를 통해서만 나가게 만듭니다.

// client.js
import OpenAI from 'openai';
import { openaiQueue } from './queue.js';
import { withRetry } from './retry.js';

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export function enqueueChatCompletion({ model, messages, max_output_tokens }) {
  return openaiQueue.add(() =>
    withRetry(() =>
      client.responses.create({
        model,
        input: messages,
        max_output_tokens,
      })
    )
  );
}

이 구조의 장점은 명확합니다.

  • 트래픽 피크에서도 동시 호출이 폭발하지 않음
  • 429가 나와도 재시도는 “제어된 속도”로만 발생
  • 큐 길이(대기열)가 곧 시스템의 압력을 나타내는 지표가 됨

큐 길이를 기반으로 “빠른 실패”도 고려

큐가 무한정 길어지면 사용자 체감 지연이 폭증합니다. 따라서 다음 같은 정책을 둡니다.

  • 큐 대기 시간이 특정 임계치를 넘으면 503로 빠르게 실패
  • 또는 더 저렴한 모델로 폴백
  • 또는 요약/축약 프롬프트로 토큰을 줄여 처리량을 확보

p-queue는 작업 타임아웃을 직접 제공하지 않으므로, 작업 래퍼에서 타임아웃을 구현하는 방식이 실용적입니다.

function withTimeout(promise, ms) {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), ms);
  return Promise.race([
    promise,
    new Promise((_, reject) => {
      ctrl.signal.addEventListener('abort', () => reject(new Error('queue_timeout')));
    }),
  ]).finally(() => clearTimeout(t));
}

토큰 버짓: 429를 줄이는 가장 저평가된 레버

많은 팀이 RPM만 보는데, 실제 병목은 TPM인 경우가 많습니다. 입력이 길어지고 출력 토큰을 크게 잡으면, 요청 수가 적어도 토큰 처리량 제한으로 429가 납니다.

실전 체크리스트

  • 출력 상한을 무의식적으로 크게 잡지 말기: max_output_tokens를 서비스 요구사항에 맞게 타이트하게
  • 시스템 프롬프트/지시문이 비대해졌는지 점검
  • 대화 히스토리를 무한히 붙이지 말고, 요약본으로 치환
  • RAG를 쓰면 검색 결과를 그대로 다 붙이지 말고, 근거 문장만 추출

간단한 토큰 예산 정책 예시

요청이 큐에 들어가기 전에 “대략적인 토큰 예산”을 계산해 큰 요청을 늦추거나 거절할 수 있습니다.

정확한 토큰 계산은 토크나이저가 필요하지만, 운영에서는 근사치만으로도 효과가 큽니다.

// tokenBudget.js
export function roughTokenEstimate(text) {
  // 언어에 따라 다르지만, 대충 1토큰은 3~4글자 수준으로 근사
  return Math.ceil(text.length / 4);
}

export function enforceBudget({ inputText, maxOutputTokens, budgetTokens }) {
  const inTok = roughTokenEstimate(inputText);
  const total = inTok + maxOutputTokens;
  if (total > budgetTokens) {
    const err = new Error('token_budget_exceeded');
    err.meta = { inTok, maxOutputTokens, total, budgetTokens };
    throw err;
  }
}

이 정책을 큐에 넣기 전에 적용하면, 큰 요청이 전체 처리량을 잡아먹는 상황을 줄일 수 있습니다.

헤더와 응답을 관찰해 “내가 무엇에 걸렸는지” 구분

429를 줄이려면 원인 분류가 필요합니다. 실전에서는 다음을 로깅합니다.

  • HTTP 상태 코드
  • 에러 타입/메시지(가능하면 원문)
  • 재시도 횟수, 최종 대기 시간
  • 요청의 입력 길이, max_output_tokens
  • 큐 길이, 큐 대기 시간

그리고 429가 증가할 때 다음 질문에 답할 수 있어야 합니다.

  • 특정 엔드포인트/기능에서만 터지는가
  • 특정 테넌트/고객 트래픽에서만 터지는가
  • 입력이 길어진 배포가 있었는가
  • 동시성 설정이 변경되었는가

멀티 인스턴스 환경: “프로세스 내부 큐”만으로는 부족

서버가 1대면 위의 큐로 충분합니다. 하지만 오토스케일링으로 인스턴스가 늘어나면, 인스턴스마다 큐가 따로 생겨 전체적으로는 제한을 초과할 수 있습니다.

이때는 다음 중 하나가 필요합니다.

  • 중앙 큐(예: Redis 기반 BullMQ, SQS, RabbitMQ)
  • 중앙 레이트 리미터(예: Redis 토큰 버킷)
  • 키를 서비스별로 분리해 제한을 분산(조직 정책에 맞게)

Redis 토큰 버킷(개념) 예시

아래는 “아이디어” 수준의 의사 코드입니다. 핵심은 분산 환경에서 TPM 또는 RPM에 해당하는 토큰을 Redis에서 원자적으로 차감하는 것입니다.

// pseudo-code (개념 예시)
// - 매 초 refill
// - 요청 전에 토큰을 acquire
// - 부족하면 큐에서 대기

async function acquire(key, cost) {
  // Lua script로 원자적 처리(증가/감소/만료)
  // return true/false
}

구현 난이도가 있다면, 먼저 중앙 큐로 “호출 자체”를 한 곳에서만 수행하게 만드는 것이 더 단순할 때도 많습니다.

운영 팁: 백오프·큐잉을 넣었는데도 429가 난다면

다음 순서로 점검하면 빠릅니다.

  1. 동시성부터 낮추기: concurrency를 절반으로 줄이고 429가 즉시 줄어드는지 확인
  2. 출력 토큰 상한 축소: max_output_tokens를 기능별로 재설계
  3. 큐 대기 시간 관측: 평균/p95가 SLA를 넘는지 확인하고 빠른 실패 도입
  4. 핫키(Hot key) 확인: 특정 고객/기능이 트래픽을 독식하면 테넌트별 큐로 분리
  5. 에러가 429가 맞는지 확인: 간혹 게이트웨이/프록시에서 비슷한 코드로 변형

추가로, 429와 함께 502가 섞여 보인다면 네트워크 경로나 OpenAI 측 일시 장애도 의심해야 합니다. 이 경우는 재시도는 하되, 타임아웃과 회로 차단기(circuit breaker)를 같이 고려하는 편이 안전합니다. 관련 이슈는 아래 글도 참고할 수 있습니다.

정리: 429 대응의 우선순위

  • 1순위: 큐잉 + 동시성 제한으로 유입을 제어
  • 2순위: 지수 백오프 + 지터로 재시도 폭주를 방지
  • 3순위: 토큰 버짓으로 TPM 병목을 줄이고 비용까지 절감
  • 4순위: 멀티 인스턴스면 분산 큐/분산 레이트 리미터로 확장

429는 “재시도하면 언젠가 되겠지”가 아니라, 시스템이 보내는 명확한 신호입니다. 백오프는 상처를 덜 나게 하는 붕대이고, 큐잉과 토큰 버짓은 체질 개선입니다. 이 셋을 함께 적용하면 실패율뿐 아니라 지연, 비용까지 같이 내려가는 것을 체감할 수 있습니다.