Published on

Gemini API 429 쿼터 초과 대응 - 재시도·백오프

Authors

운영 환경에서 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에서 500ms
  • maxDelayMs: 8초에서 15초
  • 최대 총 대기: 10초에서 20초(대화형 UX면 더 짧게)
  • 동시성 제한: 인스턴스당 3에서 10(트래픽과 모델 latency에 따라)

중요: 위는 “맞는 값”이 아니라 “측정 가능한 출발점”입니다. 대시보드에서 retry_wait_mssuccess_after_retry 비율을 보고 조정하세요.

체크리스트: 429를 만났을 때 바로 점검할 것

  1. 429 가 레이트 리밋인지 쿼터 소진인지 구분했는가
  2. Retry-After 를 존중하고 있는가
  3. 지수 백오프에 지터가 들어가 있는가
  4. 동시성 제한이 있는가(없으면 백오프만으로는 부족)
  5. 최대 총 대기 시간과 최대 시도 횟수가 있는가
  6. 프롬프트/출력 길이가 증가하면서 토큰 예산을 갉아먹고 있지 않은가
  7. 재시도 횟수와 대기 시간을 메트릭으로 보고 있는가

마무리

Gemini API 429는 “재시도 몇 번”으로 끝나는 문제가 아니라, 트래픽 패턴과 동시성, 그리고 토큰 예산이 얽힌 운영 이슈입니다.

  • 재시도는 Retry-After 우선 + 지수 백오프 + 지터
  • 그보다 먼저 동시성 제한이 필요
  • 쿼터 소진형 429는 재시도가 아니라 폴백/기능 축소/큐잉으로 전환
  • 관측 지표를 넣어야 조용한 장애를 빨리 잡을 수 있음

위 패턴으로 구성하면, 429가 발생해도 사용자 영향은 최소화하면서 시스템 전체는 안정적으로 유지할 수 있습니다.