- Published on
OpenAI API 429 RateLimit 재시도·백오프 실무
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI API를 붙이다 보면 언젠가 429를 마주칩니다. 문제는 429가 단순히 “잠깐만 기다려”가 아니라는 점입니다. 같은 코드라도 트래픽 패턴, 동시성, 배치 크기, 토큰 사용량에 따라 재현되기도 하고 안 되기도 하며, 잘못된 재시도 로직은 오히려 실패율을 키우거나 비용을 늘립니다.
이 글은 429를 “재시도 몇 번”으로 끝내지 않고, 운영 환경에서 안전하게 흡수하는 재시도·백오프 설계를 다룹니다. 특히 다음을 목표로 합니다.
- 성공률을 올리되, 폭주 시 더 악화시키지 않기
- 지연 시간과 비용을 예측 가능하게 만들기
- 장애 분석이 가능한 로그·메트릭 남기기
추가로 429 대응의 큰 그림은 아래 글도 함께 참고하면 좋습니다.
429가 의미하는 것: “요청 수”만의 문제가 아니다
429는 흔히 RPM 요청 수 제한으로만 생각하지만, 실제로는 다음이 복합적으로 작동합니다.
- 요청 빈도: 초당 요청이 순간적으로 몰리면 평균 RPM이 낮아도 터질 수 있음
- 토큰 처리량: 입력 토큰과 출력 토큰이 커질수록 같은 RPM이라도 제한에 빨리 도달
- 동시성: 동시 요청이 많으면 짧은 시간에 토큰을 폭발적으로 소비
- 조직/프로젝트 단위 제한: 서비스 인스턴스가 늘면 “각자 적당히” 보내도 합산으로 제한 초과
즉, 재시도는 필요하지만 “무조건 빨리 다시”는 최악의 선택입니다. 폭주 상황에서 재시도가 트래픽을 더 키워 429를 증폭시키는 전형적인 스로틀링 폭풍이 생깁니다.
재시도 설계의 핵심: 지수 백오프 + 지터 + 상한
실무에서 가장 안전한 기본값은 다음 조합입니다.
- 지수 백오프:
baseDelay * 2^attempt - 지터: 동일한 백오프를 쓰는 클라이언트들이 동시에 재시도하지 않도록 랜덤성 추가
- 상한: 최대 대기 시간과 최대 재시도 횟수 제한
지터는 특히 중요합니다. 지터가 없으면 여러 인스턴스가 똑같이 1s, 2s, 4s 후에 동시에 재시도해 다시 동시에 429를 맞습니다.
Node.js 예제: fetch 기반 재시도 유틸
아래는 429와 일시적 네트워크 오류를 대상으로 재시도하는 예시입니다. 포인트는 Retry-After가 있으면 우선 존중하고, 없으면 지수 백오프와 지터를 적용하는 것입니다.
type RetryOptions = {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
jitterRatio: number; // 0.2면 +-20% 랜덤
};
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function parseRetryAfterMs(retryAfter: string | null): number | null {
if (!retryAfter) return null;
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const date = Date.parse(retryAfter);
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
return null;
}
function withJitter(delayMs: number, jitterRatio: number) {
const jitter = delayMs * jitterRatio;
const min = delayMs - jitter;
const max = delayMs + jitter;
return Math.max(0, Math.floor(min + Math.random() * (max - min)));
}
function calcBackoffMs(attempt: number, opts: RetryOptions) {
const exp = opts.baseDelayMs * Math.pow(2, attempt);
const capped = Math.min(exp, opts.maxDelayMs);
return withJitter(capped, opts.jitterRatio);
}
export async function fetchWithRetry(
input: RequestInfo | URL,
init: RequestInit,
opts: RetryOptions
) {
let lastErr: unknown;
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
try {
const res = await fetch(input, init);
if (res.status !== 429 && res.status < 500) {
return res;
}
if (res.status === 429 || res.status >= 500) {
const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
const delayMs = retryAfterMs ?? calcBackoffMs(attempt, opts);
// 운영에서는 여기서 attempt, delayMs, status, requestId 등을 로깅
await sleep(delayMs);
continue;
}
return res;
} catch (e) {
lastErr = e;
const delayMs = calcBackoffMs(attempt, opts);
await sleep(delayMs);
}
}
throw lastErr ?? new Error("fetchWithRetry exhausted");
}
권장 기본값 예시
maxAttempts:5baseDelayMs:250maxDelayMs:8000jitterRatio:0.2
이 값은 “짧은 스파이크”를 흡수하는 데 좋습니다. 하지만 지속적인 과부하에서는 재시도만으로는 해결되지 않습니다. 그때 필요한 것이 동시성 제어와 큐잉입니다.
재시도만 하면 안 되는 경우: 동시성 제한이 먼저다
429가 자주 난다면, 재시도를 늘리는 대신 동시 요청 수를 제한해야 합니다. 이유는 단순합니다.
- 재시도는 실패한 요청을 다시 보내므로 총 요청 수를 늘린다
- 동시성 제한은 애초에 폭주를 막아 성공률을 높인다
Node.js 예제: p-limit로 동시성 제어
import pLimit from "p-limit";
const limit = pLimit(5); // 동시 5개로 제한
async function callOpenAI(payload: unknown) {
const res = await fetchWithRetry(
"https://api.openai.com/v1/responses",
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify(payload),
},
{
maxAttempts: 5,
baseDelayMs: 250,
maxDelayMs: 8000,
jitterRatio: 0.2,
}
);
if (!res.ok) {
const text = await res.text();
throw new Error(`OpenAI error status=${res.status} body=${text}`);
}
return res.json();
}
export async function runMany(jobs: unknown[]) {
return Promise.all(jobs.map((j) => limit(() => callOpenAI(j))));
}
여기서 동시성 5는 임의 값입니다. 실무에서는 다음을 기준으로 잡습니다.
- 평균 응답 시간, 타임아웃
- 모델별 처리량
- 서비스 인스턴스 수
- 피크 시간대 트래픽
중요한 건 “프로세스 하나에서 5”가 아니라, 전체 플릿에서 합산 동시성이 제한을 넘지 않게 해야 한다는 점입니다. 여러 파드가 있으면 각 파드에서 5로 제한해도 전체는 5 * 파드 수가 됩니다.
Retry-After를 무시하면 손해다
429 응답에 Retry-After가 오면, 서버가 “이 시간 이후 재시도하면 성공 가능성이 높다”는 힌트를 준 것입니다. 이를 무시하고 더 빨리 재시도하면 불필요한 실패를 반복합니다.
Retry-After가 있으면 우선 적용- 없으면 지수 백오프
- 단,
Retry-After가 비정상적으로 길면 상한을 두고 큐로 넘기기
어떤 에러를 재시도할 것인가: 분류가 운영을 좌우
모든 실패를 재시도하면 안 됩니다.
- 재시도 대상
429500대 서버 오류- 네트워크 타임아웃, 연결 리셋 등 일시 오류
- 즉시 실패 처리(또는 입력 수정)
400잘못된 요청401인증403권한404엔드포인트/리소스 문제
단, 400이라도 “일시적”일 수 있는 케이스가 있냐고 묻는다면, 일반적으로는 낮습니다. 오히려 400은 페이로드 생성 로직 버그, 토큰 한도 초과, 스키마 불일치 같은 구조적 문제일 가능성이 큽니다.
백오프만으로 부족할 때: 큐잉과 배치로 구조를 바꾸기
지속적으로 429가 난다면, 시스템이 “실시간 처리”를 감당할 수 없는 상태일 수 있습니다. 이때는 다음 중 하나로 구조를 바꾸는 것이 정석입니다.
- 작업 큐: 요청을 큐에 넣고 워커가 제한된 속도로 처리
- 배치 처리: 대량 작업은 Batch API 등 비동기 처리로 전환
- 캐시: 같은 입력에 대한 응답을 재사용
Batch 기반 전략은 아래 글이 더 구체적입니다.
멱등성: 재시도는 “중복 실행”을 만든다
재시도는 본질적으로 같은 작업을 다시 수행합니다. 따라서 멱등성 설계가 없으면 다음 문제가 생깁니다.
- 결제, 포인트 차감, 이메일 발송 같은 부작용이 중복 실행
- DB에 중복 레코드 생성
- 사용자에게 같은 알림이 여러 번 발송
해결책
- 요청에
idempotencyKey를 부여하고 DB에 처리 상태를 저장 - “이미 처리됨”이면 즉시 기존 결과 반환
- 외부 호출 결과를 캐시하거나, 최소한 중복 실행을 감지
간단한 멱등 키 예시
import crypto from "crypto";
export function makeIdempotencyKey(userId: string, prompt: string) {
const hash = crypto.createHash("sha256").update(`${userId}:${prompt}`).digest("hex");
return `openai:${hash}`;
}
주의할 점은 프롬프트가 길거나 비결정적 요소가 섞이면 키가 흔들릴 수 있다는 것입니다. 실무에서는 “업무 의미” 기준의 키를 따로 정의하는 편이 안전합니다.
관측 가능성: 재시도는 로그 없으면 디버깅이 불가능
429 대응은 로직만큼이나 관측이 중요합니다. 최소한 아래를 남기면 운영 난이도가 급격히 내려갑니다.
status,attempt,delayMs,model- 요청 단위 상관관계 ID 예:
traceId - 응답 헤더의 요청 식별자 예:
x-request-id가 있다면 저장 - 토큰 사용량(가능하면)과 입력 크기
메트릭으로는 다음이 유용합니다.
429비율- 재시도 횟수 분포
- 최종 성공까지 걸린 시간
- 큐 대기 시간과 워커 처리량
실무 체크리스트
운영 환경에서 429를 “관리 가능한 이벤트”로 만들려면, 아래 순서로 점검하는 것이 효율적입니다.
Retry-After가 있으면 존중하는 재시도 구현- 지수 백오프에 지터 적용, 최대 재시도와 최대 대기 상한 설정
- 동시성 제한을 코드 레벨에서 강제
- 서비스 전체 합산 동시성을 고려해 워커 수와 제한치 조정
- 지속 과부하라면 큐잉 또는 Batch로 구조 변경
- 멱등성 키로 중복 실행 방지
429와 재시도 관련 로그·메트릭으로 추적 가능하게 만들기
마무리: 429는 “실패”가 아니라 “신호”다
429는 API가 나쁘다는 뜻이 아니라, 현재 시스템의 호출 패턴이 처리량 한계를 넘고 있다는 신호입니다. 재시도·백오프는 그 신호를 완충하는 1차 방어선이고, 동시성 제어·큐잉·배치·캐시는 2차 구조적 해결책입니다.
당장 급한 불은 지수 백오프와 지터로 끄되, 429가 반복된다면 반드시 호출량을 제어하는 방향으로 설계를 바꾸는 것이 장기적으로 가장 싸고 안전합니다.