- Published on
OpenAI 429/RateLimitError 재시도·백오프·큐 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI API를 호출하다 보면 가장 흔하게 마주치는 장애가 429 Too Many Requests(혹은 SDK 예외로 RateLimitError)입니다. 많은 팀이 “재시도 몇 번 걸면 되겠지”로 시작하지만, 트래픽이 조금만 커져도 재시도 폭풍(retry storm)으로 더 빨리 한도를 소진하고, 사용자 지연이 늘고, 결국 다운스트림까지 연쇄 장애가 납니다.
이 글에서는 429가 왜 발생하는지(요청 수/토큰/동시성/버스트), 어떤 재시도 정책이 안전한지(지수 백오프 + 지터 + Retry-After), 그리고 큐(Queue)로 호출을 평탄화(smoothing) 해서 “429가 나도 서비스는 안정적으로” 운영하는 패턴을 정리합니다.
> 비슷한 결의 글로, 과부하 응답에 대한 재시도·백오프 설계는 Claude API 529 Overloaded 재시도·백오프 설계도 함께 보면 좋습니다. 또한 재시도로 인해 타임아웃이 누적되면 gRPC나 내부 호출에서 지연이 폭증할 수 있는데, 이런 경우 EKS에서 gRPC DEADLINE_EXCEEDED 폭증 해결 같은 관점(타임아웃/리트라이 상호작용)도 참고할 만합니다.
429/RateLimitError의 실체: “요청 수”만의 문제가 아니다
OpenAI의 레이트리밋은 보통 아래 축이 함께 걸립니다(계정/프로젝트/모델/조직 정책에 따라 다름).
- RPM (Requests Per Minute): 분당 요청 수
- TPM (Tokens Per Minute): 분당 토큰 사용량(입력+출력)
- 동시성(Concurrency): 동시에 처리 가능한 인플라이트 요청 수(명시/암묵)
- 버스트(Burst): 짧은 시간에 몰리는 스파이크
따라서 “요청을 1초에 10개로 제한했는데도 429가 난다”는 상황이 흔합니다. 이유는 다음 중 하나일 가능성이 큽니다.
- TPM이 먼저 찼다: 프롬프트가 길거나,
max_output_tokens가 크거나, 스트리밍에서 출력이 많이 나오는 경우 - 동시성 제한에 걸렸다: 한 번에 너무 많은 요청을 동시에 날림
- 재시도 자체가 트래픽을 증폭: 실패한 요청이 일정 간격으로 다시 몰리며 RPM/TPM을 더 빨리 소진
- 다중 워커/파드가 각자 제한: 각 인스턴스가 “자기 기준”으로만 제한하면 전체 합이 한도를 넘음(분산 레이트리밋 문제)
핵심은 429를 “에러”로만 보지 말고, 시스템이 현재 처리 가능한 속도를 초과했다는 신호로 봐야 한다는 점입니다.
재시도 기본기: Retry-After + 지수 백오프 + 지터
429 대응에서 가장 먼저 해야 할 일은 “무조건 즉시 재시도”를 금지하는 것입니다. 안전한 재시도는 아래 3요소를 갖습니다.
- Retry-After 존중: 응답 헤더에
Retry-After(초)가 있으면 최우선으로 따름 - 지수 백오프(Exponential Backoff): 재시도 간격을 기하급수적으로 늘림
- 지터(Jitter): 여러 요청이 같은 시점에 재시도되는 동기화(thundering herd)를 방지
(Node.js/TypeScript) 429 전용 재시도 래퍼
아래 예시는 OpenAI 호출을 감싸는 재시도 유틸입니다. 포인트는 다음입니다.
- 429/일시적 네트워크 오류만 재시도
Retry-After가 있으면 우선 적용- 백오프에 지터를 섞음
- 전체 작업에 상한(최대 시도 횟수, 최대 대기 시간)을 둠
// retry.ts
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function parseRetryAfterSeconds(headers: Headers | Record<string, string> | undefined) {
if (!headers) return undefined;
// fetch Headers
// @ts-ignore
const v = typeof (headers as any).get === "function"
? (headers as any).get("retry-after")
: (headers as any)["retry-after"] || (headers as any)["Retry-After"];
if (!v) return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
}
function fullJitter(baseMs: number, capMs: number) {
// AWS Architecture Blog에서 자주 쓰는 Full Jitter 형태
const temp = Math.min(capMs, baseMs);
return Math.floor(Math.random() * temp);
}
export async function withRateLimitRetry<T>(
fn: () => Promise<T>,
opts?: {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
maxTotalDelayMs?: number;
// OpenAI SDK 에러 형태를 정확히 모를 때를 대비해 판별 훅 제공
isRetryable?: (e: any) => boolean;
getHeaders?: (e: any) => any; // e.response?.headers 등
}
): Promise<T> {
const maxAttempts = opts?.maxAttempts ?? 6;
const baseDelayMs = opts?.baseDelayMs ?? 500;
const maxDelayMs = opts?.maxDelayMs ?? 20_000;
const maxTotalDelayMs = opts?.maxTotalDelayMs ?? 60_000;
let attempt = 0;
let totalDelay = 0;
while (true) {
try {
return await fn();
} catch (e: any) {
attempt++;
const status = e?.status ?? e?.response?.status;
const retryableByStatus = status === 429 || (status >= 500 && status <= 599);
const retryable = opts?.isRetryable ? opts.isRetryable(e) : retryableByStatus;
if (!retryable || attempt >= maxAttempts) throw e;
const headers = opts?.getHeaders ? opts.getHeaders(e) : (e?.response?.headers ?? e?.headers);
const ra = parseRetryAfterSeconds(headers);
// 지수 백오프: base * 2^(attempt-1)
const exp = baseDelayMs * Math.pow(2, attempt - 1);
const backoffMs = ra != null ? ra * 1000 : fullJitter(exp, maxDelayMs);
totalDelay += backoffMs;
if (totalDelay > maxTotalDelayMs) throw e;
await sleep(backoffMs);
}
}
}
이 정도만 해도 “429가 떴을 때 즉시 재시도해서 더 큰 429를 만든다”는 최악의 패턴은 피할 수 있습니다.
하지만 여기까지는 증상 완화에 가깝습니다. 트래픽이 계속 한도를 넘는 구조라면 재시도는 지연만 늘릴 뿐입니다. 다음 단계는 큐로 평탄화하는 것입니다.
큐(Queue)로 429를 ‘흡수’하기: 버퍼링과 평탄화
429는 “지금은 처리 못하니 나중에 다시 와라”에 가깝습니다. 그렇다면 애플리케이션은 두 가지 중 하나를 선택해야 합니다.
- (A) 사용자 요청을 실패 처리하고 “잠시 후 재시도”를 사용자에게 넘김
- (B) 서버가 대기열을 만들고, 가능한 속도로 순차 처리(서버가 재시도를 흡수)
대부분의 프로덕션에서는 (B)가 필요합니다. 특히 배치/비동기 작업, 챗봇의 백그라운드 요약/분류, 문서 임베딩 생성 같은 워크로드는 큐가 정답에 가깝습니다.
큐를 도입하면 얻는 효과:
- 버스트 트래픽을 큐에 적재해 다운스트림(OpenAI)을 보호
- 워커 수를 조절해 동시성 제한을 강제
- 429가 나도 워커가 백오프하며 처리하므로 사용자 API는 빠르게 응답(accepted) 가능
큐 설계 체크리스트
- 작업 단위 정의: 프롬프트/입력/모델/파라미터를 포함한 “한 번의 호출”
- 멱등성 키(idempotency key): 같은 작업이 중복 처리되지 않도록 키를 둠(특히 재시도/워커 재시작 시)
- 가시성 타임아웃(visibility timeout): 워커가 작업을 가져간 뒤 죽으면 재처리
- DLQ(Dead Letter Queue): 영구 실패(4xx 중 정책상 재시도 불가 등)는 격리
- 백프레셔(backpressure): 큐 길이가 임계치를 넘으면 신규 요청을 제한하거나 degrade
멱등성과 중복처리는 분산 시스템에서 매우 흔한 함정입니다. 이벤트/메시지 중복을 다룰 때의 사고방식은 Kafka Exactly-Once 깨질 때 중복처리 방지 전략에서 다룬 전략과 유사합니다.
(실전) BullMQ로 “OpenAI 호출 워커” 만들기
Redis 기반 BullMQ는 Node 생태계에서 큐/워커를 빠르게 구성하기 좋습니다. 아래는 전형적인 구조입니다.
- API 서버: 요청을 받으면 큐에 작업을 넣고
202 Accepted로 빠르게 응답 - 워커: 큐에서 작업을 꺼내 OpenAI 호출, 429면 백오프 재시도
1) 작업 enqueue (API 서버)
// queue.ts
import { Queue } from "bullmq";
export const openaiQueue = new Queue("openai-jobs", {
connection: {
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT ?? 6379),
},
});
// api.ts (Express 예시)
import express from "express";
import crypto from "crypto";
import { openaiQueue } from "./queue";
const app = express();
app.use(express.json());
app.post("/summaries", async (req, res) => {
const { text } = req.body as { text: string };
// 멱등성 키: 동일 텍스트 요약 요청이 반복될 때 중복 작업 방지에 활용 가능
const idempotencyKey = crypto.createHash("sha256").update(text).digest("hex");
const job = await openaiQueue.add(
"summarize",
{ text, idempotencyKey },
{
jobId: idempotencyKey, // BullMQ 레벨에서 중복 add 방지
removeOnComplete: 1000,
removeOnFail: 1000,
}
);
res.status(202).json({ jobId: job.id });
});
app.listen(3000);
2) 워커에서 처리 + 429 백오프
// worker.ts
import { Worker } from "bullmq";
import OpenAI from "openai";
import { withRateLimitRetry } from "./retry";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
const worker = new Worker(
"openai-jobs",
async (job) => {
const { text } = job.data as { text: string; idempotencyKey: string };
// 핵심: 워커 동시성(concurrency)을 제한해 동시 인플라이트를 통제
const result = await withRateLimitRetry(
async () => {
const resp = await client.responses.create({
model: "gpt-4.1-mini",
input: `다음 텍스트를 5줄로 요약해줘:\n\n${text}`,
});
return resp;
},
{
maxAttempts: 8,
baseDelayMs: 800,
maxDelayMs: 30_000,
maxTotalDelayMs: 120_000,
getHeaders: (e) => e?.response?.headers,
}
);
// TODO: DB에 저장하거나, 콜백/웹훅/폴링으로 사용자에게 전달
return { output: result.output_text };
},
{
connection: {
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT ?? 6379),
},
concurrency: 3, // 매우 중요: 여기서 동시성 상한을 박아야 함
}
);
worker.on("failed", (job, err) => {
console.error("job failed", job?.id, err);
});
이 구조의 장점은 명확합니다.
- API 서버는 OpenAI 지연/429에 덜 영향 받음
- 워커가 동시성을 통제하므로 레이트리밋을 구조적으로 준수
- 429가 나도 워커가 백오프하며 진행(큐가 완충)
단점도 있습니다.
- 사용자에게 즉시 결과를 못 주면 폴링/웹훅/UI 상태 관리가 필요
- 큐가 길어지면 처리 지연이 증가(하지만 이는 “실패”보다 관리 가능한 형태의 지연)
토큰 기반(TPM)까지 고려한 스로틀링: “요청 수 제한”만으로는 부족
RPM만 제한하면 TPM이 터져 429가 날 수 있습니다. 특히 요약/분석처럼 입력이 길거나 출력이 긴 작업은 TPM이 지배합니다.
실전에서는 아래 중 하나를 추가합니다.
- 작업별 토큰 예산 추정: 입력 길이로 대략적인 토큰을 추정해 큐에서 ‘큰 작업’을 느리게
- 모델/파라미터 조정:
max_output_tokens상한을 낮추고, 필요 시 여러 턴으로 나눔 - 우선순위 큐: 짧은 작업(저토큰)을 먼저 처리해 체감 지연을 낮춤(Shortest Job First 유사)
간단한 토큰 추정은 완벽할 필요가 없습니다. “긴 문서는 느리게 흘린다” 정도만 해도 TPM 폭주가 크게 줄어듭니다.
재시도 폭풍을 막는 운영 규칙 6가지
- 클라이언트 재시도와 서버 재시도를 중복하지 말기
- 프론트/모바일이 재시도하고 서버도 재시도하면 증폭됩니다. 한 레이어에서만 책임지세요.
- 동시성 상한을 먼저 걸기
- 백오프는 ‘시간’을 늘리지만 동시성은 ‘압력’을 줄입니다. 둘 다 필요하되 우선순위는 동시성입니다.
- 재시도 가능한 오류만 재시도
- 429, 5xx, 네트워크 오류 위주. 4xx(권한/파라미터)까지 재시도하면 큐만 막힙니다.
- 최대 총 대기 시간(Deadline)을 둬라
- 무한 재시도는 장애를 숨기고 비용만 키웁니다.
- 서킷 브레이커(Circuit Breaker) 고려
- 일정 비율 이상 429가 지속되면 잠시 신규 enqueue를 제한하거나, degrade 응답을 반환.
- 관측성(Observability)을 먼저 심기
429 비율,재시도 횟수 분포,큐 길이,작업 처리 지연,TPM 추정치를 대시보드로 봐야 튜닝이 가능합니다.
빠른 진단: 429가 늘었을 때 어디부터 볼까?
- 큐가 없다면: 먼저 호출 경로에 재시도/백오프가 “동시에” 걸리는지 확인(여러 레이어 중복)
- 큐가 있다면: 큐 길이 증가가 “유입 증가”인지 “처리량 감소”인지 분리
- 처리량 감소라면 워커 장애/네트워크/타임아웃/모델 변경(토큰 증가)을 의심
- 최근 배포가 있었다면: 프롬프트 길이 증가,
max_output_tokens증가, 스트리밍 처리 버그(중복 호출) 같은 회귀를 의심
워커가 계속 죽거나 재시작되면 큐 지연이 폭증하며 429와 섞여 보일 수 있습니다. 이런 경우에는 프로세스/서비스 레벨에서 재시작 루프를 먼저 잡아야 합니다(예: systemd 서비스가 계속 재시작될 때 진단 체크리스트).
결론: 429는 “재시도”가 아니라 “흐름 제어” 문제다
OpenAI 429/RateLimitError는 단순한 예외 처리가 아니라, 시스템이 처리 가능한 속도를 넘어섰다는 신호입니다. 안정적인 해법은 다음 순서로 접근하는 것이 좋습니다.
- Retry-After + 지수 백오프 + 지터로 재시도 폭풍을 방지
- 동시성 상한을 강제(프로세스/워커 레벨)
- 큐로 버스트를 흡수하고, 사용자 요청 경로를 비동기로 분리
- TPM까지 고려해 작업 크기를 조절(토큰 예산/우선순위)
- 지표 기반 튜닝으로 “429=운영 가능한 지연” 상태를 만든다
이 패턴을 적용하면 429가 완전히 사라지지 않더라도, 서비스는 훨씬 예측 가능해지고(지연은 늘어도 실패는 줄고), 비용과 운영 스트레스가 함께 내려갑니다.