- Published on
OpenAI Responses API 429·rate_limit 해결 10가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 환경에서 OpenAI Responses API를 붙이면 가장 먼저 마주치는 장애가 429 와 rate_limit 계열입니다. 단순히 "요청을 줄이세요"로 끝나지 않습니다. 실제로는 요청 수(RPM), 토큰 처리량(TPM), 동시성, 스트리밍 연결 유지, 재시도 폭주(thundering herd), 중복 요청, 캐시 미스가 한꺼번에 얽히면서 429가 터집니다.
이 글은 Responses API 기준으로, 429를 "빨리 잠재우는" 방법이 아니라 재발을 줄이는 구조적 해결 10가지를 정리합니다. Node.js 예제를 중심으로 설명하지만, 원리는 어떤 런타임에서도 동일합니다.
참고: RAG에서 불필요하게 토큰을 많이 쓰면 TPM 한도에 먼저 걸려 429가 더 자주 납니다. 관련 디버깅 체크리스트는 LangChain LlamaIndex RAG에서 답변이 반복되고 환각될 때...도 함께 보세요.
1) 429의 "종류"부터 분리: RPM vs TPM vs 동시성
같은 429 라도 의미가 다릅니다.
- RPM 초과: 분당 요청 수가 많음. 짧은 요청을 많이 쏘는 패턴.
- TPM 초과: 분당 토큰 처리량이 많음. 긴 프롬프트, 긴 출력, RAG 컨텍스트 과다.
- 동시성 제한: 순간적으로 동시에 열린 요청/스트리밍이 많음.
해결책이 달라서, 먼저 로그에 다음을 남기세요.
- 요청 시작 시각과 종료 시각
model, 입력 길이(대략 토큰),max_output_tokens- 스트리밍 여부
- 재시도 횟수
Node에서 최소한의 관측 로깅 예:
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function callOnce(prompt: string) {
const startedAt = Date.now();
const maxOutput = 512;
try {
const res = await client.responses.create({
model: "gpt-4.1-mini",
input: prompt,
max_output_tokens: maxOutput,
});
return res;
} finally {
const ms = Date.now() - startedAt;
console.log(JSON.stringify({
at: new Date().toISOString(),
ms,
promptChars: prompt.length,
maxOutput,
}));
}
}
이 정도만 있어도 "짧은 요청이 너무 많아서"인지, "RAG 컨텍스트가 길어서"인지 감이 잡힙니다.
2) Retry-After 존중 + 지수 백오프 + 지터(jitter)
429에서 가장 흔한 2차 장애가 동시에 재시도 폭주입니다. 모든 워커가 같은 타이밍에 재시도하면 429가 더 커집니다.
원칙:
- 응답 헤더에
Retry-After가 있으면 최우선으로 따르기 - 없으면 지수 백오프 적용
- 반드시 랜덤 지터를 섞기
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function parseRetryAfterSeconds(headers: Headers | any): number | null {
const v = headers?.get?.("retry-after") ?? headers?.["retry-after"];
if (!v) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 6): Promise<T> {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (e: any) {
const status = e?.status ?? e?.response?.status;
if (status !== 429 || attempt >= maxRetries) throw e;
const retryAfter = parseRetryAfterSeconds(e?.response?.headers);
const base = retryAfter != null ? retryAfter * 1000 : Math.min(30_000, 500 * 2 ** attempt);
const jitter = Math.floor(Math.random() * 250);
const waitMs = base + jitter;
await sleep(waitMs);
attempt += 1;
}
}
}
핵심은 "재시도"가 아니라 "재시도 정책"입니다.
3) 클라이언트 측 레이트 리미터(토큰 버킷)로 선제 차단
서버가 429를 주기 전에, 애플리케이션이 자체적으로 초당/분당 요청을 제한해야 합니다.
간단한 방식은 동시성 제한과 큐잉입니다. 예를 들어 p-limit 같은 라이브러리를 쓰거나, 직접 세마포어를 구현합니다.
class Semaphore {
private queue: Array<() => void> = [];
private active = 0;
constructor(private readonly max: number) {}
async acquire() {
if (this.active < this.max) {
this.active += 1;
return;
}
await new Promise<void>((resolve) => this.queue.push(resolve));
this.active += 1;
}
release() {
this.active -= 1;
const next = this.queue.shift();
if (next) next();
}
}
const sem = new Semaphore(5); // 동시 요청 5개로 제한
async function limitedCall(prompt: string) {
await sem.acquire();
try {
return await withRetry(() =>
client.responses.create({
model: "gpt-4.1-mini",
input: prompt,
max_output_tokens: 512,
})
);
} finally {
sem.release();
}
}
동시성 제한만으로도 스트리밍 연결이 많을 때의 429를 크게 줄일 수 있습니다.
4) 요청 병합(dedup)으로 중복 호출 제거
UI에서 같은 질문이 연속으로 발생하거나, 서버에서 같은 작업이 동시에 트리거되는 경우가 많습니다. 이때 동일 키에 대해 하나의 in-flight 요청만 유지하면 429가 급감합니다.
const inflight = new Map<string, Promise<any>>();
async function dedupedCall(key: string, prompt: string) {
const existing = inflight.get(key);
if (existing) return existing;
const p = limitedCall(prompt).finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
// 예: 사용자ID + 정규화된 질문을 키로
특히 검색 기반 RAG에서 "검색 결과가 같으면 답도 거의 같다"면, 키 설계를 통해 중복을 더 공격적으로 제거할 수 있습니다.
5) 캐싱: 응답 캐시와 "부분 캐시"를 분리
캐시는 429 해결에서 가장 강력하지만, 무작정 전체 응답만 캐시하면 적중률이 낮습니다. 아래처럼 나누면 효과가 큽니다.
- 임베딩/검색 결과 캐시: 같은 쿼리에 대한 검색 결과는 자주 재사용됨
- 시스템 프롬프트/툴 스키마 고정: 매번 길게 보내지 말고 최소화
- 최종 답변 캐시: FAQ나 반복 질문에만 적용
간단한 TTL 캐시 예:
type CacheEntry<T> = { value: T; expiresAt: number };
class TTLCache<T> {
private m = new Map<string, CacheEntry<T>>();
constructor(private ttlMs: number) {}
get(key: string): T | null {
const e = this.m.get(key);
if (!e) return null;
if (Date.now() > e.expiresAt) {
this.m.delete(key);
return null;
}
return e.value;
}
set(key: string, value: T) {
this.m.set(key, { value, expiresAt: Date.now() + this.ttlMs });
}
}
const answerCache = new TTLCache<any>(60_000);
async function cachedCall(cacheKey: string, prompt: string) {
const cached = answerCache.get(cacheKey);
if (cached) return cached;
const res = await dedupedCall(cacheKey, prompt);
answerCache.set(cacheKey, res);
return res;
}
캐시를 넣으면 RPM과 TPM을 동시에 줄입니다.
6) 토큰 예산을 강제: max_output_tokens 와 컨텍스트 절제
TPM 기반 429는 "요청 수"를 줄여도 해결이 안 됩니다. 토큰을 줄여야 합니다.
실전 체크:
max_output_tokens를 기본값으로 두지 말고 케이스별로 제한- RAG 컨텍스트를 무작정 많이 넣지 말고 상한 설정
- 긴 대화를 계속 누적하지 말고 요약/스냅샷 전략 사용
function buildPrompt(user: string, docs: string[]) {
const limitedDocs = docs.slice(0, 4); // 상위 4개만
const context = limitedDocs.join("\n\n---\n\n");
return [
"너는 정확한 기술 지원 에이전트다.",
"주어진 컨텍스트 범위에서만 답해라.",
"컨텍스트:",
context,
"질문:",
user,
].join("\n");
}
async function budgetedCall(user: string, docs: string[]) {
const prompt = buildPrompt(user, docs);
return limitedCall(prompt); // 내부에서 max_output_tokens 제한
}
RAG 품질 문제까지 같이 겪고 있다면, 토큰 예산과 청킹/리랭킹을 함께 점검하는 것이 효율적입니다. 위에서 링크한 RAG 체크리스트 글이 이때 도움이 됩니다.
7) 스트리밍 사용 시 "연결 점유"를 고려해 동시성 더 낮추기
스트리밍은 UX를 좋게 하지만, 서버 입장에서는 오래 열려 있는 요청이 됩니다. 즉, 같은 동시성 제한 값이라도 스트리밍이 훨씬 빨리 한도에 도달합니다.
대응:
- 스트리밍 요청은 별도 세마포어로 더 낮은 동시성 적용
- 프론트에서 사용자가 탭을 닫거나 새 질문을 던지면 기존 스트림을 즉시 취소
취소(AbortController) 예:
async function streamingCall(prompt: string, signal: AbortSignal) {
return client.responses.stream({
model: "gpt-4.1-mini",
input: prompt,
max_output_tokens: 512,
}, { signal });
}
const ac = new AbortController();
// 사용자가 새 질문을 입력하면 ac.abort() 호출
"취소"는 429 해결에서 생각보다 큰 효과가 있습니다. 불필요하게 유지되는 스트림을 제거하면 동시성 압박이 줄어듭니다.
8) 배치/집계: 여러 작업을 한 번에 처리하도록 설계
서버 내부에서 작은 요청을 다발로 만들어 호출하는 구조(예: 문장별 요약, 항목별 분류)는 RPM을 폭발시킵니다.
대응:
- 여러 항목을 하나의 프롬프트로 합쳐 처리
- 결과는 JSON으로 구조화해 파싱
const items = ["로그 A", "로그 B", "로그 C"];
const prompt = [
"다음 항목 각각을 한 줄로 요약해 JSON 배열로 반환해라.",
"반환 형식은 반드시 {\"summaries\":[...]} 이어야 한다.",
"항목:",
...items.map((x, i) => `${i + 1}. ${x}`),
].join("\n");
const res = await limitedCall(prompt);
요청 수를 줄이는 가장 직접적인 방법입니다.
9) 워커/서버 수평 확장 시 "전역" 레이트 리밋을 공유
K8s나 서버리스로 스케일 아웃하면, 각 인스턴스가 "자기 기준"으로만 제한해서 전체 트래픽이 한도를 초과할 수 있습니다. 이때는 분산 레이트 리미팅이 필요합니다.
- Redis 기반 토큰 버킷/슬라이딩 윈도우
- 작업 큐(예: BullMQ)로 중앙 집계 후 워커가 소비
개념 스케치(의사 코드):
// Redis에서 "분당 요청 카운터"를 INCR 하고 TTL 60초 설정
// 카운터가 임계값을 넘으면 큐에서 대기
K8s에서 장애를 빠르게 진단하는 관점은 K8s CrashLoopBackOff 원인별 10분 진단법처럼 "증상-원인-측정"으로 쪼개는 방식이 도움이 됩니다. 429도 동일하게 접근해야 재발이 줄어듭니다.
10) 실패를 "에러"가 아니라 "상태"로 다루기: 큐잉과 폴백
특정 기능이 429로 자주 깨지면, 사용자에게는 장애로 보입니다. 하지만 많은 서비스에서 429는 "잠시 밀림"으로 처리할 수 있습니다.
전략:
- 실시간 응답이 꼭 필요 없는 작업은 큐로 넘기고 비동기 처리
- 429가 나면 더 작은 모델로 폴백(품질 저하를 명시)
- 요약/분류 같은 비핵심 기능은 임시 비활성화(서킷 브레이커)
서킷 브레이커의 아주 단순한 형태:
let openUntil = 0;
async function guardedCall(prompt: string) {
if (Date.now() < openUntil) {
throw new Error("rate-limited: circuit open");
}
try {
return await limitedCall(prompt);
} catch (e: any) {
const status = e?.status ?? e?.response?.status;
if (status === 429) {
openUntil = Date.now() + 10_000; // 10초 동안 신규 호출 차단
}
throw e;
}
}
이 패턴은 "한 번 터지면 연쇄적으로 더 터지는" 상황을 막아줍니다.
운영 체크리스트: 429를 줄이는 우선순위
현장에서 빠르게 적용하려면 아래 순서가 효율적입니다.
- 동시성 제한(세마포어)부터 걸기
Retry-After존중 + 지수 백오프 + 지터- 중복 호출 제거(dedup)
- 캐싱(특히 검색/컨텍스트 쪽)
- 토큰 예산 강제(
max_output_tokens, 컨텍스트 상한) - 스트리밍 취소/동시성 분리
- 배치/집계로 RPM 절감
- 분산 환경이면 전역 레이트 리밋 공유
- 큐잉/폴백/서킷 브레이커로 사용자 영향 최소화
마무리
429 rate_limit 은 "API가 불안정"해서가 아니라, 대부분 클라이언트가 한도를 고려하지 않은 채로 호출을 설계했기 때문에 발생합니다. 재시도만 붙이면 더 악화되는 경우가 많고, 동시성 제한과 토큰 예산, 중복 제거, 캐싱을 함께 적용해야 안정화됩니다.
특히 RAG를 운영 중이라면 "정확도" 문제처럼 보이던 이슈가 사실은 "토큰 과다"로 인한 429 연쇄일 때도 많습니다. 토큰 예산과 컨텍스트 전략을 같이 최적화해 보세요.