Published on

OpenAI 429 RateLimitError 재시도·백오프 실전

Authors

서버에서 OpenAI API를 호출하다 보면 어느 순간 429 응답(대개 RateLimitError)을 만나게 됩니다. 문제는 이 에러가 “잠깐 쉬었다가 다시 해”라는 신호인데, 애플리케이션이 이를 무시하고 즉시 재시도를 걸면 더 많은 요청이 몰리면서 제한이 길어지고, 결국 사용자 체감 장애로 번집니다.

이 글에서는 429의 의미를 정확히 해석하고, 재시도 정책(지수 백오프, 지터), 동시성 제어, 큐잉, 멱등성, 관측(로그·메트릭)까지 한 번에 설계하는 방법을 정리합니다. 네트워크 타임아웃과 재시도 설계를 더 넓게 보고 싶다면 gRPC 타임아웃 지옥 탈출 - 데드라인 전파 설계도 함께 읽어보면 좋습니다.

429는 “에러”가 아니라 “흐름 제어”다

429 Too Many Requests는 서버가 과부하이거나, 계정/프로젝트/모델 단위의 속도 제한을 초과했음을 의미합니다. 중요한 포인트는 아래 3가지입니다.

  1. 429는 재시도 대상이 될 수 있지만, “지금 당장” 재시도하면 안 됩니다.
  2. 제한은 보통 “요청 수”뿐 아니라 “토큰 처리량”도 함께 걸립니다.
  3. 같은 프로세스 내 재시도만으로 해결되지 않을 수 있습니다. 여러 인스턴스가 동시에 재시도하면 집단적으로 폭주합니다.

즉, 429 대응은 단순한 try/catch가 아니라 클라이언트 측 트래픽 셰이핑 문제입니다.

재시도 전략의 핵심: 지수 백오프 + 지터

왜 지수 백오프인가

고정 간격 재시도(예: 1초마다 10회)는 모든 요청이 같은 리듬으로 다시 몰리기 쉽습니다. 지수 백오프는 실패가 이어질수록 대기 시간을 늘려 서버와 클라이언트 모두를 보호합니다.

예시:

  • 1회 실패: 0.5초 대기
  • 2회 실패: 1초 대기
  • 3회 실패: 2초 대기
  • 4회 실패: 4초 대기

왜 지터(jitter)가 필요한가

여러 워커가 동시에 429를 맞으면 모두가 같은 백오프 곡선을 타면서 “동시에 깨어나” 다시 요청을 던집니다. 이 동기화 현상을 깨기 위해 지터를 넣습니다.

대표 패턴:

  • Full jitter: sleep = random(0, base * 2^attempt)
  • Equal jitter: sleep = base * 2^(attempt-1) + random(0, base * 2^(attempt-1))

실무에서는 Full jitter가 간단하고 효과적입니다.

어떤 에러를 재시도할 것인가

재시도를 걸기 전에 분류부터 해야 합니다.

  • 재시도 권장
    • 429 (rate limit)
    • 500, 502, 503, 504 (일시적 서버/게이트웨이 문제)
    • 네트워크 타임아웃, 커넥션 리셋 등 일시적 네트워크 오류
  • 재시도 비권장
    • 400 (잘못된 파라미터)
    • 401, 403 (인증/권한)
    • 404 (리소스 없음)
    • 409 (상황에 따라 다름, 멱등성 설계 필요)

그리고 “재시도 횟수”는 무한이 아니라 상한이 있어야 합니다. 상한이 없으면 장애 시 폭주가 됩니다.

Node.js(서버)에서의 재시도·백오프 예제

아래 코드는 Next.js API Route나 백엔드 서버에서 그대로 적용 가능한 형태의 예시입니다. 핵심은 429를 포함한 재시도 대상 에러만 선별하고, 지수 백오프에 지터를 섞어 대기하는 것입니다.

type RetryOptions = {
  maxAttempts: number;
  baseDelayMs: number;
  maxDelayMs: number;
};

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

function isRetryableStatus(status?: number) {
  return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
}

function getStatusFromError(err: any): number | undefined {
  return err?.status ?? err?.response?.status;
}

function computeBackoffWithFullJitter(attempt: number, baseDelayMs: number, maxDelayMs: number) {
  const exp = baseDelayMs * Math.pow(2, attempt - 1);
  const capped = Math.min(exp, maxDelayMs);
  return Math.floor(Math.random() * capped);
}

export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions): Promise<T> {
  let lastErr: any;

  for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      lastErr = err;
      const status = getStatusFromError(err);

      if (!isRetryableStatus(status)) {
        throw err;
      }

      if (attempt === opts.maxAttempts) {
        throw err;
      }

      const delay = computeBackoffWithFullJitter(attempt, opts.baseDelayMs, opts.maxDelayMs);
      await sleep(delay);
    }
  }

  throw lastErr;
}

이제 OpenAI 호출부를 withRetry로 감싸면 됩니다.

import OpenAI from "openai";

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

export async function generateSummary(prompt: string) {
  return withRetry(
    async () => {
      const res = await client.responses.create({
        model: "gpt-4.1-mini",
        input: prompt,
      });
      return res;
    },
    {
      maxAttempts: 6,
      baseDelayMs: 300,
      maxDelayMs: 10_000,
    }
  );
}

팁: Retry-After가 있다면 우선 적용

일부 환경에서는 Retry-After 헤더가 힌트로 제공될 수 있습니다. 가능하다면 이 값을 우선하고, 없을 때만 백오프를 계산하는 방식이 더 안전합니다.

단, SDK가 헤더를 직접 노출하지 않는 경우도 있어 “있으면 적용” 정도로만 설계해두는 편이 현실적입니다.

동시성 제한이 없으면 재시도는 독이 된다

재시도 로직만 넣고 동시성 제한을 하지 않으면, 서버가 바쁠 때 요청이 동시에 몰려 429가 연쇄적으로 발생합니다. 특히 워커나 서버리스 환경에서 인스턴스가 늘어나는 순간, 제한을 더 쉽게 밟습니다.

가장 단순하면서 효과적인 방법은 동시 요청 수를 제한하는 것입니다.

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

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

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

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

const openAiSemaphore = new Semaphore(5);

export async function guardedGenerate(prompt: string) {
  await openAiSemaphore.acquire();
  try {
    return await generateSummary(prompt);
  } finally {
    openAiSemaphore.release();
  }
}
  • 서버 한 인스턴스 내에서라도 동시성을 제한하면 429 빈도가 눈에 띄게 줄어듭니다.
  • 다중 인스턴스라면 Redis 기반 분산 세마포어, 혹은 작업 큐로 확장하는 것이 좋습니다.

큐잉으로 “즉시 처리” 집착을 버리기

사용자 요청마다 OpenAI 호출을 동기 처리하면, 트래픽이 조금만 튀어도 429가 나기 쉽습니다. 다음 중 하나를 고려하세요.

  • 비동기 작업 큐(예: BullMQ, SQS, Cloud Tasks)에 넣고 워커가 처리
  • 사용자에게는 작업 생성 응답을 주고, 결과는 폴링 또는 웹훅/소켓으로 전달

이 패턴은 DB 락이나 데드락을 다룰 때 “즉시 재시도”가 아니라 “충돌을 줄이는 구조”로 접근하는 것과 유사합니다. 트러블슈팅 관점이 궁금하면 MySQL InnoDB 데드락 로그로 범인 쿼리 찾기도 같은 결의 사고방식을 제공합니다.

멱등성: 재시도로 중복 과금·중복 작업을 막기

429는 “요청이 처리되지 않았음”을 보장하지 않습니다. 네트워크 단에서 타임아웃이 났을 때는 특히 애매합니다. 따라서 재시도를 설계할 때는 아래 중 하나가 필요합니다.

  • 요청 단위의 멱등 키를 만들어 서버 내부에서 중복 실행 방지
  • 결과를 캐시하고 같은 입력은 일정 시간 동일 결과를 재사용
  • 작업 ID를 발급하고 같은 작업 ID는 한 번만 실행

예: hash(userId + prompt + model + params)로 키를 만들고, 이미 처리 중이면 기존 작업을 기다리게 만들 수 있습니다.

관측: 429를 “숫자”로 보지 않으면 개선이 안 된다

실제로 운영에서 중요한 것은 “가끔 429가 난다”가 아니라 다음을 계량화하는 것입니다.

  • 분당 429 발생률
  • 재시도 횟수 분포(평균, p95)
  • 백오프 총 대기 시간(사용자 지연에 직결)
  • 모델별, 엔드포인트별, 테넌트별 제한 초과 여부

로그에는 최소한 아래를 남기는 것을 추천합니다.

  • status (예: 429)
  • attempt
  • delayMs
  • 요청의 논리적 종류(요약, 분류, 임베딩 등)
  • 토큰 추정치 또는 입력 크기

클라이언트 성능 문제를 볼 때 Long Task를 추적하듯이, 백엔드도 원인을 분해해 추적해야 합니다. 프런트 관측 방식이 궁금하면 Chrome INP 점수 급락? Long Task 추적·해결도 참고할 만합니다.

실전 권장 설정(출발점)

서비스마다 다르지만, 출발점으로 아래 조합이 무난합니다.

  • 동시성 제한: 인스턴스당 3~10부터 시작
  • 재시도 횟수: 4~6회
  • baseDelayMs: 200~500ms
  • maxDelayMs: 10~30초
  • 지터: Full jitter
  • 타임아웃: 호출 전체에 상한(예: 30초~60초) 부여

중요한 것은 “재시도 횟수”보다 “동시성 제한 + 지터”가 체감 안정성에 더 크게 기여한다는 점입니다.

체크리스트

  • 429를 즉시 재시도하지 않고, 지수 백오프 + 지터를 적용했는가
  • 재시도 대상 에러를 선별했는가(모든 에러를 재시도하지 않는가)
  • 인스턴스 내 동시성 제한이 있는가
  • 다중 인스턴스 환경에서 집단 재시도 폭주를 막을 장치가 있는가(큐, 분산 제한)
  • 멱등성(중복 실행 방지)을 고려했는가
  • 429 발생률과 재시도 비용을 메트릭으로 보고 있는가

마무리

OpenAI의 429는 회피해야 할 “예외”가 아니라, 시스템이 스스로 트래픽을 조절하도록 요구하는 “프로토콜”에 가깝습니다. 지수 백오프와 지터는 기본이고, 동시성 제한과 큐잉, 멱등성까지 포함해 설계하면 429는 장애가 아니라 자연스러운 압력 조절 장치가 됩니다.

다음 단계로는 서비스 특성에 맞춰 “요청을 줄이는 전략”도 병행해보세요. 예를 들어 프롬프트 캐시, 결과 캐시, 배치 처리, 작은 모델로 1차 처리 후 필요한 경우에만 큰 모델 호출 같은 방식이 429 자체를 근본적으로 줄여줍니다.