- Published on
OpenAI 429 Rate Limit 대응 - 백오프·배치·캐시
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI API를 붙이다 보면 가장 먼저 마주치는 장애가 429입니다. 트래픽이 조금만 몰려도 요청이 튕기고, 무작정 재시도하면 더 큰 폭주(thundering herd)로 이어집니다. 이 글은 429 Rate Limit을 단순히 “재시도”로 뭉개지 않고, 백오프(backoff), 배치(batch), 캐시(cache) 세 축으로 재설계해 안정적으로 처리하는 방법을 다룹니다.
아래 내용은 특히 다음 상황에서 효과가 큽니다.
- 사용자 동시 접속이 높고, 짧은 시간에 요청이 몰리는 서비스
- 여러 마이크로서비스/워커가 같은 API 키를 공유하는 구조
- 동일하거나 유사한 프롬프트가 반복되는 업무 자동화/CS 응답
관련해서 429의 원인 분석과 재시도 패턴을 더 깊게 보고 싶다면 내부 글도 함께 참고하세요.
429의 본질: “요청 수”와 “토큰 수” 두 가지 병목
OpenAI의 rate limit은 보통 두 계열로 이해하면 실전에서 빠르게 정리됩니다.
RPM(Requests Per Minute): 분당 요청 수 제한TPM(Tokens Per Minute): 분당 토큰 사용량 제한
여기서 중요한 포인트는, 429가 떴다고 해서 항상 “TPM만 초과”는 아니라는 점입니다. 예를 들어 짧은 프롬프트를 초당 수십 개씩 쏘면 RPM이 먼저 터집니다. 반대로 배치로 한 번에 길게 보내면 TPM이 터집니다.
따라서 대응도 두 갈래로 나뉩니다.
RPM대응: 요청을 줄이거나 합치기(배치), 동시성을 조절(큐), 캐시로 중복 제거TPM대응: 출력 길이 제한, 프롬프트 압축, 모델 변경, 배치 크기 제한, 캐시
이 글은 이 중에서 애플리케이션 레벨에서 가장 즉시 효과가 큰 3가지: 백오프·배치·캐시를 집중적으로 다룹니다.
1) 백오프: “재시도”가 아니라 “안전한 재시도”
429에서 재시도는 필요합니다. 하지만 다음이 빠지면 재시도가 곧 장애 증폭이 됩니다.
- 지수 백오프(exponential backoff)
- 지터(jitter): 여러 클라이언트가 같은 타이밍에 재시도하지 않게 랜덤성 부여
- 최대 재시도 횟수와 데드라인
Retry-After헤더가 있다면 우선 존중
Node.js 예시: 지수 백오프 + 지터 + Retry-After
아래 코드는 OpenAI SDK를 쓰든, fetch를 쓰든 공통으로 적용 가능한 형태입니다.
type RetryOptions = {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
timeoutMs: number;
};
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function parseRetryAfterMs(headers: Headers): number | null {
const v = headers.get("retry-after");
if (!v) return null;
// seconds 형태가 일반적
const seconds = Number(v);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
// 날짜 형태도 가능
const dateMs = Date.parse(v);
if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
return null;
}
async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions): Promise<T> {
const started = Date.now();
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
try {
return await fn();
} catch (e: any) {
const elapsed = Date.now() - started;
const isLast = attempt === opts.maxRetries;
// 여기서는 예시로 e.status를 본다고 가정
const status = e?.status;
const headers: Headers | undefined = e?.headers;
const retryable = status === 429 || status === 503 || status === 502;
if (!retryable || isLast || elapsed > opts.timeoutMs) throw e;
const retryAfterMs = headers ? parseRetryAfterMs(headers) : null;
// 지수 백오프: base * 2^attempt
const exp = opts.baseDelayMs * Math.pow(2, attempt);
const capped = Math.min(opts.maxDelayMs, exp);
// 지터: 0.5x ~ 1.0x
const jittered = Math.floor(capped * (0.5 + Math.random() * 0.5));
const delay = retryAfterMs !== null ? Math.max(retryAfterMs, jittered) : jittered;
await sleep(delay);
}
}
// 논리상 도달하지 않음
throw new Error("unreachable");
}
백오프에서 자주 하는 실수
- 고정 딜레이(예: 항상 1초)로 재시도
- 트래픽이 몰리면 모든 클라이언트가 1초 후에 동시에 재시도해서 다시
429
- 트래픽이 몰리면 모든 클라이언트가 1초 후에 동시에 재시도해서 다시
- 무한 재시도
- 큐가 쌓이고 워커가 고갈되며, 결국 전체 장애로 번짐
429만 재시도하고503은 즉시 실패- 실제 운영에서는
503도 일시 장애인 경우가 많아 재시도 가치가 큼
- 실제 운영에서는
백오프는 “개별 요청을 성공시키는 기술”이지만, 그것만으로는 한도가 낮은 구간에서 근본적으로 부족합니다. 다음 단계가 배치입니다.
2) 배치: RPM을 줄이는 가장 강력한 레버
RPM이 병목이라면 해결책은 간단합니다. “요청 수를 줄인다.”
배치 전략은 크게 두 가지입니다.
- 동일 시점에 들어온 요청을 묶어 한 번에 처리(micro-batching)
- 여러 작업을 큐에 쌓아 워커가 일정 크기로 묶어서 처리(queue batching)
다만 LLM은 결과가 사용자별로 달라야 하므로, 단순히 프롬프트를 합치면 응답을 분리하기 어렵습니다. 그래서 배치할 때는 “한 번의 호출로 여러 개를 처리하되, 결과를 분리할 수 있게 입력 포맷을 설계”하는 것이 핵심입니다.
마이크로 배치 예시: 50ms 윈도우로 요청 합치기
아래는 요청을 50ms 동안 모아서 한 번에 호출하고, 결과를 요청별로 다시 나눠주는 패턴입니다.
type Task = {
id: string;
input: string;
resolve: (v: string) => void;
reject: (e: unknown) => void;
};
class MicroBatcher {
private queue: Task[] = [];
private timer: NodeJS.Timeout | null = null;
constructor(private windowMs: number, private maxBatchSize: number) {}
enqueue(id: string, input: string): Promise<string> {
return new Promise((resolve, reject) => {
this.queue.push({ id, input, resolve, reject });
if (this.queue.length >= this.maxBatchSize) {
this.flush().catch(() => void 0);
return;
}
if (!this.timer) {
this.timer = setTimeout(() => {
this.flush().catch(() => void 0);
}, this.windowMs);
}
});
}
private async flush() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const batch = this.queue.splice(0, this.maxBatchSize);
if (batch.length === 0) return;
try {
// 입력을 구조화해서 한 번에 호출
const payload = batch.map((t) => ({ id: t.id, input: t.input }));
const responseText = await callLLM(payload);
// LLM 응답을 JSON으로 받는 설계(아래 프롬프트 참고)
const parsed = JSON.parse(responseText) as { id: string; output: string }[];
const map = new Map(parsed.map((p) => [p.id, p.output]));
for (const t of batch) {
const out = map.get(t.id);
if (!out) t.reject(new Error("missing output"));
else t.resolve(out);
}
} catch (e) {
for (const t of batch) t.reject(e);
}
}
}
async function callLLM(items: { id: string; input: string }[]): Promise<string> {
// 실제로는 OpenAI Responses API 호출
// 여기서는 예시로 프롬프트만 설명
const prompt = {
instruction:
"You are a JSON generator. Return ONLY JSON array. Each element: {id, output}.",
items,
};
// fetch 또는 SDK 호출 후 text 반환
return JSON.stringify(items.map((x) => ({ id: x.id, output: x.input.toUpperCase() })));
}
배치의 장점은 명확합니다.
RPM을 즉시 낮출 수 있음- 백오프 재시도 횟수도 줄어듦
하지만 트레이드오프도 있습니다.
- 지연(latency)이 증가할 수 있음(윈도우
windowMs만큼) - 배치가 커질수록
TPM이 늘어TPM병목으로 이동할 수 있음 - 응답 분리 로직이 필요하고, JSON 파싱 실패 같은 새로운 실패 모드가 생김
따라서 운영에서는 보통 다음 조합이 안정적입니다.
- 마이크로 배치 윈도우는
20ms~100ms사이에서 시작 - 배치 최대 크기는
TPM을 넘기지 않는 선에서 제한 - 배치 호출 자체도
withRetry로 감싸기
3) 캐시: 중복 요청 제거가 곧 Rate Limit 절감
캐시는 429 대응에서 가장 비용 효율이 좋습니다. 이유는 간단합니다.
- 같은 입력에 대해 API 호출을 아예 하지 않음
RPM과TPM을 동시에 절감- 비용 절감 효과도 즉시 발생
LLM 캐시는 “완전히 동일한 입력만 캐시”하는 방식부터, “의미적으로 유사하면 캐시”하는 방식까지 스펙트럼이 넓습니다. 여기서는 운영에서 사고가 적은 순서대로 소개합니다.
3-1) 결정적(deterministic) 캐시 키 설계
캐시 키는 최소한 다음을 포함해야 합니다.
- 모델 이름
- 시스템/지시문(프롬프트 템플릿 버전)
- 사용자 입력
- 주요 파라미터(예: temperature, max_output_tokens)
이 중 하나라도 빠지면 “다른 조건의 결과를 잘못 재사용”할 수 있습니다.
import crypto from "crypto";
type CacheKeyInput = {
model: string;
promptVersion: string;
system: string;
user: string;
temperature: number;
maxOutputTokens: number;
};
export function makeCacheKey(x: CacheKeyInput) {
const raw = JSON.stringify(x);
return crypto.createHash("sha256").update(raw).digest("hex");
}
3-2) Redis 캐시 예시: TTL과 음수 캐시(negative cache)
- TTL은 업무 성격에 따라 다르지만, 고객 문의 요약/분류처럼 반복이 많은 경우
1d도 유효합니다. - 실패도 캐시하는 “음수 캐시”는 조심해서 써야 합니다. 다만
429처럼 일시적 실패는 짧게(5s~30s) 캐시하면 폭주를 막는 데 도움이 됩니다.
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
async function cachedLLM(key: string, ttlSec: number, fn: () => Promise<string>) {
const hit = await redis.get(key);
if (hit) return hit;
try {
const v = await fn();
await redis.set(key, v, { EX: ttlSec });
return v;
} catch (e: any) {
// 429는 짧게 음수 캐시해서 동시 폭주를 완화
if (e?.status === 429) {
await redis.set(key + ":err429", "1", { EX: 10 });
}
throw e;
}
}
3-3) 캐시가 어려운 경우: 정규화(normalization)로 적중률 올리기
사용자 입력이 조금씩 달라 캐시가 안 먹는 경우가 많습니다. 이때는 캐시 키에 넣기 전 입력을 정규화합니다.
- 공백/개행 정리
- 날짜, 주문번호 같은 변동 필드 마스킹
- 불필요한 로그/서명 제거
정규화는 “정답이 하나인 문제”에서만 강하게 적용하는 것이 안전합니다. 예를 들어 “에러 로그 요약”은 정규화가 잘 먹지만, “창의적 글쓰기”는 정규화가 결과 품질을 망칠 수 있습니다.
백오프·배치·캐시를 함께 쓸 때의 권장 아키텍처
세 가지를 한 번에 적용할 때는 순서가 중요합니다.
- 캐시 조회로 중복 제거
- 캐시 미스만 배치 큐에 적재
- 배치 호출은 백오프로 보호
- 성공 응답은 캐시에 저장
이 흐름을 지키면 429가 떠도 서비스가 무너지지 않고, 호출량이 자연스럽게 눌립니다.
간단한 흐름 코드(의사 코드)
async function handleRequest(req: { userText: string }) {
const key = makeCacheKey({
model: "gpt-4.1-mini",
promptVersion: "v3",
system: "classifier",
user: req.userText,
temperature: 0,
maxOutputTokens: 200,
});
const cached = await redis.get(key);
if (cached) return cached;
const result = await batcher.enqueue(key, req.userText);
await redis.set(key, result, { EX: 3600 });
return result;
}
// batcher 내부 callLLM은 withRetry로 감싸서 429에 대응
운영 체크리스트: “429를 줄이는” 실전 팁
1) 동시성 제한은 필수
백오프가 있어도 동시성이 무제한이면 결국 RPM이 터집니다. 프로세스 내부에서 세마포어로 동시 호출을 제한하거나, 큐 기반 워커로 전환하세요.
- 웹 요청 스레드에서 직접 호출하지 말고 작업 큐로 넘기기
- 워커 수와 동시 실행 수를 분리해서 조절하기
2) 배치 크기는 토큰 예산으로 관리
배치 최대 크기를 “개수”로만 제한하면 TPM 폭발이 납니다. 입력 길이를 대략 추정해 토큰 예산을 초과하면 배치를 쪼개는 방식이 안전합니다.
3) 캐시는 프롬프트 버전과 함께 굴리기
프롬프트를 개선할 때 캐시가 발목을 잡습니다. promptVersion을 키에 넣어 “새 버전 배포 시 자연스럽게 캐시 무효화”가 되게 하세요.
4) 실패 모드도 설계하기
- 백오프 후에도 실패하면 사용자에게 대체 응답 제공(예: “잠시 후 다시 시도”)
- 중요한 작업은 큐에 남겨 재처리, 덜 중요한 작업은 드롭
이런 “보상/재처리” 관점은 분산 시스템에서 특히 중요합니다. 트랜잭션이 얽힌 작업이라면 사가 패턴의 보상 설계 관점도 도움이 됩니다.
결론: 429는 “한도”가 아니라 “설계 신호”
429 Rate Limit은 단순 장애가 아니라, 시스템이 현재 트래픽을 감당하도록 설계되지 않았다는 신호입니다. 실전에서 가장 효과적인 순서는 다음과 같습니다.
- 캐시로 중복 호출 제거(비용, RPM, TPM 동시 절감)
- 배치로 요청 수를 구조적으로 줄이기(RPM 병목 해소)
- 백오프로 일시적 초과를 부드럽게 흡수하기(장애 증폭 방지)
이 3가지를 함께 적용하면, 같은 한도에서도 처리량이 눈에 띄게 올라가고 429가 “간헐적 이벤트” 수준으로 내려갑니다. 이후에는 모델 선택, 프롬프트 최적화, 토큰 예산 관리까지 확장해 더 단단한 호출 계층을 만들 수 있습니다.