- Published on
OpenAI 429/RateLimitError 실전 백오프·리트라이
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI API를 붙이다 보면 어느 순간 429(Too Many Requests) 또는 RateLimitError를 마주합니다. 문제는 단순히 “요청을 덜 보내라”로 끝나지 않는다는 점입니다. 트래픽 피크, 배치 작업, 사용자 동시성, 스트리밍 응답, 멀티 인스턴스 확장, 그리고 모델별 토큰 한도까지 겹치면 재현도 어렵고 장애는 간헐적으로 터집니다.
이 글에서는 429가 왜 발생하는지(요청 수 vs 토큰), 무엇을 재시도해야 하고 무엇을 즉시 실패시켜야 하는지, 그리고 지수 백오프 + 지터 + 재시도 예산 + 큐/동시성 제한을 조합해 “서비스가 망가지지 않는” 형태로 설계하는 방법을 정리합니다.
관련해서 트래픽/리소스 병목을 진단하는 방법은 Spring Boot 대용량 트래픽 - HikariCP 풀 고갈 진단 글의 접근(증상-원인-계측-완화)을 함께 참고하면 좋습니다. 또한 LLM 출력 안정화는 RAG 환각을 줄이는 JSON Schema 강제 출력법처럼 “재시도 횟수를 줄이는” 방향으로도 큰 효과가 납니다.
429/RateLimitError의 진짜 의미: 요청 수만의 문제가 아니다
OpenAI 계열 API의 레이트 리밋은 보통 아래 축으로 걸립니다.
- RPM: 분당 요청 수(Requests Per Minute)
- TPM: 분당 토큰 수(Tokens Per Minute)
- (경우에 따라) 동시 처리 제한 또는 계정/프로젝트 단위 제한
즉, 같은 1개의 요청이라도 max_output_tokens가 크거나 입력 프롬프트가 길면 TPM을 더 빨리 소모해서 429가 납니다. 반대로 짧은 요청을 초당 수백 개 보내면 RPM이 먼저 터집니다.
또한 장애 상황에서 흔한 패턴이 있습니다.
- 배치/크론이 정각에 몰려서 요청 폭증
- 프론트에서 다중 클릭/재전송으로 중복 호출
- 워커가 실패를 “즉시 재시도” 하며 스파이크를 더 키움(재시도 폭풍)
- 오토스케일로 인스턴스 수가 늘면서 전체 요청량이 선형 증가(전역 제한을 초과)
따라서 429 대응은 “재시도 로직 추가”가 아니라 부하를 평탄화하고, 실패를 제어된 방식으로 다루는 설계가 핵심입니다.
재시도 가능한 것 vs 즉시 실패시켜야 하는 것
모든 에러를 재시도하면 오히려 상황이 악화됩니다. 아래처럼 분류하는 것이 안전합니다.
재시도 권장
429/RateLimitError408(Request Timeout)5xx(일시적 서버 오류)- 네트워크 단절/일시적 DNS 문제
재시도 비권장(즉시 실패)
400(잘못된 요청, 스키마/파라미터 오류)401/403(키/권한 문제)404(리소스/엔드포인트 오류)- “입력 자체가 잘못돼서” 계속 실패하는 케이스
추가로, 스트리밍 도중 끊긴 경우는 애매합니다. 사용자 UX 관점에서는 재시도 대신 “부분 결과 + 재생성 버튼”이 낫고, 서버 관점에서는 동일 요청을 자동 재시도하면 중복 과금/중복 처리가 생길 수 있습니다.
백오프 설계 핵심 4가지
1) 지수 백오프(Exponential Backoff)
기본 공식은 다음과 같습니다.
delay = base * 2^attempt
예: base=200ms면 200ms, 400ms, 800ms, 1600ms ...
2) 지터(Jitter)는 필수
여러 워커가 동시에 429를 맞으면 모두 같은 타이밍에 재시도해서 다시 429를 맞습니다(동기화 스파이크). 이를 막기 위해 지터를 섞습니다.
- Full jitter:
random(0, base * 2^attempt) - Decorrelated jitter: 이전 딜레이를 기반으로 랜덤화
실무에서는 full jitter가 구현이 단순하고 효과가 좋습니다.
3) Retry-After 헤더를 존중
서버가 Retry-After를 주면 그 값을 우선합니다. 없을 때만 백오프 계산값을 씁니다.
주의: 헤더가 초 단위인지 밀리초 단위인지, 또는 날짜 포맷인지 구현체마다 다를 수 있어 파싱을 방어적으로 해야 합니다.
4) 재시도 예산(Retry Budget)과 상한선
- 최대 재시도 횟수(
maxRetries) - 최대 총 대기 시간(
maxTotalDelayMs) - 요청 단위 타임아웃(예: 30초)
이 3개가 없으면, 장애 시 재시도가 쌓여서 워커 스레드/큐/DB 연결 같은 다른 자원을 고갈시킵니다. 이는 Go gRPC DEADLINE_EXCEEDED 9가지 원인과 처방에서 말하는 “타임아웃 설계”와 동일한 결입니다.
Node.js(Typescript) 실전 리트라이 래퍼 예제
아래 코드는 OpenAI SDK 호출을 감싸는 형태로 작성했습니다. 포인트는 다음입니다.
429및 일시적 오류만 재시도Retry-After우선- full jitter 적용
- 재시도 예산(
maxRetries,maxTotalDelayMs) 적용 - 로깅/메트릭 훅을 넣기 쉬운 구조
type RetryOptions = {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
maxTotalDelayMs: number;
};
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function parseRetryAfterMs(headers: Headers | Record<string, string> | undefined): number | null {
if (!headers) return null;
const get = (k: string) => {
if (typeof (headers as any).get === "function") return (headers as any).get(k);
const obj = headers as Record<string, string>;
const key = Object.keys(obj).find((x) => x.toLowerCase() === k.toLowerCase());
return key ? obj[key] : undefined;
};
const v = get("retry-after");
if (!v) return null;
// 1) seconds
const sec = Number(v);
if (Number.isFinite(sec)) return Math.max(0, Math.floor(sec * 1000));
// 2) HTTP date
const dt = Date.parse(v);
if (!Number.isNaN(dt)) return Math.max(0, dt - Date.now());
return null;
}
function isRetryableStatus(status: number) {
if (status === 429) return true;
if (status === 408) return true;
if (status >= 500 && status <= 599) return true;
return false;
}
function computeFullJitterDelayMs(baseDelayMs: number, maxDelayMs: number, attempt: number) {
const cap = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
return Math.floor(Math.random() * cap);
}
export async function withRetry<T>(
fn: () => Promise<T>,
opts: RetryOptions,
onRetry?: (info: { attempt: number; delayMs: number; reason: string }) => void
): Promise<T> {
const startedAt = Date.now();
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err: any) {
const status: number | undefined = err?.status ?? err?.response?.status;
const headers = err?.headers ?? err?.response?.headers;
const retryAfterMs = parseRetryAfterMs(headers);
const retryable = typeof status === "number" ? isRetryableStatus(status) : false;
if (!retryable) throw err;
if (attempt >= opts.maxRetries) throw err;
const computed = computeFullJitterDelayMs(opts.baseDelayMs, opts.maxDelayMs, attempt);
const delayMs = retryAfterMs !== null ? Math.min(opts.maxDelayMs, retryAfterMs) : computed;
const elapsed = Date.now() - startedAt;
if (elapsed + delayMs > opts.maxTotalDelayMs) throw err;
onRetry?.({
attempt,
delayMs,
reason: `status=${status}${retryAfterMs !== null ? ", retry-after" : ""}`,
});
await sleep(delayMs);
}
}
}
사용 예시는 다음과 같습니다.
import OpenAI from "openai";
import { withRetry } from "./withRetry";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
async function run() {
const res = await withRetry(
async () => {
return await client.responses.create({
model: "gpt-4.1-mini",
input: "요약해줘: ...",
max_output_tokens: 400,
});
},
{
maxRetries: 6,
baseDelayMs: 200,
maxDelayMs: 8000,
maxTotalDelayMs: 20000,
},
({ attempt, delayMs, reason }) => {
console.warn("retry", { attempt, delayMs, reason });
}
);
console.log(res.output_text);
}
run().catch(console.error);
백오프만으로는 부족한 경우: 동시성 제한과 큐잉
재시도는 “실패 후”의 대응입니다. 하지만 실제로 429를 줄이려면 요청을 보내기 전에 모양을 잡아야 합니다.
1) 프로세스 내부 동시성 제한
Node 서버 한 인스턴스에서 동시에 OpenAI 호출을 N개만 허용하면, 순간 스파이크를 크게 줄일 수 있습니다.
class Semaphore {
private available: number;
private queue: Array<() => void> = [];
constructor(count: number) {
this.available = count;
}
async acquire() {
if (this.available > 0) {
this.available -= 1;
return;
}
await new Promise<void>((resolve) => this.queue.push(resolve));
}
release() {
this.available += 1;
const next = this.queue.shift();
if (next) {
this.available -= 1;
next();
}
}
}
const sem = new Semaphore(8);
export async function limited<T>(fn: () => Promise<T>) {
await sem.acquire();
try {
return await fn();
} finally {
sem.release();
}
}
limited와 withRetry를 조합하면 “동시성으로 인한 자폭”을 상당히 막을 수 있습니다.
const res = await limited(() => withRetry(() => client.responses.create({
model: "gpt-4.1-mini",
input: "...",
}), {
maxRetries: 6,
baseDelayMs: 200,
maxDelayMs: 8000,
maxTotalDelayMs: 20000,
}));
2) 멀티 인스턴스에서는 전역 제한이 필요
오토스케일링 환경(예: Kubernetes, Cloud Run)에서는 인스턴스별로 동시성 제한을 걸어도 전체 요청량이 전역 한도를 초과할 수 있습니다.
이때는 다음 중 하나가 필요합니다.
- 중앙 큐(예: SQS, RabbitMQ, Redis Streams)로 작업을 모은 뒤 워커가 일정 속도로 소비
- Redis 기반 토큰 버킷/리키 버킷으로 전역 RPM/TPM을 근사 제어
- “사용자별/조직별” 쿼터를 애플리케이션 레벨에서 분리
특히 Cloud Run처럼 순간 확장이 빠른 환경에서는 429가 “콜드 스타트”와 섞여 증상이 복잡해지기도 합니다. 이 경우 GCP Cloud Run 503·Cold Start 원인과 튜닝에서 말하는 동시성/인스턴스 상한 전략과 함께 봐야 합니다.
토큰 관점 최적화: 429를 “근본적으로” 줄이는 방법
429를 줄이는 가장 확실한 방법은 TPM 소비를 줄이는 것입니다.
- 입력 프롬프트를 짧게: 불필요한 로그/HTML/중복 컨텍스트 제거
- 출력 상한 설정:
max_output_tokens를 과도하게 크게 잡지 않기 - 구조화 출력으로 재시도 감소: JSON Schema 강제 등으로 “형식 오류로 인한 재시도”를 없애기
- 캐시: 동일 입력/동일 시스템 프롬프트 조합 결과 캐싱(특히 임베딩/요약)
또한 스트리밍을 쓰는 경우, 사용자가 중간에 이탈하면 서버는 이미 토큰을 소비했을 수 있습니다. UX와 과금/부하를 함께 고려해 “짧은 초안 먼저, 필요 시 확장” 같은 단계적 생성 전략도 효과적입니다.
장애 대응 체크리스트(운영 관점)
백오프 코드를 넣었는데도 429가 계속 난다면, 아래를 순서대로 점검하는 것이 좋습니다.
- 에러 비율과 분포: 특정 모델/엔드포인트/테넌트에 집중되는가
- RPM vs TPM 어느 쪽이 병목인지: 긴 프롬프트/큰 출력이 원인인지
- 재시도 폭풍 여부: 같은 요청이 여러 번 중복 실행되는지(아이템포턴시 키/요청 ID 필요)
- 동시성 제한 유무: 인스턴스 내부, 전역 각각의 제한이 있는지
- 큐 적체: 큐 길이, 처리율, 워커 수, 재시도 메시지의 지연
- 타임아웃: 클라이언트 타임아웃이 너무 짧아 재시도를 유발하는지
특히 “중복 실행”은 레이트 리밋을 악화시키는 대표적인 숨은 원인입니다. 분산 환경에서 중복 실행을 제어하는 감각은 MSA 사가(Saga) 패턴 - 중복 실행·보상처리 버그 해결에서 다루는 아이디어(중복 방지 키, 상태 전이, 보상 처리)와 유사합니다.
결론: 안전한 기본 조합
실전에서 가장 재현성 있게 효과를 내는 조합은 다음입니다.
429/일시적 오류만 선별 재시도- 지수 백오프 + full jitter
Retry-After우선 적용maxRetries와maxTotalDelayMs로 재시도 예산 설정- 인스턴스 내부 동시성 제한
- 멀티 인스턴스면 전역 큐/레이트 리미터 도입
이렇게 하면 429가 “가끔 나는 에러”가 아니라, 서비스가 감당 가능한 범위에서 흡수되는 “제어된 이벤트”가 됩니다.