Published on

OpenAI API 429 RateLimit 재시도·백오프 실무

Authors

서버에서 OpenAI API를 붙이다 보면 언젠가 429를 마주칩니다. 문제는 429가 단순히 “잠깐만 기다려”가 아니라는 점입니다. 같은 코드라도 트래픽 패턴, 동시성, 배치 크기, 토큰 사용량에 따라 재현되기도 하고 안 되기도 하며, 잘못된 재시도 로직은 오히려 실패율을 키우거나 비용을 늘립니다.

이 글은 429를 “재시도 몇 번”으로 끝내지 않고, 운영 환경에서 안전하게 흡수하는 재시도·백오프 설계를 다룹니다. 특히 다음을 목표로 합니다.

  • 성공률을 올리되, 폭주 시 더 악화시키지 않기
  • 지연 시간과 비용을 예측 가능하게 만들기
  • 장애 분석이 가능한 로그·메트릭 남기기

추가로 429 대응의 큰 그림은 아래 글도 함께 참고하면 좋습니다.

429가 의미하는 것: “요청 수”만의 문제가 아니다

429는 흔히 RPM 요청 수 제한으로만 생각하지만, 실제로는 다음이 복합적으로 작동합니다.

  • 요청 빈도: 초당 요청이 순간적으로 몰리면 평균 RPM이 낮아도 터질 수 있음
  • 토큰 처리량: 입력 토큰과 출력 토큰이 커질수록 같은 RPM이라도 제한에 빨리 도달
  • 동시성: 동시 요청이 많으면 짧은 시간에 토큰을 폭발적으로 소비
  • 조직/프로젝트 단위 제한: 서비스 인스턴스가 늘면 “각자 적당히” 보내도 합산으로 제한 초과

즉, 재시도는 필요하지만 “무조건 빨리 다시”는 최악의 선택입니다. 폭주 상황에서 재시도가 트래픽을 더 키워 429를 증폭시키는 전형적인 스로틀링 폭풍이 생깁니다.

재시도 설계의 핵심: 지수 백오프 + 지터 + 상한

실무에서 가장 안전한 기본값은 다음 조합입니다.

  • 지수 백오프: baseDelay * 2^attempt
  • 지터: 동일한 백오프를 쓰는 클라이언트들이 동시에 재시도하지 않도록 랜덤성 추가
  • 상한: 최대 대기 시간과 최대 재시도 횟수 제한

지터는 특히 중요합니다. 지터가 없으면 여러 인스턴스가 똑같이 1s, 2s, 4s 후에 동시에 재시도해 다시 동시에 429를 맞습니다.

Node.js 예제: fetch 기반 재시도 유틸

아래는 429와 일시적 네트워크 오류를 대상으로 재시도하는 예시입니다. 포인트는 Retry-After가 있으면 우선 존중하고, 없으면 지수 백오프와 지터를 적용하는 것입니다.

type RetryOptions = {
  maxAttempts: number;
  baseDelayMs: number;
  maxDelayMs: number;
  jitterRatio: number; // 0.2면 +-20% 랜덤
};

function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, 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);
  const date = Date.parse(retryAfter);
  if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
  return null;
}

function withJitter(delayMs: number, jitterRatio: number) {
  const jitter = delayMs * jitterRatio;
  const min = delayMs - jitter;
  const max = delayMs + jitter;
  return Math.max(0, Math.floor(min + Math.random() * (max - min)));
}

function calcBackoffMs(attempt: number, opts: RetryOptions) {
  const exp = opts.baseDelayMs * Math.pow(2, attempt);
  const capped = Math.min(exp, opts.maxDelayMs);
  return withJitter(capped, opts.jitterRatio);
}

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

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

      if (res.status !== 429 && res.status < 500) {
        return res;
      }

      if (res.status === 429 || res.status >= 500) {
        const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
        const delayMs = retryAfterMs ?? calcBackoffMs(attempt, opts);

        // 운영에서는 여기서 attempt, delayMs, status, requestId 등을 로깅
        await sleep(delayMs);
        continue;
      }

      return res;
    } catch (e) {
      lastErr = e;
      const delayMs = calcBackoffMs(attempt, opts);
      await sleep(delayMs);
    }
  }

  throw lastErr ?? new Error("fetchWithRetry exhausted");
}

권장 기본값 예시

  • maxAttempts: 5
  • baseDelayMs: 250
  • maxDelayMs: 8000
  • jitterRatio: 0.2

이 값은 “짧은 스파이크”를 흡수하는 데 좋습니다. 하지만 지속적인 과부하에서는 재시도만으로는 해결되지 않습니다. 그때 필요한 것이 동시성 제어와 큐잉입니다.

재시도만 하면 안 되는 경우: 동시성 제한이 먼저다

429가 자주 난다면, 재시도를 늘리는 대신 동시 요청 수를 제한해야 합니다. 이유는 단순합니다.

  • 재시도는 실패한 요청을 다시 보내므로 총 요청 수를 늘린다
  • 동시성 제한은 애초에 폭주를 막아 성공률을 높인다

Node.js 예제: p-limit로 동시성 제어

import pLimit from "p-limit";

const limit = pLimit(5); // 동시 5개로 제한

async function callOpenAI(payload: unknown) {
  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: 5,
      baseDelayMs: 250,
      maxDelayMs: 8000,
      jitterRatio: 0.2,
    }
  );

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

  return res.json();
}

export async function runMany(jobs: unknown[]) {
  return Promise.all(jobs.map((j) => limit(() => callOpenAI(j))));
}

여기서 동시성 5는 임의 값입니다. 실무에서는 다음을 기준으로 잡습니다.

  • 평균 응답 시간, 타임아웃
  • 모델별 처리량
  • 서비스 인스턴스 수
  • 피크 시간대 트래픽

중요한 건 “프로세스 하나에서 5”가 아니라, 전체 플릿에서 합산 동시성이 제한을 넘지 않게 해야 한다는 점입니다. 여러 파드가 있으면 각 파드에서 5로 제한해도 전체는 5 * 파드 수가 됩니다.

Retry-After를 무시하면 손해다

429 응답에 Retry-After가 오면, 서버가 “이 시간 이후 재시도하면 성공 가능성이 높다”는 힌트를 준 것입니다. 이를 무시하고 더 빨리 재시도하면 불필요한 실패를 반복합니다.

  • Retry-After가 있으면 우선 적용
  • 없으면 지수 백오프
  • 단, Retry-After가 비정상적으로 길면 상한을 두고 큐로 넘기기

어떤 에러를 재시도할 것인가: 분류가 운영을 좌우

모든 실패를 재시도하면 안 됩니다.

  • 재시도 대상
    • 429
    • 500대 서버 오류
    • 네트워크 타임아웃, 연결 리셋 등 일시 오류
  • 즉시 실패 처리(또는 입력 수정)
    • 400 잘못된 요청
    • 401 인증
    • 403 권한
    • 404 엔드포인트/리소스 문제

단, 400이라도 “일시적”일 수 있는 케이스가 있냐고 묻는다면, 일반적으로는 낮습니다. 오히려 400은 페이로드 생성 로직 버그, 토큰 한도 초과, 스키마 불일치 같은 구조적 문제일 가능성이 큽니다.

백오프만으로 부족할 때: 큐잉과 배치로 구조를 바꾸기

지속적으로 429가 난다면, 시스템이 “실시간 처리”를 감당할 수 없는 상태일 수 있습니다. 이때는 다음 중 하나로 구조를 바꾸는 것이 정석입니다.

  • 작업 큐: 요청을 큐에 넣고 워커가 제한된 속도로 처리
  • 배치 처리: 대량 작업은 Batch API 등 비동기 처리로 전환
  • 캐시: 같은 입력에 대한 응답을 재사용

Batch 기반 전략은 아래 글이 더 구체적입니다.

멱등성: 재시도는 “중복 실행”을 만든다

재시도는 본질적으로 같은 작업을 다시 수행합니다. 따라서 멱등성 설계가 없으면 다음 문제가 생깁니다.

  • 결제, 포인트 차감, 이메일 발송 같은 부작용이 중복 실행
  • DB에 중복 레코드 생성
  • 사용자에게 같은 알림이 여러 번 발송

해결책

  • 요청에 idempotencyKey를 부여하고 DB에 처리 상태를 저장
  • “이미 처리됨”이면 즉시 기존 결과 반환
  • 외부 호출 결과를 캐시하거나, 최소한 중복 실행을 감지

간단한 멱등 키 예시

import crypto from "crypto";

export function makeIdempotencyKey(userId: string, prompt: string) {
  const hash = crypto.createHash("sha256").update(`${userId}:${prompt}`).digest("hex");
  return `openai:${hash}`;
}

주의할 점은 프롬프트가 길거나 비결정적 요소가 섞이면 키가 흔들릴 수 있다는 것입니다. 실무에서는 “업무 의미” 기준의 키를 따로 정의하는 편이 안전합니다.

관측 가능성: 재시도는 로그 없으면 디버깅이 불가능

429 대응은 로직만큼이나 관측이 중요합니다. 최소한 아래를 남기면 운영 난이도가 급격히 내려갑니다.

  • status, attempt, delayMs, model
  • 요청 단위 상관관계 ID 예: traceId
  • 응답 헤더의 요청 식별자 예: x-request-id가 있다면 저장
  • 토큰 사용량(가능하면)과 입력 크기

메트릭으로는 다음이 유용합니다.

  • 429 비율
  • 재시도 횟수 분포
  • 최종 성공까지 걸린 시간
  • 큐 대기 시간과 워커 처리량

실무 체크리스트

운영 환경에서 429를 “관리 가능한 이벤트”로 만들려면, 아래 순서로 점검하는 것이 효율적입니다.

  1. Retry-After가 있으면 존중하는 재시도 구현
  2. 지수 백오프에 지터 적용, 최대 재시도와 최대 대기 상한 설정
  3. 동시성 제한을 코드 레벨에서 강제
  4. 서비스 전체 합산 동시성을 고려해 워커 수와 제한치 조정
  5. 지속 과부하라면 큐잉 또는 Batch로 구조 변경
  6. 멱등성 키로 중복 실행 방지
  7. 429와 재시도 관련 로그·메트릭으로 추적 가능하게 만들기

마무리: 429는 “실패”가 아니라 “신호”다

429는 API가 나쁘다는 뜻이 아니라, 현재 시스템의 호출 패턴이 처리량 한계를 넘고 있다는 신호입니다. 재시도·백오프는 그 신호를 완충하는 1차 방어선이고, 동시성 제어·큐잉·배치·캐시는 2차 구조적 해결책입니다.

당장 급한 불은 지수 백오프와 지터로 끄되, 429가 반복된다면 반드시 호출량을 제어하는 방향으로 설계를 바꾸는 것이 장기적으로 가장 싸고 안전합니다.