- Published on
OpenAI API 429 Rate Limit 재시도·큐잉 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI API를 붙이다 보면 어느 순간 429 응답을 마주합니다. 트래픽이 늘거나 배치 작업이 겹치면 특히 자주 터지는데, 많은 팀이 여기서 sleep(1) 같은 단순 재시도로 버티다가 오히려 호출 폭주를 만들어 장애를 키웁니다.
이 글은 429 Rate Limit을 "에러"가 아니라 "흐름 제어 신호"로 받아들이고, 재시도와 큐잉을 함께 설계해 안정적인 처리량을 확보하는 방법을 다룹니다. 특히 다음을 목표로 합니다.
- 재시도 폭풍(thundering herd) 방지
- 동시성 제어로 초당 요청 수를 평탄화
- 토큰 사용량 기준으로도 안전하게 큐잉
- 사용자 요청과 배치 요청을 분리해 품질 보장
관련해서 네트워크 호출이 끊기거나 불안정할 때의 패턴은 gRPC 스트리밍 끊김 대응 - Retry·Circuit Breaker 설계도 함께 보면 전체적인 복원력 설계에 도움이 됩니다. 결제나 크레딧 이슈로 인한 실패는 429와 성격이 다르니 OpenAI Responses API 402 결제·크레딧 오류 해결도 구분해서 참고하세요.
429는 왜 발생하나: 요청 수와 토큰 수의 이중 제한
OpenAI API의 제한은 보통 두 축으로 동작합니다.
- 요청 횟수 기반 제한: 분당 요청 수(RPM), 초당 요청 수(RPS) 등
- 토큰 기반 제한: 분당 토큰(TPM), 초당 토큰 등
여기서 중요한 포인트는 "요청 수가 적어도" 한 번에 큰 프롬프트나 긴 출력으로 토큰을 많이 쓰면 429가 날 수 있다는 점입니다. 즉, 단순히 동시 요청 개수만 줄이는 것으로는 해결이 안 되고, 토큰 사용량까지 고려한 큐잉이 필요합니다.
또한 429는 다음 케이스로 나뉘어 처리 전략이 달라집니다.
- 순간 버스트로 인한
429: 짧은 백오프 후 회복 가능 - 지속적인 과부하로 인한
429: 재시도보다 큐잉과 처리량 제한이 우선 - 여러 워커가 동시에 재시도하며 악화: 지터 없는 재시도 패턴의 전형
안티패턴: 고정 딜레이 재시도와 무제한 동시성
다음 패턴은 가장 흔하고, 가장 위험합니다.
- 모든 실패에 대해
1초고정 sleep 후 재시도 - 재시도 횟수만 늘리고 동시성은 그대로
- 프론트 요청마다 즉시 OpenAI 호출(서버에서 팬아웃)
왜 문제냐면, 429가 뜬 시점에 이미 제한을 넘었는데, 모든 요청이 같은 템포로 다시 몰리면 제한 초과 구간이 길어집니다. 결국 성공률은 오르지 않고 지연만 늘어나며, 심하면 다른 정상 트래픽까지 밀려납니다.
기본기 1: Retry-After 우선, 없으면 지수 백오프 + 지터
가능하면 응답 헤더의 재시도 힌트를 최우선으로 따르세요. 일반적으로 Retry-After가 있으면 그 값을 존중하는 것이 가장 안전합니다. 없는 경우에는 지수 백오프를 사용하되, 반드시 지터를 섞어 동시 재시도 폭주를 막습니다.
Node.js 예제: 429에만 제한적으로 재시도
아래 예시는 OpenAI SDK를 직접 호출하는 대신, 에러를 분류하고 429일 때만 재시도합니다. 또한 최대 지연을 제한하고, 전체 요청에 타임아웃을 둡니다.
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function parseRetryAfterSeconds(err: any): number | null {
const v = err?.response?.headers?.["retry-after"] ?? err?.headers?.["retry-after"];
if (!v) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function jitteredBackoffMs(attempt: number, baseMs = 250, capMs = 10_000) {
// full jitter: random(0, min(cap, base * 2^attempt))
const max = Math.min(capMs, baseMs * Math.pow(2, attempt));
return Math.floor(Math.random() * max);
}
async function callWithRetry<T>(fn: () => Promise<T>, opts?: { maxRetries?: number }) {
const maxRetries = opts?.maxRetries ?? 6;
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err: any) {
const status = err?.status ?? err?.response?.status;
const is429 = status === 429;
if (!is429) throw err;
if (attempt >= maxRetries) throw err;
const ra = parseRetryAfterSeconds(err);
const waitMs = ra != null ? ra * 1000 : jitteredBackoffMs(attempt);
await sleep(waitMs);
}
}
}
export async function generateText(prompt: string) {
return callWithRetry(async () => {
return client.responses.create({
model: "gpt-4.1-mini",
input: prompt,
});
});
}
핵심은 다음입니다.
429에만 재시도한다(다른 에러는 즉시 실패 처리 또는 별도 정책)Retry-After가 있으면 우선 적용한다- 지수 백오프에 지터를 적용한다
- 무한 재시도는 금지한다
하지만 이것만으로는 부족합니다. 재시도는 "이미 큐가 없고" "순간 버스트"일 때만 효과적입니다. 지속적인 부하 상황에서는 큐잉이 필요합니다.
기본기 2: 서버 내부 큐잉으로 호출을 평탄화
429를 줄이는 가장 확실한 방법은, OpenAI 호출을 "즉시 실행"이 아니라 "큐에 넣고 일정 속도로 처리"하는 구조로 바꾸는 것입니다.
큐잉이 필요한 신호
- 사용자 요청이 동시에 몰릴 수 있다
- 배치 작업이 정해진 시간에 한꺼번에 돈다
- 워커 수를 늘리면 성공률이 오히려 떨어진다
429가 특정 시간대에 연속 발생한다
설계 원칙
- 동시성 제한: 동시에 실행되는 OpenAI 호출 개수 제한
- 레이트 제한: 초당 요청 수 또는 분당 요청 수를 제한
- 토큰 제한: 토큰 소비량을 추정해 분당 토큰을 넘지 않게 제한
- 우선순위: 사용자 인터랙션이 배치보다 먼저 처리되도록 분리
동시성 제한의 최소 구현: 세마포어
가장 먼저 할 일은 "동시에 몇 개까지"를 제한하는 것입니다. 이는 RPS를 직접 맞추는 것보다 단순하지만, 폭주를 크게 줄입니다.
class Semaphore {
private available: number;
private waiters: Array<() => void> = [];
constructor(private capacity: number) {
this.available = capacity;
}
async acquire() {
if (this.available > 0) {
this.available -= 1;
return;
}
await new Promise<void>((resolve) => this.waiters.push(resolve));
}
release() {
const w = this.waiters.shift();
if (w) w();
else this.available = Math.min(this.capacity, this.available + 1);
}
}
const sem = new Semaphore(5); // 동시에 5개만
async function withConcurrencyLimit<T>(fn: () => Promise<T>) {
await sem.acquire();
try {
return await fn();
} finally {
sem.release();
}
}
이제 OpenAI 호출을 다음처럼 감싸면 됩니다.
export async function generateTextLimited(prompt: string) {
return withConcurrencyLimit(() =>
callWithRetry(() =>
client.responses.create({ model: "gpt-4.1-mini", input: prompt })
)
);
}
하지만 동시성만 제한하면 "짧은 시간에 5개씩 계속" 나가므로, 계정의 RPM이나 TPM 제한에 따라 여전히 429가 뜰 수 있습니다. 다음 단계는 레이트 리미터입니다.
요청 수 기반 레이트 리미터: 토큰 버킷
토큰 버킷(token bucket)은 "초당 N개" 같은 속도를 자연스럽게 맞추는 데 적합합니다. 구현체를 직접 만들 수도 있지만, 운영에서는 검증된 라이브러리를 쓰는 편이 안전합니다.
예제: Bottleneck으로 RPS와 동시성 함께 제어
import Bottleneck from "bottleneck";
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
// minTime: 요청 간 최소 간격(ms)
// maxConcurrent: 동시 실행 제한
const limiter = new Bottleneck({
minTime: 120, // 대략 초당 8.3 req
maxConcurrent: 4,
});
async function createResponse(input: string) {
return limiter.schedule(() =>
callWithRetry(() =>
client.responses.create({
model: "gpt-4.1-mini",
input,
})
)
);
}
이 구조의 장점은 다음입니다.
- 트래픽이 몰려도 내부 큐에 쌓이고, 일정 속도로 빠져나간다
- 동시성과 속도를 한 곳에서 관리한다
429가 발생해도 재시도는 limiter 뒤에서 실행되어 폭주가 덜하다
단, 토큰 기반 제한이 더 빡센 계정이나 긴 출력이 많은 서비스라면 TPM을 고려해야 합니다.
토큰 기반 큐잉: "요청"이 아니라 "비용"을 큐잉한다
TPM 제한을 피하려면, 각 요청이 사용할 토큰을 대략 추정하고 분당 예산 안에서 실행되도록 해야 합니다. 완벽한 예측은 어렵지만, 실무에서는 다음 방식이 효과적입니다.
- 입력 토큰: 프롬프트 길이로 근사(토크나이저 사용)
- 출력 토큰:
max_output_tokens같은 상한으로 근사 - 총 토큰 예산:
inputTokens + maxOutputTokens
간단한 TPM 예산 스케줄러(개념 구현)
아래 코드는 "1분 창" 기준으로 토큰 예산을 소비하는 매우 단순한 형태입니다. 실제 운영에서는 슬라이딩 윈도우나 분산 락, Redis 기반 전역 레이트 리미터로 확장하는 것을 권장합니다.
type Job<T> = {
cost: number; // 예상 토큰 비용
run: () => Promise<T>;
resolve: (v: T) => void;
reject: (e: any) => void;
};
class TokenBudgetQueue {
private queue: Job<any>[] = [];
private used = 0;
private windowStart = Date.now();
constructor(private budgetPerMinute: number) {
setInterval(() => this.tick(), 50).unref();
}
private resetWindowIfNeeded() {
const now = Date.now();
if (now - this.windowStart >= 60_000) {
this.windowStart = now;
this.used = 0;
}
}
private async tick() {
this.resetWindowIfNeeded();
const job = this.queue[0];
if (!job) return;
if (this.used + job.cost > this.budgetPerMinute) return;
this.queue.shift();
this.used += job.cost;
try {
const v = await job.run();
job.resolve(v);
} catch (e) {
job.reject(e);
}
}
schedule<T>(cost: number, run: () => Promise<T>) {
return new Promise<T>((resolve, reject) => {
this.queue.push({ cost, run, resolve, reject });
});
}
}
const tokenQueue = new TokenBudgetQueue(120_000); // 분당 120k 토큰 예산(예시)
사용 예시는 다음과 같습니다.
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
function roughTokenEstimate(input: string, maxOutputTokens: number) {
// 매우 러프한 근사: 영어는 4 chars/token 근처, 한국어는 더 보수적으로 잡는 편
const inputTokens = Math.ceil(input.length / 3);
return inputTokens + maxOutputTokens;
}
export async function queuedResponse(input: string) {
const maxOutputTokens = 800;
const cost = roughTokenEstimate(input, maxOutputTokens);
return tokenQueue.schedule(cost, () =>
callWithRetry(() =>
client.responses.create({
model: "gpt-4.1-mini",
input,
max_output_tokens: maxOutputTokens,
})
)
);
}
이렇게 하면 "긴 요청"이 들어왔을 때 짧은 요청들을 다 막아버리는 상황을 줄일 수 있습니다. 더 나아가면, 비용이 큰 작업은 별도 큐로 보내거나, 사용자 플랜별로 예산을 분리할 수도 있습니다.
재시도와 큐잉의 결합: 어디에 두어야 하나
권장 순서는 다음입니다.
- 1단계: 큐(동시성, RPS, TPM)를 통과
- 2단계: 실제 API 호출
- 3단계:
429면 재시도하되, 재시도도 큐의 규칙을 다시 타도록 설계
즉, 재시도 로직이 큐 밖에서 무제한으로 돌면 다시 폭주합니다. 이상적으로는 "재시도도 새로운 작업"으로 큐에 재등록되어야 합니다.
라이브러리를 쓴다면, limiter 안에서 callWithRetry를 실행하거나, callWithRetry 내부에서 재호출 시에도 limiter를 거치게 만들어야 합니다.
우선순위 큐: 사용자 요청을 배치보다 먼저
실서비스에서는 배치 요약, 인덱싱, 임베딩 생성 같은 작업이 백그라운드에서 돌다가 사용자 대화 품질을 떨어뜨리는 일이 흔합니다. 해결책은 간단합니다.
high큐: 사용자 인터랙션low큐: 배치/백필
그리고 high 큐에 항상 처리 슬롯을 우선 배정합니다.
Bottleneck을 쓴다면 limiter를 두 개로 나누고, high limiter가 비어 있을 때만 low를 실행하는 방식으로 구성할 수 있습니다. 더 큰 규모라면 Redis 기반 큐(예: BullMQ)로 분리하고 워커를 분리하는 편이 운영이 쉽습니다.
관측과 튜닝: 지표 없이는 해결이 안 된다
429는 "얼마나" 제한에 걸렸는지, "어느 구간"이 병목인지 모르면 튜닝이 불가능합니다. 최소한 아래를 기록하세요.
429발생률, 연속 발생 구간 길이- 요청 대기 시간(큐에서 대기한 시간)
- 요청 실행 시간(OpenAI API latency)
- 입력 토큰 추정치, 출력 토큰 상한, 실제 사용량(가능하면)
- 재시도 횟수 분포
대기 시간이 길어지면, 단순히 limiter 값을 올리기보다 다음을 먼저 검토합니다.
- 프롬프트를 줄여 토큰 비용을 낮출 수 있는가
- 출력 상한(
max_output_tokens)이 과도하지 않은가 - 동일 입력에 대한 캐시가 가능한가
- 배치 작업이 사용자 트래픽 피크 시간에 겹치지 않는가
실전 체크리스트
429는 순간 버스트인지 지속 과부하인지 먼저 구분한다Retry-After가 있으면 우선 존중한다- 지수 백오프에는 지터를 반드시 넣는다
- 동시성 제한은 가장 먼저 적용한다
- RPS 제한과 TPM 제한을 분리해서 생각한다
- 재시도는 큐 밖에서 돌지 않게 한다
- 사용자 요청과 배치 작업 큐를 분리하고 우선순위를 둔다
- 지표를 수집해 limiter 값을 "감"이 아니라 데이터로 조정한다
마무리
429 Rate Limit은 피할 수 없는 현실이지만, 재시도만으로 해결하려 하면 시스템이 더 불안정해집니다. 재시도는 "짧고 제한적으로", 큐잉은 "상시적으로" 적용해 호출을 평탄화하는 것이 핵심입니다. 동시성 제한과 레이트 리미터를 먼저 깔고, 토큰 예산 기반 큐잉까지 확장하면 트래픽이 커져도 예측 가능한 지연과 안정적인 성공률을 만들 수 있습니다.