Published on

OpenAI API 429·Rate limit 실전 백오프 패턴

Authors

서버에서 OpenAI API를 호출하다 보면 가장 흔하게 마주치는 장애가 429 입니다. 많은 팀이 “그냥 재시도하면 되겠지”라고 접근하지만, 재시도 로직이 단순할수록 오히려 더 많은 429를 만들고(동시에 몰리는 재시도 폭주), 지연이 늘어나며, 최악의 경우 다운스트림 전체를 불안정하게 만듭니다.

이 글은 429를 단순 에러가 아니라 용량(capacity) 신호로 보고, 백오프(backoff)와 동시성 제어, 토큰 예산 관리까지 포함한 실전 패턴을 정리합니다. 예시는 Node.js 중심으로 제공하지만, 원리는 어떤 언어에도 그대로 적용됩니다.

429를 “재시도하면 되는 에러”로 보면 망하는 이유

429는 보통 아래 중 하나(혹은 조합)로 발생합니다.

  • 요청 수 제한(RPM/RPS): 짧은 시간에 요청이 몰림
  • 토큰 사용량 제한(TPM): 요청 수는 적어도 출력이 길거나 입력이 커서 토큰이 빠르게 소진됨
  • 동시성/큐 용량 제한: 백엔드가 순간적으로 처리할 수 있는 동시 요청을 초과

문제는 429에서 “모두가 동시에 재시도”를 하면, 다음 순간에도 같은 조건이 반복되어 **동기화된 재시도 폭주(thundering herd)**가 생깁니다. 그래서 백오프는 단순 대기보다 반드시 **지터(jitter)**가 포함되어야 합니다.

기본 원칙: Retry-After를 존중하고, 지수 백오프에 지터를 섞어라

1) 서버가 주는 힌트: Retry-After

응답 헤더에 Retry-After가 있으면, 그 값을 최우선으로 사용합니다. 없으면 클라이언트가 지수 백오프를 적용합니다.

2) 지수 백오프 + Full Jitter

AWS 아키텍처 권장안으로 널리 쓰이는 방식이 Full Jitter 입니다.

  • 기본 지수 증가: base * 2^attempt
  • 최종 대기: random(0, cap) 형태로 분산

이 방식은 재시도 타이밍을 넓게 퍼뜨려서 재시도 폭주를 크게 줄입니다.

실전 패턴 1: OpenAI 호출 래퍼에 백오프를 “표준화”하기

서비스 코드 곳곳에서 제각각 재시도를 구현하면, 나중에 튜닝이 불가능해집니다. 먼저 단일 래퍼 함수로 호출을 모읍니다.

아래 예시는 fetch 기반이며, 429와 일시적 5xx에 대해 재시도합니다.

// backoff.ts
export function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function parseRetryAfterMs(retryAfter: string | null): number | null {
  if (!retryAfter) return null;

  // Retry-After: seconds (most common)
  const seconds = Number(retryAfter);
  if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);

  // Retry-After: HTTP date
  const dateMs = Date.parse(retryAfter);
  if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());

  return null;
}

export function fullJitterDelayMs(attempt: number, baseMs: number, capMs: number) {
  const exp = Math.min(capMs, baseMs * Math.pow(2, attempt));
  return Math.floor(Math.random() * exp);
}

export type RetryOptions = {
  maxAttempts: number;
  baseDelayMs: number;
  capDelayMs: number;
  retryOnStatuses: number[];
};

export async function fetchWithRetry(
  input: RequestInfo,
  init: RequestInit,
  opts: RetryOptions
) {
  let lastError: unknown;

  for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
    try {
      const res = await fetch(input, init);

      if (!opts.retryOnStatuses.includes(res.status)) {
        return res;
      }

      const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
      const delayMs = retryAfterMs ?? fullJitterDelayMs(attempt, opts.baseDelayMs, opts.capDelayMs);

      // response body can be useful for logging
      const bodyText = await res.text().catch(() => "");
      lastError = new Error(
        `retryable status=${res.status} attempt=${attempt + 1} delayMs=${delayMs} body=${bodyText}`
      );

      await sleep(delayMs);
      continue;
    } catch (err) {
      // network errors: also retry with backoff
      lastError = err;
      const delayMs = fullJitterDelayMs(attempt, opts.baseDelayMs, opts.capDelayMs);
      await sleep(delayMs);
      continue;
    }
  }

  throw lastError;
}

사용 예:

import { fetchWithRetry } from "./backoff";

const res = await fetchWithRetry(
  "https://api.openai.com/v1/responses",
  {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: "gpt-4.1-mini",
      input: "Summarize this text...",
    }),
  },
  {
    maxAttempts: 6,
    baseDelayMs: 250,
    capDelayMs: 8000,
    retryOnStatuses: [429, 500, 502, 503, 504],
  }
);

if (!res.ok) {
  throw new Error(`OpenAI error status=${res.status} body=${await res.text()}`);
}

const data = await res.json();

이 정도만 적용해도 “재시도 폭주”는 상당히 줄어듭니다. 하지만 트래픽이 커지면 이것만으로는 부족합니다.

실전 패턴 2: 동시성 제한(Concurrency limit)을 백오프보다 먼저 걸어라

429의 가장 큰 원인이 “짧은 시간에 동시 호출이 몰림”이라면, 백오프는 사후 대응일 뿐입니다. 프런트/배치/웹훅 등 여러 경로에서 OpenAI를 호출한다면, 프로세스 내부에서라도 동시성 제한을 걸어야 합니다.

Node.js에서는 간단히 세마포어로 구현할 수 있습니다.

// semaphore.ts
export class Semaphore {
  private available: number;
  private waiters: Array<() => void> = [];

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

  async acquire() {
    if (this.available > 0) {
      this.available -= 1;
      return;
    }

    await new Promise<void>((resolve) => {
      this.waiters.push(resolve);
    });
  }

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

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

호출부에서 결합:

import { Semaphore, withPermit } from "./semaphore";
import { fetchWithRetry } from "./backoff";

const openaiSem = new Semaphore(5); // 예: 프로세스 당 동시 5개로 제한

async function callOpenAI(payload: unknown) {
  return withPermit(openaiSem, async () => {
    const res = await fetchWithRetry(
      "https://api.openai.com/v1/responses",
      {
        method: "POST",
        headers: {
          "content-type": "application/json",
          authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
        },
        body: JSON.stringify(payload),
      },
      {
        maxAttempts: 6,
        baseDelayMs: 250,
        capDelayMs: 8000,
        retryOnStatuses: [429, 500, 502, 503, 504],
      }
    );

    if (!res.ok) {
      throw new Error(`OpenAI error status=${res.status} body=${await res.text()}`);
    }

    return res.json();
  });
}

이 패턴의 핵심은 “백오프는 예외 상황에서만 작동하도록 만들고, 평상시에는 동시성 제한으로 429 자체를 줄이는 것”입니다.

실전 패턴 3: 토큰 예산 기반 큐잉(TPM)으로 429를 구조적으로 줄이기

RPM보다 더 까다로운 것이 TPM입니다. 특히 요약/분석처럼 출력이 길어지거나, 입력 컨텍스트가 커지면 요청 수가 적어도 토큰이 빨리 소진됩니다.

이때는 단순 동시성 제한만으로는 부족하고, 토큰 예산을 시간 단위로 나눠 쓰는 방식이 필요합니다.

아래는 매우 단순화한 토큰 버킷 예시입니다.

// tokenBucket.ts
import { sleep } from "./backoff";

export class TokenBucket {
  private capacity: number;
  private tokens: number;
  private refillPerMs: number;
  private lastRefill: number;

  constructor(tokensPerMinute: number) {
    this.capacity = tokensPerMinute;
    this.tokens = tokensPerMinute;
    this.refillPerMs = tokensPerMinute / 60000;
    this.lastRefill = Date.now();
  }

  private refill() {
    const now = Date.now();
    const deltaMs = now - this.lastRefill;
    if (deltaMs <= 0) return;

    this.tokens = Math.min(this.capacity, this.tokens + deltaMs * this.refillPerMs);
    this.lastRefill = now;
  }

  async consume(cost: number) {
    while (true) {
      this.refill();
      if (this.tokens >= cost) {
        this.tokens -= cost;
        return;
      }

      const missing = cost - this.tokens;
      const waitMs = Math.ceil(missing / this.refillPerMs);
      await sleep(Math.min(waitMs, 2000));
    }
  }
}

사용 시에는 “요청당 토큰 비용”을 추정해야 합니다.

  • 입력 토큰: 대략 문자 수 / 4 같은 거친 추정으로 시작 가능
  • 출력 토큰: max_output_tokens를 비용으로 잡는 것이 안전(보수적)
import { TokenBucket } from "./tokenBucket";
import { fetchWithRetry } from "./backoff";

const bucket = new TokenBucket(120000); // 예: 분당 120k 토큰 예산

function estimateTokens(text: string) {
  return Math.ceil(text.length / 4);
}

async function callOpenAIWithTokenBudget(inputText: string) {
  const inputCost = estimateTokens(inputText);
  const outputBudget = 800; // 정책적으로 제한
  const cost = inputCost + outputBudget;

  await bucket.consume(cost);

  const res = await fetchWithRetry(
    "https://api.openai.com/v1/responses",
    {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      },
      body: JSON.stringify({
        model: "gpt-4.1-mini",
        input: inputText,
        max_output_tokens: outputBudget,
      }),
    },
    {
      maxAttempts: 6,
      baseDelayMs: 250,
      capDelayMs: 8000,
      retryOnStatuses: [429, 500, 502, 503, 504],
    }
  );

  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

이렇게 하면 TPM 초과로 인한 429를 “사후 재시도”가 아니라 “사전 대기”로 바꿔, 전체 시스템의 지연 분포가 훨씬 예측 가능해집니다.

실전 패턴 4: 멱등성(Idempotency)과 중복 요청 방지

백오프 재시도는 “같은 요청을 여러 번 보내는 것”입니다. 결제/주문 같은 도메인뿐 아니라, AI 호출도 다음 문제가 생깁니다.

  • 같은 사용자 입력이 여러 번 처리되어 비용 증가
  • 스트리밍/툴 호출 등 상태가 섞여 결과가 달라짐

가능하면 애플리케이션 레벨에서 요청 키를 만들고, 동일 키 요청은 같은 결과를 재사용하거나, 진행 중이면 합류(join)시키는 방식이 좋습니다.

간단한 인메모리 디듀프 예:

// dedupe.ts
const inFlight = new Map<string, Promise<unknown>>();

export async function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {
  const existing = inFlight.get(key);
  if (existing) return existing as Promise<T>;

  const p = fn().finally(() => {
    inFlight.delete(key);
  });

  inFlight.set(key, p as Promise<unknown>);
  return p;
}
import { dedupe } from "./dedupe";

async function summarizeOnce(userId: string, text: string) {
  const key = `sum:${userId}:${text.length}:${text.slice(0, 64)}`;
  return dedupe(key, async () => {
    // callOpenAI(...) 같은 실제 호출
  });
}

프로덕션에서는 Redis 같은 외부 저장소로 확장해 다중 인스턴스에서도 동작하도록 만듭니다.

실전 패턴 5: 서킷 브레이커와 “빠른 실패”로 연쇄 장애 막기

429가 급증하는 상황은 보통 “우리 시스템이 이미 과부하”이거나 “다운스트림이 용량 한계”인 상황입니다. 이때 무한정 기다리면 요청이 쌓여 타임아웃이 늘고, 워커 스레드/커넥션 풀이 고갈되며, 다른 기능까지 같이 죽습니다.

따라서 아래 정책을 함께 둡니다.

  • 최대 재시도 횟수 제한
  • 전체 대기 시간 상한(예: 요청당 15초)
  • 일정 비율 이상 429면 서킷 오픈 후 빠른 실패(또는 degrade)

이런 “장애 전파 차단”은 쿠버네티스 환경에서 특히 중요합니다. 비슷한 맥락으로, 클러스터 내부의 병목이 연쇄 장애로 번지는 사례는 EKS에서 Webhook 타임아웃? Admission 진단법 같은 글의 접근(병목 지점 식별, 타임아웃/리트라이의 상호작용 점검)과도 닿아 있습니다.

관측(Observability): 429를 줄이려면 먼저 “무엇이 제한을 치는지” 보여야 한다

백오프를 넣었는데도 429가 계속 나면, 대개 아래 중 하나가 원인입니다.

  • 동시성 제한이 없어서 순간 피크가 계속 발생
  • 입력/출력이 커져 TPM이 초과
  • 재시도 정책이 공격적(캡이 너무 작거나 지터가 없음)
  • 타임아웃이 짧아 네트워크 오류가 늘고, 그 재시도가 다시 부하를 만듦

최소한 다음 로그/메트릭을 남기면 튜닝이 쉬워집니다.

  • status=429 카운트, 비율
  • 재시도 횟수 분포(attempt 히스토그램)
  • 백오프 대기 시간(delayMs)
  • 요청당 추정 토큰(estimatedTokensIn, max_output_tokens)
  • 큐 대기 시간(동시성 세마포어 대기, 토큰 버킷 대기)

네트워크 계층 이슈가 의심될 때는 HTTP2 스트림 에러처럼 전혀 다른 증상이 섞여 보일 수도 있습니다. 그런 경우에는 Go net/http2 stream error 원인·해결 7가지처럼 전송 계층의 실패 패턴을 함께 점검하는 것이 좋습니다.

권장 조합: “동시성 제한 + 토큰 예산 + 지터 백오프”

실무에서 가장 안정적인 조합은 아래입니다.

  1. 동시성 제한으로 순간 피크를 먼저 누름
  2. **토큰 예산(버킷/큐)**으로 TPM 초과를 구조적으로 방지
  3. 그래도 발생하는 429와 일시적 5xx지수 백오프 + Full Jitter로 흡수
  4. 재시도는 상한을 두고, 초과 시 빠르게 실패하거나 degrade
  5. 디듀프/멱등성으로 중복 비용과 결과 흔들림을 줄임

이 조합은 “요청을 많이 보낼수록 더 많이 실패한다”는 악순환을 끊고, 비용과 지연을 예측 가능하게 만듭니다.

체크리스트: 지금 429가 많다면 여기부터

  • 재시도에 지터가 있는가(없으면 즉시 추가)
  • Retry-After를 존중하는가
  • 프로세스/서비스 단위 동시성 제한이 있는가
  • 입력 크기와 max_output_tokens 상한을 두고 있는가
  • TPM 예산을 사전 대기로 바꾸는 큐/버킷이 있는가
  • 재시도 총 시간 상한, 최대 시도 횟수가 있는가
  • 동일 요청 중복 방지(디듀프)가 있는가

429는 “OpenAI가 불안정하다”가 아니라 “우리 호출 패턴이 용량을 초과했다”는 신호인 경우가 대부분입니다. 백오프는 출발점이고, 동시성/토큰 예산/관측까지 묶었을 때 비로소 재현 가능한 안정성이 만들어집니다.