- Published on
OpenAI 429·Rate Limit - 백오프·큐잉 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영에서 OpenAI API를 붙이면 가장 먼저 부딪히는 오류가 429 Too Many Requests 입니다. 개발 환경에서는 “잠깐 기다렸다 다시 호출”로 넘어가도, 트래픽이 몰리는 프로덕션에서는 재시도가 오히려 트래픽을 더 키워 장애를 증폭시키는 경우가 많습니다.
이 글은 429를 단순 예외 처리로 끝내지 않고, 백오프(Backoff) + 큐잉(Queueing) + 토큰 버짓(Token budget) 을 묶어 지연과 비용, 실패율을 함께 낮추는 실전 패턴을 다룹니다. Node.js 기준 예제를 포함하지만, 개념은 어떤 런타임에도 그대로 적용됩니다.
관련해서 더 깊게 파고든 장문 가이드는 아래 글도 함께 참고하면 좋습니다.
- OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기
- 429가 OpenAI만의 문제가 아니라는 관점에서는: EKS ImagePullBackOff 429 Too Many Requests 해결
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가 난다면
다음 순서로 점검하면 빠릅니다.
- 동시성부터 낮추기:
concurrency를 절반으로 줄이고 429가 즉시 줄어드는지 확인 - 출력 토큰 상한 축소:
max_output_tokens를 기능별로 재설계 - 큐 대기 시간 관측: 평균/
p95가 SLA를 넘는지 확인하고 빠른 실패 도입 - 핫키(Hot key) 확인: 특정 고객/기능이 트래픽을 독식하면 테넌트별 큐로 분리
- 에러가 429가 맞는지 확인: 간혹 게이트웨이/프록시에서 비슷한 코드로 변형
추가로, 429와 함께 502가 섞여 보인다면 네트워크 경로나 OpenAI 측 일시 장애도 의심해야 합니다. 이 경우는 재시도는 하되, 타임아웃과 회로 차단기(circuit breaker)를 같이 고려하는 편이 안전합니다. 관련 이슈는 아래 글도 참고할 수 있습니다.
정리: 429 대응의 우선순위
- 1순위: 큐잉 + 동시성 제한으로 유입을 제어
- 2순위: 지수 백오프 + 지터로 재시도 폭주를 방지
- 3순위: 토큰 버짓으로
TPM병목을 줄이고 비용까지 절감 - 4순위: 멀티 인스턴스면 분산 큐/분산 레이트 리미터로 확장
429는 “재시도하면 언젠가 되겠지”가 아니라, 시스템이 보내는 명확한 신호입니다. 백오프는 상처를 덜 나게 하는 붕대이고, 큐잉과 토큰 버짓은 체질 개선입니다. 이 셋을 함께 적용하면 실패율뿐 아니라 지연, 비용까지 같이 내려가는 것을 체감할 수 있습니다.