- Published on
OpenAI 429 Rate Limit 해결 - 백오프·큐·배치
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
OpenAI API를 붙인 서비스가 트래픽을 받기 시작하면, 어느 순간부터 429 Too Many Requests가 간헐적으로 터지기 시작합니다. 문제는 단순히 “요청을 천천히 보내라”로 끝나지 않는다는 점입니다. 프런트/백엔드 동시성, 스트리밍, 재시도 폭주, 토큰 사용량, 멀티 테넌시까지 얽히면 429는 금방 장애로 커집니다.
이 글에서는 429를 원인별로 분해하고, 실무에서 가장 효과가 큰 3가지 축인 백오프(Backoff), 큐(Queue) 기반 스로틀링, 배치(Batch) 처리로 안정성을 끌어올리는 방법을 코드와 함께 정리합니다.
429 Rate Limit의 정체: “요청 수”만이 아니다
429는 보통 다음 중 하나(또는 조합)로 발생합니다.
- 요청 레이트 제한: 분당 요청 수(RPM), 초당 요청 수(RPS)
- 토큰 레이트 제한: 분당 토큰 수(TPM). 입력+출력 토큰이 모두 포함됩니다.
- 동시성 제한: 동시에 처리 가능한 요청 수(특히 스트리밍/긴 응답에서 체감)
- 조직/프로젝트 단위 제한: 여러 서비스가 같은 키/프로젝트를 공유하면 합산되어 터짐
- 재시도 폭주: 429가 나자마자 모두가 즉시 재시도하면 “재시도 폭풍(thundering herd)”로 더 악화
즉, 429 대응은 “재시도”만 넣는 게 아니라 시스템 레벨에서 부하를 평탄화하는 쪽으로 설계해야 합니다.
1) 백오프: 지수 백오프 + 지터는 필수
가장 먼저 해야 할 일은 재시도 정책을 **지수 백오프(exponential backoff)**로 바꾸고, 반드시 **지터(jitter)**를 넣는 것입니다. 지터가 없으면 여러 워커가 같은 타이밍에 다시 몰려 429가 지속됩니다.
권장 정책
- 429, 500, 502, 503, 504 등 일시 장애에만 재시도
- 최대 재시도 횟수 제한(예: 5회)
- 지수 백오프(예: 0.5s, 1s, 2s, 4s...) + 랜덤 지터
Retry-After헤더가 있으면 우선 적용- 요청 단위 타임아웃(예: 30초)과 전체 작업 타임아웃(예: 2분)을 분리
Node.js 예시(지수 백오프 + 지터 + Retry-After)
아래 코드는 OpenAI SDK를 쓰든, fetch를 쓰든 동일한 패턴으로 적용 가능합니다.
type RetryableError = {
status?: number;
headers?: Record<string, string>;
message?: string;
};
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function parseRetryAfterMs(headers?: Record<string, string>) {
const v = headers?.["retry-after"];
if (!v) return null;
const sec = Number(v);
if (Number.isFinite(sec)) return Math.max(0, sec * 1000);
return null;
}
function isRetryable(status?: number) {
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
}
export async function withBackoff<T>(
fn: () => Promise<T>,
opts?: { maxRetries?: number; baseDelayMs?: number; maxDelayMs?: number }
): Promise<T> {
const maxRetries = opts?.maxRetries ?? 5;
const baseDelayMs = opts?.baseDelayMs ?? 500;
const maxDelayMs = opts?.maxDelayMs ?? 10_000;
let attempt = 0;
while (true) {
try {
return await fn();
} catch (e: any) {
const err: RetryableError = {
status: e?.status ?? e?.response?.status,
headers: e?.headers ?? e?.response?.headers,
message: e?.message,
};
if (!isRetryable(err.status) || attempt >= maxRetries) {
throw e;
}
const retryAfterMs = parseRetryAfterMs(err.headers);
const exp = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
const jitter = Math.floor(Math.random() * 250); // 0~249ms
const delay = retryAfterMs ?? exp + jitter;
attempt += 1;
await sleep(delay);
}
}
}
백오프만으로 부족한 경우
백오프는 “이미 터진 상황”을 완화할 뿐, 동시성 폭주를 막지 못합니다. 특히 다음 조건이면 백오프만으로는 429가 계속 납니다.
- 사용자 요청이 몰릴 때 서버가 요청을 무제한으로 동시 실행
- 스트리밍 응답이 길어 커넥션이 오래 점유
- 여러 인스턴스가 동일 키를 공유하면서 각각 재시도
이때 필요한 게 큐 기반 스로틀링입니다.
2) 큐: 중앙에서 동시성과 레이트를 “강제”한다
큐는 429 대응에서 가장 확실한 방법입니다. 핵심은 “요청을 받는 속도”와 “OpenAI로 나가는 속도”를 분리해, 외부 트래픽 스파이크를 내부에서 평탄화하는 것입니다.
큐 설계 체크리스트
- 동시성 제한: 워커가 동시에 처리할 작업 수(예: 5)
- 레이트 제한: 초당/분당 작업 수(예: 초당 2개)
- 토큰 기반 제한: 작업마다 예상 토큰을 추정해서 TPM을 넘지 않게
- 테넌트별 공정성: 특정 고객이 큐를 독점하지 않게(가중치/라운드로빈)
- 중복 제거/멱등성: 동일 요청이 중복 enqueue 되지 않게
간단한 인메모리 큐(단일 인스턴스용)
프로덕션에서는 Redis 기반 큐(BullMQ 등)를 권장하지만, 개념을 이해하기 위한 최소 구현입니다.
type Job<T> = {
run: () => Promise<T>;
resolve: (v: T) => void;
reject: (e: unknown) => void;
};
export class RateLimitedQueue {
private concurrency: number;
private intervalMs: number;
private running = 0;
private q: Job<any>[] = [];
private lastStartAt = 0;
constructor(opts: { concurrency: number; intervalMs: number }) {
this.concurrency = opts.concurrency;
this.intervalMs = opts.intervalMs;
}
add<T>(run: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.q.push({ run, resolve, reject });
this.pump();
});
}
private async pump() {
if (this.running >= this.concurrency) return;
const job = this.q.shift();
if (!job) return;
const now = Date.now();
const wait = Math.max(0, this.intervalMs - (now - this.lastStartAt));
if (wait > 0) {
this.q.unshift(job);
setTimeout(() => this.pump(), wait);
return;
}
this.lastStartAt = Date.now();
this.running += 1;
try {
const v = await job.run();
job.resolve(v);
} catch (e) {
job.reject(e);
} finally {
this.running -= 1;
this.pump();
}
}
}
사용 예시는 다음과 같습니다.
import { RateLimitedQueue } from "./RateLimitedQueue";
import { withBackoff } from "./withBackoff";
const queue = new RateLimitedQueue({ concurrency: 3, intervalMs: 400 });
async function callOpenAI(payload: any) {
return withBackoff(async () => {
// 여기에 OpenAI 호출 로직
// 예: await client.responses.create(payload)
return { ok: true };
});
}
export function enqueueOpenAI(payload: any) {
return queue.add(() => callOpenAI(payload));
}
멀티 인스턴스라면 Redis 큐로
서버가 2대 이상이면 인메모리 큐는 의미가 줄어듭니다. 인스턴스별로 각각 큐가 돌아가면 제한을 공유하지 못해 다시 429가 납니다. 이 경우 Redis 기반 큐(예: BullMQ)와 중앙 레이트리미터(토큰 버킷)를 붙여야 합니다.
운영 환경에서 “큐 워커가 밀려서 Pod가 Pending” 같은 리소스 이슈도 종종 같이 터집니다. 쿠버네티스에서 워커 수를 늘리다 CPU가 부족하면 처리량이 오히려 감소하니, 필요하면 EKS Pod Pending(Insufficient cpu) 원인과 해결도 함께 점검하세요.
3) 배치: 호출 횟수 자체를 줄이는 가장 강력한 방법
429를 줄이는 가장 확실한 방법은 “재시도 잘하기”가 아니라 API 호출 횟수 자체를 줄이는 것입니다. 배치는 다음 상황에서 특히 효과가 큽니다.
- 짧은 요청을 대량으로 처리(요약, 분류, 태깅)
- 오프라인/비동기 작업(백필, 리포트 생성)
- 사용자 응답이 즉시 필요하지 않은 작업
배치 전략 3가지
- 요청 합치기: 여러 문서를 한 번에 모델 입력으로 묶고, 출력 형식을 JSON으로 강제
- 작업 큐에서 마이크로 배칭: 예를 들어 100ms 동안 들어온 작업을 모아 10개 단위로 처리
- 중복 제거/캐시: 같은 입력은 해시 키로 캐시해 재호출을 방지
Python 마이크로 배칭 예시
아래는 “짧은 시간 창 동안 요청을 모아 한 번에 처리”하는 매우 단순한 형태입니다.
import asyncio
import time
from typing import Any, List, Tuple
class MicroBatcher:
def __init__(self, max_batch_size: int = 10, max_wait_ms: int = 80):
self.max_batch_size = max_batch_size
self.max_wait_ms = max_wait_ms
self.buffer: List[Tuple[Any, asyncio.Future]] = []
self.lock = asyncio.Lock()
async def add(self, item: Any):
fut = asyncio.get_event_loop().create_future()
async with self.lock:
self.buffer.append((item, fut))
if len(self.buffer) == 1:
asyncio.create_task(self._flush_soon())
if len(self.buffer) >= self.max_batch_size:
asyncio.create_task(self._flush())
return await fut
async def _flush_soon(self):
await asyncio.sleep(self.max_wait_ms / 1000)
await self._flush()
async def _flush(self):
async with self.lock:
if not self.buffer:
return
batch = self.buffer[: self.max_batch_size]
self.buffer = self.buffer[self.max_batch_size :]
items = [x for x, _ in batch]
futures = [f for _, f in batch]
try:
# 여기에 OpenAI 호출을 1번만 수행하도록 구성
# 예: responses.create(input=items를 묶어 전달)
results = ["ok" for _ in items]
for fut, r in zip(futures, results):
fut.set_result(r)
except Exception as e:
for fut in futures:
fut.set_exception(e)
# 남은 버퍼가 있으면 계속 플러시
if self.buffer:
asyncio.create_task(self._flush())
배치를 적용하면 RPM 자체가 내려가고, 동일한 TPM이라도 “요청 오버헤드”가 줄어 체감 안정성이 크게 좋아집니다.
토큰 관점에서의 429 줄이기: 입력을 줄이면 TPM이 내려간다
TPM 제한에 걸리는 429는 백오프/큐만으로는 “지연”만 늘고 근본 해결이 안 되는 경우가 많습니다. 이때는 토큰을 줄여야 합니다.
- 시스템 프롬프트를 짧게 유지하고 중복 문구 제거
- RAG라면 상위 문서 수를 줄이거나, 문서 chunk 크기/중복을 조정
- JSON 출력 스키마를 간결하게(불필요한 필드 제거)
- 모델 응답 길이 제한(
max_output_tokens)을 현실적으로 설정
RAG에서 검색 결과가 불필요하게 길어져 토큰이 폭증하는 문제는 자주 발생합니다. 검색 품질과 함께 토큰 비용도 같이 최적화하려면 PostgreSQL pgvector RAG 검색 품질 급락 원인과 해결 체크리스트를 같이 참고하면 도움이 됩니다.
운영에서 자주 놓치는 포인트 6가지
1) 재시도는 “서버 내부”에서만 통제
클라이언트(브라우저/모바일)에서 429를 보고 즉시 재시도하게 만들면, 사용자 수만큼 재시도 폭풍이 생깁니다. 가능하면 서버에서만 재시도하고, 클라이언트에는 202(accepted) + 폴링 또는 SSE로 상태를 전달하는 방식이 안전합니다.
2) 타임아웃과 큐 적체를 분리해 관측
- API 호출 타임아웃
- 큐 대기 시간
- 워커 처리 시간
이 3가지를 분리해서 메트릭으로 봐야 “느린 모델”인지 “큐가 막힌 것”인지 구분됩니다.
3) 멱등 키로 중복 요청 제거
예를 들어 userId + promptHash 조합으로 멱등 키를 만들고, 동일 키 작업이 이미 실행 중이면 기존 작업 결과를 공유하세요. 이 한 가지로 호출량이 크게 줄어드는 서비스가 많습니다.
4) 스트리밍은 동시성 제한을 더 빡빡하게
스트리밍은 커넥션을 오래 물고 있어 동시성 제한에 더 민감합니다. 스트리밍 엔드포인트는 별도 큐/별도 제한으로 분리하는 편이 운영이 쉽습니다.
5) 키/프로젝트 분리 전략
- 개발/스테이징/프로덕션 분리
- 백오피스 배치 작업 키와 실시간 사용자 트래픽 키 분리
한쪽이 폭주해도 다른 쪽을 보호할 수 있습니다.
6) “429가 안 나게”가 목표가 아니다
목표는 429를 0으로 만드는 게 아니라, 429가 나도 사용자 경험과 시스템 안정성이 무너지지 않게 만드는 것입니다. 즉, 큐로 흡수하고, 백오프로 완화하고, 배치로 총량을 줄이는 3단 구성이 가장 현실적입니다.
추천 아키텍처: 실시간과 배치를 분리한 3계층
- API 서버: 요청 검증, 멱등 키 생성, 작업 enqueue
- 큐: 테넌트별 공정성, 레이트/동시성 제한
- 워커: OpenAI 호출(백오프 포함), 결과 저장, 이벤트 발행
실시간 응답이 꼭 필요하면 “짧은 작업만 동기 처리”하고, 일정 시간(예: 2초) 넘으면 비동기로 전환하는 하이브리드도 좋습니다.
결론
OpenAI 429는 단순 에러가 아니라, 시스템이 “외부 제한을 내부 트래픽 모델에 맞게 흡수하지 못한다”는 신호입니다.
- 백오프로 재시도 폭풍을 막고
- 큐로 동시성과 레이트를 강제하며
- 배치로 호출 횟수 자체를 줄이면
429는 장애가 아니라 “일시적 지연” 수준으로 통제 가능합니다. 다음 단계로는 테넌트별 레이트리미팅, 토큰 예산 기반 스케줄링, 관측(큐 대기 시간/TPM 추정)까지 붙여 운영 완성도를 높이세요.