- Published on
Gemini API 429 쿼터 초과 대응 - 재시도·백오프
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 환경에서 Gemini API를 붙이다 보면 가장 자주 마주치는 장애 중 하나가 429 입니다. 에러 메시지는 대개 “quota exceeded” 혹은 “rate limit exceeded” 류로 나오지만, 실제 원인은 다양합니다. 단순히 “잠깐 기다렸다가 다시 호출” 수준으로 처리하면 트래픽이 조금만 늘어도 재시도가 폭주하면서 더 큰 장애로 번집니다.
이 글에서는 Gemini API 429를 재시도·백오프(Exponential Backoff)·지터(Jitter)·동시성 제한·토큰/요청 예산 관리 관점에서 실전적으로 정리합니다. 또한 서버리스/쿠버네티스처럼 스케일 아웃이 쉬운 환경에서 429가 더 악화되는 이유와, 이를 막는 패턴도 함께 다룹니다.
관련해서 429를 “TPM만 넘어서” 발생한다고 오해하기 쉬운데, OpenAI 케이스지만 원인 분해 방식은 매우 유사합니다: OpenAI Responses API 429인데 TPM만 넘는 6가지 원인
429가 의미하는 것: 쿼터 vs 레이트 리밋
429 Too Many Requests 는 HTTP 레벨에서 “너무 많은 요청”을 뜻하지만, 실제로는 크게 두 부류로 나뉩니다.
1) 순간 레이트 리밋 초과
- 짧은 시간 창에서 RPS가 너무 높음
- 동시 요청이 너무 많음
- 동일 API 키/프로젝트 단위로 제한
특징
- 잠깐만 줄이면 바로 회복
- 올바른 백오프가 있으면 사용자 영향이 작음
2) 일/월 단위 쿼터 소진
- 결제/플랜/프로젝트 쿼터를 다 씀
- 모델별 또는 기능별 쿼터가 별도로 걸려 있음
특징
- 기다린다고 해결되지 않음
- 재시도는 비용만 증가시키고 장애를 연장
따라서 429를 받았을 때는 “무조건 재시도”가 아니라 재시도 가능한 429인지를 먼저 판단해야 합니다.
429 대응의 핵심 원칙 5가지
원칙 1) 재시도는 지수 백오프 + 지터로
- 고정 대기(예: 매번 1초)는 동시 다발 재시도 폭주를 만든다
- 지수 백오프는 점점 간격을 늘려 서버 압력을 낮춘다
- 지터는 여러 인스턴스가 같은 타이밍에 재시도하는 현상을 깨준다
권장 공식(예시)
sleep = random(0, base * 2^attempt)형태의 Full Jitter
원칙 2) Retry-After 헤더가 있으면 최우선
일부 게이트웨이/프록시/플랫폼은 Retry-After 를 내려줍니다. 있으면 백오프 계산보다 우선 적용하세요.
주의: MDX 환경에서는 Retry-After: 3 같은 표기를 본문에 쓸 때 부등호는 없지만, 코드/헤더는 가급적 코드 블록으로 고정하는 습관이 안전합니다.
원칙 3) 동시성 제한이 백오프보다 먼저다
백오프는 “이미 터진 뒤”의 완화책입니다. 근본적으로는 동시 호출 수를 제한해야 합니다.
- Node.js:
p-limit,Bottleneck - Python:
asyncio.Semaphore - 서버 여러 대: Redis 기반 토큰 버킷/리키 버킷
원칙 4) 실패 예산을 정하고 빨리 포기하기
재시도는 사용자 경험을 살리지만, 무한 재시도는 전체 시스템을 망칩니다.
- 최대 시도 횟수
- 최대 총 대기 시간
- 요청 단위 타임아웃
- 서킷 브레이커(연속 실패 시 빠른 실패)
원칙 5) “요청 수”와 “토큰/출력 길이” 둘 다 예산화
LLM API는 “요청 수 제한” 외에도 “토큰 기반 제한”이 함께 걸리는 경우가 많습니다.
- 프롬프트가 길어지면 같은 RPS라도 더 빨리 한도를 맞는다
- 스트리밍/장문 출력은 처리 시간이 길어 동시성이 늘어난다
실전 구현 1: Node.js에서 429 재시도 + 백오프 + 지터
아래 예시는 fetch 기반의 호출을 감싸서 429 및 일시 장애(예: 503)에 대해 재시도합니다. 핵심은
Retry-After우선- Full Jitter 백오프
- 최대 시도 횟수와 최대 총 대기 시간
type RetryOptions = {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
maxTotalWaitMs: number;
};
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, 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);
return null;
}
function fullJitterDelayMs(attempt: number, baseDelayMs: number, maxDelayMs: number) {
const cap = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
return Math.floor(Math.random() * cap);
}
export async function withRetry<T>(
fn: () => Promise<T>,
opts: RetryOptions
): Promise<T> {
const startedAt = Date.now();
let lastErr: unknown;
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
try {
return await fn();
} catch (err: any) {
lastErr = err;
const status = err?.status ?? err?.response?.status;
const retryAfter = err?.response?.headers?.get?.("retry-after") ?? null;
const retryableStatus = status === 429 || status === 503 || status === 502;
if (!retryableStatus) throw err;
const retryAfterMs = parseRetryAfterMs(retryAfter);
const delayMs = retryAfterMs ?? fullJitterDelayMs(attempt, opts.baseDelayMs, opts.maxDelayMs);
const elapsed = Date.now() - startedAt;
if (elapsed + delayMs > opts.maxTotalWaitMs) break;
await sleep(delayMs);
}
}
throw lastErr;
}
이제 Gemini 호출부를 아래처럼 감싸면 됩니다.
async function callGemini(payload: unknown) {
const res = await fetch(process.env.GEMINI_ENDPOINT as string, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.GEMINI_API_KEY}`,
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const err: any = new Error(`Gemini request failed: ${res.status}`);
err.status = res.status;
err.response = res;
throw err;
}
return res.json();
}
const data = await withRetry(
() => callGemini({ /* ... */ }),
{
maxAttempts: 6,
baseDelayMs: 200,
maxDelayMs: 8000,
maxTotalWaitMs: 15000,
}
);
포인트
maxTotalWaitMs로 “사용자 요청 한 번”이 무한정 늘어지는 것을 막습니다.maxDelayMs로 비정상적으로 긴 슬립을 방지합니다.
실전 구현 2: 동시성 제한까지 포함하기
재시도만 넣으면 피크 시간에 더 쉽게 터집니다. 특히 Next.js API 라우트나 워커가 오토스케일되면, 각 인스턴스가 독립적으로 재시도하면서 전체적으로 트래픽이 더 커질 수 있습니다.
단일 프로세스(한 서버)에서의 간단한 제한
import pLimit from "p-limit";
const limit = pLimit(5); // 동시에 5개만 Gemini 호출
export async function summarizeMany(items: string[]) {
return Promise.all(
items.map((text) =>
limit(() =>
withRetry(
() => callGemini({ text }),
{ maxAttempts: 6, baseDelayMs: 200, maxDelayMs: 8000, maxTotalWaitMs: 15000 }
)
)
)
);
}
여러 인스턴스(분산)에서의 제한
- Redis 토큰 버킷
- Cloud Tasks, SQS, PubSub로 큐잉 후 워커에서 제한
- API Gateway 레벨에서 per-key throttling
분산 제한은 구현 난도가 있지만, “스케일 아웃으로 429가 악화되는 문제”를 근본적으로 해결합니다.
429를 더 악화시키는 흔한 패턴
1) 타임아웃이 짧아서 중복 요청이 쌓임
클라이언트가 3초 타임아웃으로 끊고 다시 쏘면, 서버는 이미 처리 중인데 클라이언트만 재요청하는 중복이 생깁니다.
- 해결: 클라이언트 타임아웃을 합리적으로 늘리고, 서버는 idempotency key를 고려
2) 스트리밍 응답을 열어둔 채로 동시성이 증가
스트리밍은 체감 UX는 좋지만 연결이 오래 유지됩니다.
- 해결: 동시 스트림 수 제한, 긴 출력은 후처리 잡으로 이동
3) 프롬프트가 점점 비대해져 토큰 예산을 초과
대화 히스토리를 무한히 붙이면, 어느 순간부터 같은 호출 수에서도 제한에 걸립니다.
- 해결: 히스토리 요약, 컨텍스트 윈도우 관리, RAG 문서 수 제한
RAG/메모리 관련 장애는 형태는 다르지만 “입력이 비대해져 시스템이 무너지는” 점에서 유사합니다: FAISS RAG 메모리 폭증 OOM 해결 체크리스트
재시도 정책을 상태 코드만으로 결정하면 안 되는 이유
429 라도
- 몇 초 기다리면 풀리는 레이트 리밋
- 오늘 쿼터를 다 써서 절대 안 풀리는 쿼터 소진
이 둘은 대응이 완전히 다릅니다.
권장 분기
Retry-After가 있으면: 재시도 가치가 높음- 에러 바디에 “quota exhausted” 류가 명시되면: 즉시 실패하고 대체 경로로
- 동일 키로 수분간 계속 429면: 서킷 브레이커 열고 빠른 실패
대체 경로 예시
- 더 싼 모델로 폴백
- 기능 축소(요약 길이 제한, 문서 수 제한)
- 큐에 적재 후 비동기 처리로 전환
운영에서 꼭 넣어야 하는 관측(Observability)
재시도는 “조용히 성공”하기 때문에, 관측이 없으면 쿼터 문제를 늦게 알아차립니다.
최소 지표
gemini_requests_total(status별)gemini_retries_total(attempt별)gemini_retry_wait_ms(분포)gemini_concurrency(현재 동시 호출)gemini_prompt_tokens,gemini_output_tokens(가능하면)
로그 팁
- 요청 단위 correlation id
- 재시도 사유:
status,retryAfterMs,attempt
Next.js 서버 환경에서의 주의점
Next.js API Route / Route Handler는 트래픽이 늘면 인스턴스가 늘어날 수 있습니다. 이때 각 인스턴스가
- 동일한 백오프 정책으로
- 동일한 순간에
- 동일한 수의 재시도를
하면 “재시도 스톰”이 됩니다.
완화책
- 지터는 필수
- 가능하면 큐 기반 비동기 처리
- 서버 내부 동시성 제한 + 전역(분산) 제한
또한 서버에서 외부 API를 많이 호출할수록 egress, NAT, 보안그룹, 라우팅 문제로 장애가 섞여 들어오기도 합니다. 429만 보다가 네트워크 병목을 놓치기 쉽습니다: EKS에서 Pod는 정상인데 egress만 막힐 때 점검
추천 설정값 가이드(출발점)
서비스 성격에 따라 다르지만, 웹 요청 동기 처리 기준으로 무난한 출발점은 아래입니다.
- 최대 시도: 5회에서 7회
baseDelayMs: 200ms에서 500msmaxDelayMs: 8초에서 15초- 최대 총 대기: 10초에서 20초(대화형 UX면 더 짧게)
- 동시성 제한: 인스턴스당 3에서 10(트래픽과 모델 latency에 따라)
중요: 위는 “맞는 값”이 아니라 “측정 가능한 출발점”입니다. 대시보드에서 retry_wait_ms 와 success_after_retry 비율을 보고 조정하세요.
체크리스트: 429를 만났을 때 바로 점검할 것
429가 레이트 리밋인지 쿼터 소진인지 구분했는가Retry-After를 존중하고 있는가- 지수 백오프에 지터가 들어가 있는가
- 동시성 제한이 있는가(없으면 백오프만으로는 부족)
- 최대 총 대기 시간과 최대 시도 횟수가 있는가
- 프롬프트/출력 길이가 증가하면서 토큰 예산을 갉아먹고 있지 않은가
- 재시도 횟수와 대기 시간을 메트릭으로 보고 있는가
마무리
Gemini API 429는 “재시도 몇 번”으로 끝나는 문제가 아니라, 트래픽 패턴과 동시성, 그리고 토큰 예산이 얽힌 운영 이슈입니다.
- 재시도는
Retry-After우선 + 지수 백오프 + 지터 - 그보다 먼저 동시성 제한이 필요
- 쿼터 소진형 429는 재시도가 아니라 폴백/기능 축소/큐잉으로 전환
- 관측 지표를 넣어야 조용한 장애를 빨리 잡을 수 있음
위 패턴으로 구성하면, 429가 발생해도 사용자 영향은 최소화하면서 시스템 전체는 안정적으로 유지할 수 있습니다.