- Published on
OpenAI 429 rate_limit_exceeded 재시도 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI API를 호출하다 보면 429 와 함께 rate_limit_exceeded 를 맞닥뜨리는 일이 흔합니다. 많은 팀이 여기서 sleep(1) 후 재시도 같은 단순 처방을 넣는데, 트래픽이 몰리는 순간엔 이 방식이 오히려 재시도 폭풍(retry storm)을 만들고 장애를 키웁니다.
이 글에서는 429 rate_limit_exceeded 를 “일시적 실패”로만 보지 않고, 재시도 정책(Backoff + Jitter), 동시성 제어, 요청 예산(budget) 기반 제한, 서킷 브레이커, **관측(Observability)**까지 포함한 실전 설계를 다룹니다.
또한 쿠버네티스 환경에서 재시도 폭주가 Pod 재시작이나 연쇄 장애로 번지는 패턴도 자주 보이므로, 필요하면 K8s Pod CrashLoopBackOff 원인 7가지와 해결도 함께 참고하면 좋습니다.
429의 의미를 정확히 분류하기
429 는 한 가지 원인만을 뜻하지 않습니다. 최소한 아래 두 축으로 나눠야 재시도 전략이 정교해집니다.
1) 단기 과부하 vs 구조적 초과
- 단기 과부하(Transient): 순간적으로 RPM/TPM 한도를 넘었거나, 계정/모델 측에서 짧은 시간 혼잡이 발생
- 올바른 백오프와 지터를 주면 대부분 회복
- 구조적 초과(Chronic): 평균 트래픽 자체가 한도를 지속적으로 초과
- 재시도는 비용만 늘리고 성공률을 낮춤
- 해결은 “동시성/큐잉/캐시/요약/모델 변경/쿼터 상향” 같은 구조적 조치
2) 헤더 기반 신호 유무
가능하면 응답의 Retry-After 같은 힌트를 신뢰하고, 없을 때만 클라이언트가 계산한 백오프를 사용합니다.
- 서버가
Retry-After를 제공한다면 우선 적용 - 제공하지 않는다면 지수 백오프 + 지터로 계산
재시도 설계의 기본: 지수 백오프 + 지터
재시도의 핵심은 “언제 다시 시도할지”를 분산시키는 것입니다. 모든 워커가 같은 타이밍에 다시 때리면, 다음 초에도 또 429 를 맞습니다.
권장 구성 요소는 다음과 같습니다.
- 지수 백오프(Exponential Backoff):
base * 2^attempt - 지터(Jitter): 랜덤성을 추가해 재시도 타이밍을 분산
- 상한(Max delay): 무한정 커지지 않게 제한
- 총 시간 제한(Deadline): 사용자 요청의 SLA를 넘기지 않게 전체 재시도 시간을 제한
지터는 여러 방식이 있지만, 실무에서는 아래 두 가지가 많이 쓰입니다.
- Full Jitter:
sleep = random(0, cap) - Equal Jitter:
sleep = cap/2 + random(0, cap/2)
Full Jitter가 분산 효과가 좋고 구현도 단순합니다.
“재시도”만으로는 부족하다: 동시성 제어가 본체
429 는 대개 “너무 많은 요청이 동시에 나간다”는 신호입니다. 재시도는 증상을 늦출 뿐이고, 동시성(concurrency)과 발사율(rate)을 제어하지 않으면 같은 문제가 반복됩니다.
실전에서 가장 효과가 큰 순서는 보통 이렇습니다.
- 호출부에 전역 동시성 제한(세마포어)
- 프로세스/Pod 단위가 아니라 클러스터 전체 단위 제한(Redis 토큰 버킷 등)
- 그래도 넘치면 큐잉(작업 큐)으로 흡수
- 최후에 우아한 실패(Graceful degradation)
쿠버네티스에서 여러 Pod가 스케일아웃되면 “Pod 수만큼 동시성”이 곱해져 한도를 쉽게 넘습니다. 이 경우는 애플리케이션 레벨에서 분산 레이트 리미터가 필요합니다.
Node.js 예제: 429 대응 재시도 유틸리티
아래 예제는 다음을 포함합니다.
Retry-After우선 적용- 없으면 지수 백오프 + Full Jitter
- 재시도 가능한 에러만 선별
- 전체 데드라인(총 재시도 시간) 제한
// retry.ts
type RetryOptions = {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
deadlineMs: number;
};
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function parseRetryAfterMs(value: string | null): number | null {
if (!value) return null;
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, Math.floor(seconds * 1000));
// HTTP-date 형식은 환경별 파싱 이슈가 있어 여기선 단순화
return null;
}
function isRetryable429(err: any): boolean {
const status = err?.status ?? err?.response?.status;
if (status !== 429) return false;
const code = err?.error?.code ?? err?.response?.data?.error?.code;
// OpenAI 계열에서 흔히 보는 코드명을 기준으로 방어적으로 처리
return code === "rate_limit_exceeded" || code === "insufficient_quota" || !code;
}
export async function withRetry<T>(
fn: () => Promise<T>,
opts: RetryOptions
): Promise<T> {
const started = Date.now();
let lastErr: unknown;
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
try {
return await fn();
} catch (err: any) {
lastErr = err;
const elapsed = Date.now() - started;
const remaining = opts.deadlineMs - elapsed;
if (remaining <= 0) break;
if (!isRetryable429(err)) throw err;
const retryAfterHeader =
err?.response?.headers?.["retry-after"] ?? err?.response?.headers?.["Retry-After"] ?? null;
const retryAfterMs = parseRetryAfterMs(retryAfterHeader);
const exp = opts.baseDelayMs * Math.pow(2, attempt);
const cap = Math.min(opts.maxDelayMs, exp);
const jittered = Math.floor(Math.random() * cap); // Full Jitter
const sleepMs = Math.min(remaining, retryAfterMs ?? jittered);
if (sleepMs <= 0) break;
await sleep(sleepMs);
}
}
throw lastErr;
}
이 유틸만으로도 “무작정 즉시 재시도”보다 성공률은 올라가지만, 트래픽이 큰 서비스라면 아래의 동시성 제어가 같이 들어가야 합니다.
Node.js 예제: 세마포어로 동시성 제한
프로세스 내부에서라도 동시에 날아가는 요청 수를 제한하면 429 빈도가 눈에 띄게 줄어듭니다.
// semaphore.ts
export class Semaphore {
private available: number;
private queue: Array<() => void> = [];
constructor(private readonly capacity: number) {
this.available = capacity;
}
async acquire(): Promise<() => void> {
if (this.available > 0) {
this.available -= 1;
return () => this.release();
}
await new Promise<void>((resolve) => this.queue.push(resolve));
this.available -= 1;
return () => this.release();
}
private release() {
this.available += 1;
const next = this.queue.shift();
if (next) next();
}
}
사용 예시는 다음과 같습니다.
import { Semaphore } from "./semaphore";
import { withRetry } from "./retry";
const sem = new Semaphore(8); // 프로세스당 동시 호출 8개 제한
async function callOpenAI(payload: any) {
const release = await sem.acquire();
try {
return await withRetry(
async () => {
// 실제 호출부 (fetch/SDK 등)
// 실패 시 err.response.headers["retry-after"] 등을 포함해 throw 되도록 구성
return await doRequest(payload);
},
{
maxAttempts: 6,
baseDelayMs: 200,
maxDelayMs: 8000,
deadlineMs: 15000
}
);
} finally {
release();
}
}
포인트는 “재시도는 세마포어 밖에서”가 아니라 세마포어 안에서 수행해, 재시도 요청이 동시성을 다시 폭발시키지 않게 하는 것입니다.
분산 환경: Redis 토큰 버킷으로 클러스터 전체 레이트 제한
Pod가 여러 개면 프로세스 세마포어만으로는 부족합니다. 이때는 Redis 같은 공유 저장소를 두고, 토큰 버킷 또는 슬라이딩 윈도우 카운터로 “클러스터 전체”에서 요청량을 조절합니다.
간단한 토큰 버킷의 핵심은 다음입니다.
- 매 초(또는 더 짧은 주기) 토큰을 일정량 보충
- 요청 1건당 토큰 1개 소비
- 토큰이 없으면 대기하거나 실패
구현은 Lua 스크립트로 원자적으로 처리하는 편이 안전합니다.
-- redis-token-bucket.lua
-- KEYS[1] bucket key
-- ARGV[1] capacity
-- ARGV[2] refill_per_sec
-- ARGV[3] now_ms
-- ARGV[4] cost
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_per_sec = tonumber(ARGV[2])
local now_ms = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then tokens = capacity end
if ts == nil then ts = now_ms end
local delta_ms = math.max(0, now_ms - ts)
local refill = (delta_ms / 1000.0) * refill_per_sec
local new_tokens = math.min(capacity, tokens + refill)
local allowed = 0
if new_tokens >= cost then
new_tokens = new_tokens - cost
allowed = 1
end
redis.call('HMSET', key, 'tokens', new_tokens, 'ts', now_ms)
redis.call('PEXPIRE', key, 600000)
return allowed
애플리케이션에서는 이 스크립트가 1 을 반환할 때만 OpenAI 호출을 진행하고, 아니면 백오프 후 재시도하거나 큐로 넘깁니다.
서킷 브레이커: 429가 “연쇄 장애”로 번지는 것을 차단
재시도는 성공률을 올리지만, 특정 시간대에 계속 429 가 나오는 상황에서는 애플리케이션의 스레드/이벤트 루프를 잠식합니다. 이때는 서킷 브레이커로 “잠깐 호출을 멈추고 회복을 기다리는” 전략이 필요합니다.
권장 정책 예시:
- 최근 30초 동안
429비율이 50% 이상이면 브레이커 Open - Open 상태에서는 즉시 실패(혹은 캐시/대체 응답)
- 10초 후 Half-open으로 소수 트래픽만 통과
- 성공률이 회복되면 Close
이 패턴은 ALB 502 나 504 같은 네트워크 계층 장애 대응과도 유사합니다. 외부 의존성이 흔들릴 때 상위 서비스가 “함께 무너지지 않게” 하는 것이 목적입니다. 관련해서는 AWS ALB 502·504 난사 - 원인별 해결 체크리스트에서 장애 전파를 줄이는 접근을 참고할 수 있습니다.
요청 예산(budget)과 우아한 실패 설계
사용자 요청 하나가 OpenAI 호출을 여러 번 유발하는 구조라면, 429 상황에서 비용이 기하급수적으로 늘어납니다. 그래서 재시도에는 “기술적 정책” 외에 “제품 정책”이 같이 들어가야 합니다.
- 사용자 요청당 최대 OpenAI 호출 수 제한
- 작업당 최대 토큰/비용 상한
- 일정 시간 동안 같은 입력은 캐시(특히 임베딩/분류)
- 품질을 낮춘 대체 경로 제공
- 예: 모델을 더 작은 것으로 전환
- 예: 요약 길이 축소
- 예: 실시간 생성 대신 비동기 처리
이런 우아한 실패는 단순히 장애를 피하는 게 아니라, SLA를 지키는 제품 경험에 직결됩니다.
관측: 재시도는 “성공”이 아니라 “부채”다
재시도를 넣으면 표면상 성공률은 올라가지만, 내부적으로는 지연과 비용이 증가합니다. 따라서 다음 지표를 반드시 쌓아야 합니다.
429발생률(모델/엔드포인트/테넌트/키별)- 재시도 횟수 분포(평균보다 p95, p99)
- 최종 성공까지의 누적 지연 시간
Retry-After준수 여부- 큐 대기 시간과 드롭률
특히 p95 지연이 급증하면 사용자 체감이 크게 나빠집니다. 프론트엔드에서 Long Task가 누적돼 INP가 나빠지는 것처럼, 백엔드에서도 재시도는 “눈에 안 보이는 긴 작업”이 됩니다. 성능 관점의 문제 해결 접근은 Chrome INP 느림? Long Task·TBT 7분 개선에서 소개한 것과 유사하게, 병목을 계측하고 제거하는 순서가 중요합니다.
실전 체크리스트
마지막으로 429 rate_limit_exceeded 대응을 위한 현실적인 체크리스트를 정리합니다.
1) 재시도 정책
Retry-After우선 적용- 지수 백오프 + Full Jitter
maxAttempts와deadlineMs를 함께 둔다- 재시도 가능한 케이스만 선별한다
2) 동시성/레이트 제어
- 프로세스 내부 세마포어로 동시성 상한
- Pod가 많으면 Redis 토큰 버킷 등 분산 제한
- 재시도 트래픽이 동시성을 다시 폭발시키지 않게 설계
3) 장애 전파 차단
- 서킷 브레이커로 지속적인
429구간에서 호출을 멈춘다 - 우아한 실패(대체 응답, 비동기 전환, 품질 저하)를 준비한다
4) 관측과 운영
429를 “에러 로그”가 아니라 “용량 신호”로 본다- p95 지연과 재시도 분포를 대시보드화
- 키/테넌트별로 분리해 폭주 주체를 찾는다
결론
429 rate_limit_exceeded 는 단순한 예외 처리가 아니라 용량 관리 문제입니다. 올바른 재시도는 지수 백오프와 지터로 시작하지만, 안정적인 시스템을 만들려면 동시성 제어와 분산 레이트 제한, 서킷 브레이커, 관측까지 함께 묶어야 합니다.
특히 트래픽이 커질수록 “재시도”는 성공률을 올리는 도구이면서 동시에 지연과 비용을 키우는 부채가 됩니다. 재시도를 넣었다면, 반드시 동시성 제한과 지표화를 같이 넣어 429 를 구조적으로 줄이는 방향으로 운영하세요.