Published on

OpenAI 429/RateLimitError 실전 백오프·리트라이

Authors

서버에서 OpenAI API를 붙이다 보면 어느 순간 429(Too Many Requests) 또는 RateLimitError를 마주합니다. 문제는 단순히 “요청을 덜 보내라”로 끝나지 않는다는 점입니다. 트래픽 피크, 배치 작업, 사용자 동시성, 스트리밍 응답, 멀티 인스턴스 확장, 그리고 모델별 토큰 한도까지 겹치면 재현도 어렵고 장애는 간헐적으로 터집니다.

이 글에서는 429가 왜 발생하는지(요청 수 vs 토큰), 무엇을 재시도해야 하고 무엇을 즉시 실패시켜야 하는지, 그리고 지수 백오프 + 지터 + 재시도 예산 + 큐/동시성 제한을 조합해 “서비스가 망가지지 않는” 형태로 설계하는 방법을 정리합니다.

관련해서 트래픽/리소스 병목을 진단하는 방법은 Spring Boot 대용량 트래픽 - HikariCP 풀 고갈 진단 글의 접근(증상-원인-계측-완화)을 함께 참고하면 좋습니다. 또한 LLM 출력 안정화는 RAG 환각을 줄이는 JSON Schema 강제 출력법처럼 “재시도 횟수를 줄이는” 방향으로도 큰 효과가 납니다.

429/RateLimitError의 진짜 의미: 요청 수만의 문제가 아니다

OpenAI 계열 API의 레이트 리밋은 보통 아래 축으로 걸립니다.

  • RPM: 분당 요청 수(Requests Per Minute)
  • TPM: 분당 토큰 수(Tokens Per Minute)
  • (경우에 따라) 동시 처리 제한 또는 계정/프로젝트 단위 제한

즉, 같은 1개의 요청이라도 max_output_tokens가 크거나 입력 프롬프트가 길면 TPM을 더 빨리 소모해서 429가 납니다. 반대로 짧은 요청을 초당 수백 개 보내면 RPM이 먼저 터집니다.

또한 장애 상황에서 흔한 패턴이 있습니다.

  • 배치/크론이 정각에 몰려서 요청 폭증
  • 프론트에서 다중 클릭/재전송으로 중복 호출
  • 워커가 실패를 “즉시 재시도” 하며 스파이크를 더 키움(재시도 폭풍)
  • 오토스케일로 인스턴스 수가 늘면서 전체 요청량이 선형 증가(전역 제한을 초과)

따라서 429 대응은 “재시도 로직 추가”가 아니라 부하를 평탄화하고, 실패를 제어된 방식으로 다루는 설계가 핵심입니다.

재시도 가능한 것 vs 즉시 실패시켜야 하는 것

모든 에러를 재시도하면 오히려 상황이 악화됩니다. 아래처럼 분류하는 것이 안전합니다.

재시도 권장

  • 429 / RateLimitError
  • 408(Request Timeout)
  • 5xx(일시적 서버 오류)
  • 네트워크 단절/일시적 DNS 문제

재시도 비권장(즉시 실패)

  • 400(잘못된 요청, 스키마/파라미터 오류)
  • 401/403(키/권한 문제)
  • 404(리소스/엔드포인트 오류)
  • “입력 자체가 잘못돼서” 계속 실패하는 케이스

추가로, 스트리밍 도중 끊긴 경우는 애매합니다. 사용자 UX 관점에서는 재시도 대신 “부분 결과 + 재생성 버튼”이 낫고, 서버 관점에서는 동일 요청을 자동 재시도하면 중복 과금/중복 처리가 생길 수 있습니다.

백오프 설계 핵심 4가지

1) 지수 백오프(Exponential Backoff)

기본 공식은 다음과 같습니다.

  • delay = base * 2^attempt

예: base=200ms200ms, 400ms, 800ms, 1600ms ...

2) 지터(Jitter)는 필수

여러 워커가 동시에 429를 맞으면 모두 같은 타이밍에 재시도해서 다시 429를 맞습니다(동기화 스파이크). 이를 막기 위해 지터를 섞습니다.

  • Full jitter: random(0, base * 2^attempt)
  • Decorrelated jitter: 이전 딜레이를 기반으로 랜덤화

실무에서는 full jitter가 구현이 단순하고 효과가 좋습니다.

3) Retry-After 헤더를 존중

서버가 Retry-After를 주면 그 값을 우선합니다. 없을 때만 백오프 계산값을 씁니다.

주의: 헤더가 초 단위인지 밀리초 단위인지, 또는 날짜 포맷인지 구현체마다 다를 수 있어 파싱을 방어적으로 해야 합니다.

4) 재시도 예산(Retry Budget)과 상한선

  • 최대 재시도 횟수(maxRetries)
  • 최대 총 대기 시간(maxTotalDelayMs)
  • 요청 단위 타임아웃(예: 30초)

이 3개가 없으면, 장애 시 재시도가 쌓여서 워커 스레드/큐/DB 연결 같은 다른 자원을 고갈시킵니다. 이는 Go gRPC DEADLINE_EXCEEDED 9가지 원인과 처방에서 말하는 “타임아웃 설계”와 동일한 결입니다.

Node.js(Typescript) 실전 리트라이 래퍼 예제

아래 코드는 OpenAI SDK 호출을 감싸는 형태로 작성했습니다. 포인트는 다음입니다.

  • 429 및 일시적 오류만 재시도
  • Retry-After 우선
  • full jitter 적용
  • 재시도 예산(maxRetries, maxTotalDelayMs) 적용
  • 로깅/메트릭 훅을 넣기 쉬운 구조
type RetryOptions = {
  maxRetries: number;
  baseDelayMs: number;
  maxDelayMs: number;
  maxTotalDelayMs: number;
};

function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, ms));
}

function parseRetryAfterMs(headers: Headers | Record<string, string> | undefined): number | null {
  if (!headers) return null;

  const get = (k: string) => {
    if (typeof (headers as any).get === "function") return (headers as any).get(k);
    const obj = headers as Record<string, string>;
    const key = Object.keys(obj).find((x) => x.toLowerCase() === k.toLowerCase());
    return key ? obj[key] : undefined;
  };

  const v = get("retry-after");
  if (!v) return null;

  // 1) seconds
  const sec = Number(v);
  if (Number.isFinite(sec)) return Math.max(0, Math.floor(sec * 1000));

  // 2) HTTP date
  const dt = Date.parse(v);
  if (!Number.isNaN(dt)) return Math.max(0, dt - Date.now());

  return null;
}

function isRetryableStatus(status: number) {
  if (status === 429) return true;
  if (status === 408) return true;
  if (status >= 500 && status <= 599) return true;
  return false;
}

function computeFullJitterDelayMs(baseDelayMs: number, maxDelayMs: number, attempt: 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,
  onRetry?: (info: { attempt: number; delayMs: number; reason: string }) => void
): Promise<T> {
  const startedAt = Date.now();

  for (let attempt = 0; ; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      const status: number | undefined = err?.status ?? err?.response?.status;
      const headers = err?.headers ?? err?.response?.headers;

      const retryAfterMs = parseRetryAfterMs(headers);
      const retryable = typeof status === "number" ? isRetryableStatus(status) : false;

      if (!retryable) throw err;
      if (attempt >= opts.maxRetries) throw err;

      const computed = computeFullJitterDelayMs(opts.baseDelayMs, opts.maxDelayMs, attempt);
      const delayMs = retryAfterMs !== null ? Math.min(opts.maxDelayMs, retryAfterMs) : computed;

      const elapsed = Date.now() - startedAt;
      if (elapsed + delayMs > opts.maxTotalDelayMs) throw err;

      onRetry?.({
        attempt,
        delayMs,
        reason: `status=${status}${retryAfterMs !== null ? ", retry-after" : ""}`,
      });

      await sleep(delayMs);
    }
  }
}

사용 예시는 다음과 같습니다.

import OpenAI from "openai";
import { withRetry } from "./withRetry";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });

async function run() {
  const res = await withRetry(
    async () => {
      return await client.responses.create({
        model: "gpt-4.1-mini",
        input: "요약해줘: ...",
        max_output_tokens: 400,
      });
    },
    {
      maxRetries: 6,
      baseDelayMs: 200,
      maxDelayMs: 8000,
      maxTotalDelayMs: 20000,
    },
    ({ attempt, delayMs, reason }) => {
      console.warn("retry", { attempt, delayMs, reason });
    }
  );

  console.log(res.output_text);
}

run().catch(console.error);

백오프만으로는 부족한 경우: 동시성 제한과 큐잉

재시도는 “실패 후”의 대응입니다. 하지만 실제로 429를 줄이려면 요청을 보내기 전에 모양을 잡아야 합니다.

1) 프로세스 내부 동시성 제한

Node 서버 한 인스턴스에서 동시에 OpenAI 호출을 N개만 허용하면, 순간 스파이크를 크게 줄일 수 있습니다.

class Semaphore {
  private available: number;
  private queue: Array<() => void> = [];

  constructor(count: number) {
    this.available = count;
  }

  async acquire() {
    if (this.available > 0) {
      this.available -= 1;
      return;
    }
    await new Promise<void>((resolve) => this.queue.push(resolve));
  }

  release() {
    this.available += 1;
    const next = this.queue.shift();
    if (next) {
      this.available -= 1;
      next();
    }
  }
}

const sem = new Semaphore(8);

export async function limited<T>(fn: () => Promise<T>) {
  await sem.acquire();
  try {
    return await fn();
  } finally {
    sem.release();
  }
}

limitedwithRetry를 조합하면 “동시성으로 인한 자폭”을 상당히 막을 수 있습니다.

const res = await limited(() => withRetry(() => client.responses.create({
  model: "gpt-4.1-mini",
  input: "...",
}), {
  maxRetries: 6,
  baseDelayMs: 200,
  maxDelayMs: 8000,
  maxTotalDelayMs: 20000,
}));

2) 멀티 인스턴스에서는 전역 제한이 필요

오토스케일링 환경(예: Kubernetes, Cloud Run)에서는 인스턴스별로 동시성 제한을 걸어도 전체 요청량이 전역 한도를 초과할 수 있습니다.

이때는 다음 중 하나가 필요합니다.

  • 중앙 큐(예: SQS, RabbitMQ, Redis Streams)로 작업을 모은 뒤 워커가 일정 속도로 소비
  • Redis 기반 토큰 버킷/리키 버킷으로 전역 RPM/TPM을 근사 제어
  • “사용자별/조직별” 쿼터를 애플리케이션 레벨에서 분리

특히 Cloud Run처럼 순간 확장이 빠른 환경에서는 429가 “콜드 스타트”와 섞여 증상이 복잡해지기도 합니다. 이 경우 GCP Cloud Run 503·Cold Start 원인과 튜닝에서 말하는 동시성/인스턴스 상한 전략과 함께 봐야 합니다.

토큰 관점 최적화: 429를 “근본적으로” 줄이는 방법

429를 줄이는 가장 확실한 방법은 TPM 소비를 줄이는 것입니다.

  • 입력 프롬프트를 짧게: 불필요한 로그/HTML/중복 컨텍스트 제거
  • 출력 상한 설정: max_output_tokens를 과도하게 크게 잡지 않기
  • 구조화 출력으로 재시도 감소: JSON Schema 강제 등으로 “형식 오류로 인한 재시도”를 없애기
  • 캐시: 동일 입력/동일 시스템 프롬프트 조합 결과 캐싱(특히 임베딩/요약)

또한 스트리밍을 쓰는 경우, 사용자가 중간에 이탈하면 서버는 이미 토큰을 소비했을 수 있습니다. UX와 과금/부하를 함께 고려해 “짧은 초안 먼저, 필요 시 확장” 같은 단계적 생성 전략도 효과적입니다.

장애 대응 체크리스트(운영 관점)

백오프 코드를 넣었는데도 429가 계속 난다면, 아래를 순서대로 점검하는 것이 좋습니다.

  1. 에러 비율과 분포: 특정 모델/엔드포인트/테넌트에 집중되는가
  2. RPM vs TPM 어느 쪽이 병목인지: 긴 프롬프트/큰 출력이 원인인지
  3. 재시도 폭풍 여부: 같은 요청이 여러 번 중복 실행되는지(아이템포턴시 키/요청 ID 필요)
  4. 동시성 제한 유무: 인스턴스 내부, 전역 각각의 제한이 있는지
  5. 큐 적체: 큐 길이, 처리율, 워커 수, 재시도 메시지의 지연
  6. 타임아웃: 클라이언트 타임아웃이 너무 짧아 재시도를 유발하는지

특히 “중복 실행”은 레이트 리밋을 악화시키는 대표적인 숨은 원인입니다. 분산 환경에서 중복 실행을 제어하는 감각은 MSA 사가(Saga) 패턴 - 중복 실행·보상처리 버그 해결에서 다루는 아이디어(중복 방지 키, 상태 전이, 보상 처리)와 유사합니다.

결론: 안전한 기본 조합

실전에서 가장 재현성 있게 효과를 내는 조합은 다음입니다.

  • 429/일시적 오류만 선별 재시도
  • 지수 백오프 + full jitter
  • Retry-After 우선 적용
  • maxRetriesmaxTotalDelayMs재시도 예산 설정
  • 인스턴스 내부 동시성 제한
  • 멀티 인스턴스면 전역 큐/레이트 리미터 도입

이렇게 하면 429가 “가끔 나는 에러”가 아니라, 서비스가 감당 가능한 범위에서 흡수되는 “제어된 이벤트”가 됩니다.