- Published on
OpenAI 429/Rate Limit 대응 - 재시도·백오프·큐잉
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI API를 호출하다 보면 가장 자주 마주치는 장애 중 하나가 HTTP 429 (Too Many Requests / Rate Limit) 입니다. 429는 “요청을 너무 빨리/너무 많이 보냈다”는 신호이지만, 단순히 sleep(1)로 땜질하면 스파이크 트래픽, 동시성 증가, 배치 작업에서 곧바로 다시 터집니다.
이 글에서는 429를 시스템적으로 다루는 방법을 정리합니다.
- 재시도는 언제/어떻게 해야 하는가
- 지수 백오프 + 지터(jitter)로 왜 ‘동시 재시도 폭주’를 막는가
- 애초에 429가 나지 않게 큐잉/워크풀로 호출량을 평탄화하는 방법
- 운영에서 필요한 관측(로그/메트릭) 포인트
> 참고: 429는 OpenAI만의 문제가 아니라, 외부 API를 붙이는 모든 서비스에서 반복되는 패턴입니다. 특히 쿠버네티스/EKS 환경에서 트래픽이 순간적으로 튀면 5xx/4xx가 연쇄적으로 발생할 수 있는데, 이런 장애 진단 관점은 EKS에서 Envoy 503 UF·URX 원인과 해결 10분도 함께 보면 좋습니다.
1) OpenAI 429의 본질: “실패”가 아니라 “흐름 제어 신호”
429는 보통 아래 상황에서 발생합니다.
- 짧은 시간에 요청이 몰림(동시성 폭증)
- 스트리밍/긴 응답으로 인해 연결이 오래 유지되며 동시 사용량이 증가
- 배치/크론이 같은 시각에 몰려 실행
- 클라이언트가 재시도 폭주(서로 같은 타이밍에 재시도)
핵심은 429를 일시적(transient) 오류로 보고, “성공할 때까지 무한 재시도”가 아니라 정책 기반 재시도 + 상한 + 큐잉으로 통제해야 한다는 점입니다.
2) 재시도 정책: 무엇을 재시도하고, 무엇은 즉시 실패할 것인가
재시도는 만능이 아닙니다. 다음 기준이 실무에서 안전합니다.
2.1 재시도 대상
- 429: 대표적인 재시도 대상
- 408/409/5xx(일부): 네트워크/일시 장애 성격이면 재시도 가능
2.2 재시도하면 안 되는 것
- 인증/권한(401/403): 키 문제, 권한 문제는 재시도해도 해결되지 않음
- 검증 실패(400): 프롬프트/파라미터 오류는 즉시 실패 후 수정 필요
2.3 반드시 필요한 상한
- 최대 재시도 횟수
- 최대 대기 시간(예: 전체 20~60초)
- 요청 타임아웃(서버에서 무한 대기 방지)
3) 지수 백오프 + 지터: “다 같이 재시도”를 피하는 핵심
429가 났을 때 모든 워커가 동일하게 sleep(1) 후 재시도하면, 1초 뒤에 다시 한 번 동시에 몰려 또 429가 납니다(동기화된 재시도 폭주).
이를 막는 정석이 Exponential Backoff + Jitter 입니다.
- 백오프(지수 증가): 0.5s → 1s → 2s → 4s …
- 지터(랜덤 흔들기): 각 시도마다 ±랜덤을 섞어 재시도 타이밍을 분산
3.1 Node.js(Typescript) 예제: 429 재시도 + 지터
아래 예제는 fetch 기반으로 429/5xx에 대해 재시도하며, Retry-After 헤더가 있으면 우선 존중합니다.
type RetryOptions = {
maxAttempts: number; // 총 시도 횟수(최초 1회 포함)
baseDelayMs: number; // 초기 딜레이
maxDelayMs: number; // 딜레이 상한
timeoutMs: number; // 요청 타임아웃
};
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function calcJitteredBackoff(attempt: number, base: number, max: number) {
// attempt: 1부터 시작(첫 재시도)
const exp = Math.min(max, base * 2 ** (attempt - 1));
// full jitter: 0 ~ exp 사이 랜덤
return Math.floor(Math.random() * exp);
}
async function fetchWithTimeout(input: RequestInfo, init: RequestInit, timeoutMs: number) {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(input, { ...init, signal: controller.signal });
return res;
} finally {
clearTimeout(t);
}
}
export async function callOpenAIWithRetry(
url: string,
init: RequestInit,
opt: RetryOptions
) {
let lastErr: unknown;
for (let attempt = 1; attempt <= opt.maxAttempts; attempt++) {
try {
const res = await fetchWithTimeout(url, init, opt.timeoutMs);
if (res.ok) return res;
// 재시도 대상 판별
const retryable = res.status === 429 || (res.status >= 500 && res.status <= 599);
if (!retryable) {
const body = await res.text().catch(() => "");
throw new Error(`Non-retryable status=${res.status} body=${body}`);
}
// 마지막 시도면 실패
if (attempt === opt.maxAttempts) {
const body = await res.text().catch(() => "");
throw new Error(`Retry exhausted status=${res.status} body=${body}`);
}
// Retry-After 우선
const ra = res.headers.get("retry-after");
let waitMs: number | null = null;
if (ra) {
const sec = Number(ra);
if (!Number.isNaN(sec)) waitMs = sec * 1000;
}
if (waitMs == null) {
waitMs = calcJitteredBackoff(attempt, opt.baseDelayMs, opt.maxDelayMs);
}
await sleep(waitMs);
continue;
} catch (e) {
lastErr = e;
// 네트워크 오류/Abort 등도 일시 오류로 간주할지 정책 결정
if (attempt === opt.maxAttempts) break;
const waitMs = calcJitteredBackoff(attempt, opt.baseDelayMs, opt.maxDelayMs);
await sleep(waitMs);
}
}
throw lastErr;
}
포인트
Retry-After가 있으면 이를 우선 적용- 지터는
full jitter(0~exp 랜덤)가 운영에서 무난 timeoutMs는 반드시 둬서 “재시도 + 무한 대기” 조합을 막기
4) “재시도만”으로는 부족하다: 큐잉/워크풀로 429를 예방
재시도는 사후 대응입니다. 트래픽이 지속적으로 한도를 넘으면 재시도는 오히려 비용과 지연만 늘립니다. 이때 필요한 게 큐잉(Queueing) 과 동시성 제한(Concurrency Limit) 입니다.
4.1 단일 프로세스에서의 간단한 큐: 동시성 N 제한
Node.js에서는 p-limit 같은 라이브러리로 간단히 “동시 호출 개수”를 제한할 수 있습니다.
import pLimit from "p-limit";
const limit = pLimit(5); // 동시에 5개만 OpenAI 호출
async function runJobs(jobs: Array<() => Promise<unknown>>) {
return Promise.all(
jobs.map((job) =>
limit(async () => {
// 여기서 callOpenAIWithRetry 같은 래퍼로 감싸기
return job();
})
)
);
}
이 방식은 단일 인스턴스에서는 효과가 좋지만, 서버가 여러 대로 스케일아웃되면 인스턴스별로 5개씩 총량이 늘어나 다시 429가 날 수 있습니다.
4.2 분산 환경에서는 “전역 제한”이 필요
스케일아웃된 환경(EKS/서버리스/다중 워커)에서는 다음 중 하나를 고려합니다.
- 중앙 큐(SQS/RabbitMQ/Kafka/Redis Streams)
- 토큰 버킷/리키 버킷을 Redis로 전역 구현
- API Gateway/Envoy 레이트리밋 필터로 경계에서 제한
특히 MSA에서 결제/주문처럼 “한 번만 처리되어야 하는 작업”을 큐로 흘릴 때는 중복 처리 방지가 중요합니다. 이 관점은 MSA 사가 실패로 중복결제 터질 때 Outbox로 막기에서 다룬 Outbox 패턴과도 연결됩니다(큐잉은 재시도와 항상 짝을 이룹니다).
5) 백프레셔(Backpressure): 큐가 쌓일 때 시스템을 보호하는 법
큐잉을 넣으면 429는 줄지만, 대신 큐 적체라는 새로운 문제가 생깁니다. 따라서 백프레셔 전략이 필요합니다.
- 큐 길이가 임계치 초과 시: 새 요청을 429/503으로 빠르게 거절(서버 보호)
- 우선순위 큐: 사용자 요청(온라인)과 배치(오프라인)를 분리
- 요청 단위 비용 기반 스케줄링: “긴 작업”이 “짧은 작업”을 굶기지 않게
운영에서는 “OpenAI 호출이 느려져서 워커가 막히고, 그 여파로 다른 컴포넌트가 연쇄 장애”가 자주 발생합니다. 쿠버네티스라면 이게 결국 재시작 루프로 번지기도 하니, 장애가 반복될 경우 Kubernetes CrashLoopBackOff 10가지 원인과 15분 진단 같은 체크리스트 관점으로도 함께 점검하는 게 좋습니다.
6) 설계 체크리스트: 실전에서 놓치기 쉬운 디테일
6.1 Idempotency(멱등성) 고려
재시도는 동일 요청을 여러 번 보낼 수 있습니다. 따라서
- 가능하면 요청 키(작업 ID) 를 두고 결과를 캐시/저장
- 서버 측에서 동일 작업을 중복 수행하지 않도록 방지
6.2 부분 실패 처리
스트리밍 응답에서 중간에 끊기면 “부분 결과”가 남습니다.
- 재시도 시 동일 프롬프트를 그대로 보내면 결과가 달라질 수 있음
- 사용자에게는 “재시도 중” 상태를 명확히 표시
- 중요 업무라면 결과를 단계적으로 저장(체크포인트)
6.3 관측(Observability)
429 대응은 튜닝의 영역입니다. 다음 지표를 최소로 잡으세요.
- 429 발생률(모델/엔드포인트/테넌트별)
- 재시도 횟수 분포(p50/p95)
- 큐 길이, 큐 대기 시간
- OpenAI 호출 레이턴시(p95/p99)
로그에는 다음이 있으면 원인 분석이 빨라집니다.
status,request_id(있다면),attempt,wait_ms,queue_delay_ms
7) 권장 아키텍처 예시(요약)
7.1 간단한 웹앱(단일 서버)
- 동시성 제한(p-limit)
- 429/5xx 재시도(지수 백오프 + 지터)
- 타임아웃/서킷 브레이커(간단히라도)
7.2 프로덕션 MSA/스케일아웃
- 중앙 큐(SQS/Kafka 등)로 작업 분리
- 워커 풀에서 제한된 동시성으로 OpenAI 호출
- 전역 레이트리밋(필요 시 Redis 토큰 버킷)
- 백프레셔(큐 길이 기반 거절/우선순위)
- 멱등성/중복 방지(Outbox/작업 ID)
8) 마무리: 429는 “조절하라”는 신호다
OpenAI 429는 단순 오류가 아니라, 시스템이 외부 자원을 사용하는 방식에 대해 “속도를 조절하라”고 말하는 신호입니다.
- 재시도는 필수지만, 지수 백오프 + 지터가 없으면 재시도가 또 다른 장애를 만듭니다.
- 트래픽이 스파이크를 가진다면, 근본 처방은 큐잉과 동시성 제한입니다.
- 운영 단계에서는 메트릭 기반 튜닝(429율, 재시도 분포, 큐 대기)을 통해 “안정성과 비용”의 균형점을 찾아야 합니다.
원한다면 사용 중인 스택(Next.js/Express/FastAPI/Spring), 배포 형태(EKS/서버리스), 트래픽 패턴(동시 사용자/배치 주기)을 알려주면 그 조건에 맞춘 레이트리밋/큐잉 설계와 코드(예: Redis 토큰 버킷, SQS 워커, BullMQ)로 더 구체화해드릴 수 있습니다.