Published on

OpenAI 429·Rate Limit 에러 재시도 설계

Authors

운영 환경에서 OpenAI API를 붙이면 생각보다 빨리 429(Rate Limit)와 마주칩니다. 개발 단계에서는 트래픽이 작아 재현이 어렵지만, 배포 후에는 동시 요청, 배치 작업, 재시도 폭풍이 겹치면서 순간적으로 한도를 넘기기 쉽습니다. 문제는 429 자체보다도 잘못된 재시도 전략이 장애를 증폭시킨다는 점입니다. 즉시 재시도는 더 많은 429를 만들고, 워커가 바쁘게 돌며 비용과 지연을 키우고, 결국 사용자 체감 장애로 이어집니다.

이 글에서는 OpenAI 429를 “네트워크 오류처럼 몇 번 더 쏘면 된다”로 취급하지 않고, 재시도 정책을 시스템 설계로 끌어올리는 방법을 다룹니다. 특히 백오프, 지터, 동시성 제한, 큐잉, idempotency, 관측(로그·메트릭)까지 한 번에 정리합니다.

관련해서 더 많은 패턴을 훑고 싶다면 다음 글도 함께 보세요.

429를 “일시적 실패”로만 보면 생기는 함정

429는 보통 “잠깐 쉬었다가 다시 요청하라”는 의미지만, 운영에서는 다음 두 가지가 섞여 나타납니다.

  1. 진짜 Rate Limit 초과
    • 초당 요청 수(RPS), 분당 토큰(TPM) 같은 제한을 넘김
    • 특정 모델/프로젝트 단위 제한
  2. 자체 보호(서버 혼잡) 성격의 제한
    • 순간 부하로 인한 제한
    • 동시 요청이 몰려 큐가 과도해짐

이때 고정 딜레이 sleep(1s) 같은 단순 재시도는 거의 항상 실패합니다. 이유는 다음과 같습니다.

  • 여러 인스턴스가 동시에 같은 딜레이로 깨어나서 동기화된 재시도 폭주가 발생
  • 실패한 요청이 쌓여 대기열이 늘고 타임아웃이 증가
  • 타임아웃을 retry가 다시 증폭시키며 양의 피드백 루프 형성

따라서 429 재시도는 “몇 번 더”가 아니라, 재시도 속도 제어와 요청량 제어가 핵심입니다.

설계 목표: 무엇을 보장할 것인가

재시도 설계를 시작하기 전에 목표를 명확히 두면 구현이 쉬워집니다.

  • 사용자 요청 경로(온라인)는 짧은 예산 내에서만 재시도하고, 실패 시 우아하게 degrade
  • 배치/비동기 워커는 큐 기반으로 흡수하고, 제한 내에서 천천히 처리
  • 같은 작업을 중복 수행하지 않도록 idempotency 키/작업 키를 둠
  • 재시도는 무한이 아니라 최대 시도 횟수 + 최대 경과 시간으로 제한
  • 429가 늘어나면 자동으로 동시성을 줄이고, 회복되면 천천히 늘리는 적응형 컨트롤

핵심 원칙 5가지

1) Exponential Backoff + Full Jitter

가장 기본이면서도 효과가 큰 조합입니다.

  • 지수 백오프: base * 2^attempt
  • 지터: 재시도 대기 시간을 랜덤화해 동기화 폭주를 방지

권장 형태는 흔히 말하는 Full Jitter입니다.

  • sleep = random(0, min(cap, base * 2^attempt))

고정 딜레이보다 훨씬 안정적으로 수렴합니다.

2) Retry-After를 존중

서버가 Retry-After(초 단위 또는 날짜)를 주는 경우가 있습니다. 이 값이 있으면 백오프보다 우선합니다.

  • retryAfter가 있으면 그 시간 이상 대기
  • 단, 너무 큰 값이면(예: 수 분 이상) 온라인 경로에서는 즉시 실패로 전환하고 비동기 큐로 넘기는 게 낫습니다.

3) 동시성 제한(클라이언트 측 Rate Limiter)

재시도만 잘해도 부족합니다. 애초에 한도를 넘지 않게 클라이언트에서 동시성/속도 제한을 걸어야 합니다.

  • 인스턴스 단위 제한(프로세스 내 토큰 버킷)
  • 여러 인스턴스가 있으면 분산 레이트리밋(예: Redis 기반)

특히 서버리스/오토스케일 환경에서는 인스턴스 수가 늘면서 총 요청량이 갑자기 증가하므로, 중앙집중형 제한이 필요해집니다.

4) 재시도 가능한 것과 불가능한 것 분리

429는 대체로 재시도 대상이지만, 다음은 재시도해도 의미가 없습니다.

  • 인증/권한 문제(401, 403)
  • 요청 형식 오류(400, 415 등)
  • 잘못된 파라미터로 인한 지속 실패

즉, 재시도 정책은 status code와 에러 타입을 기준으로 분기해야 합니다.

5) 온라인 경로와 비동기 경로를 분리

사용자 요청(HTTP 동기 응답)에서 429가 나면 길게 재시도할수록 사용자 체감이 나빠집니다.

  • 온라인: 짧은 재시도(예: 13회, 총 25초 예산)
  • 비동기: 큐 적재 후 워커가 천천히 처리(수 분~수 시간까지)

이 분리가 되어야 429가 나도 서비스가 “느려질 뿐 죽지 않는” 형태가 됩니다.

Node.js/TypeScript: 429 재시도 래퍼 예제

아래 예제는 다음을 포함합니다.

  • Retry-After 파싱
  • Full Jitter 기반 백오프
  • 최대 시도 횟수 및 최대 경과 시간 제한
  • 429 및 일시적 5xx만 재시도
type RetryOptions = {
  maxAttempts: number;
  baseDelayMs: number;
  capDelayMs: number;
  maxElapsedMs: number;
};

function parseRetryAfterMs(headers: Headers): number | null {
  const v = headers.get('retry-after');
  if (!v) return null;

  // seconds 형식
  const seconds = Number(v);
  if (!Number.isNaN(seconds)) return Math.max(0, seconds * 1000);

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

  return null;
}

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

function isRetryableStatus(status: number) {
  // 429 + 일시적 서버 오류만 재시도
  return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
}

export async function withRetry<T>(
  fn: () => Promise<{ data: T; response: Response }>,
  opts: RetryOptions
): Promise<T> {
  const start = Date.now();
  let lastErr: unknown;

  for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
    const elapsed = Date.now() - start;
    if (elapsed > opts.maxElapsedMs) break;

    try {
      const { data, response } = await fn();

      if (!isRetryableStatus(response.status)) {
        if (!response.ok) {
          throw new Error(`Non-retryable status: ${response.status}`);
        }
        return data;
      }

      // retryable status
      const retryAfter = parseRetryAfterMs(response.headers);
      const delay = retryAfter ?? fullJitterDelayMs(attempt, opts.baseDelayMs, opts.capDelayMs);

      // 마지막 시도면 종료
      if (attempt === opts.maxAttempts - 1) {
        throw new Error(`Retry exhausted with status: ${response.status}`);
      }

      await new Promise((r) => setTimeout(r, delay));
      continue;
    } catch (e) {
      lastErr = e;

      // 네트워크 오류 등도 제한적으로 재시도할 수 있지만,
      // 여기서는 단순화를 위해 attempt 기반 백오프만 적용
      const delay = fullJitterDelayMs(attempt, opts.baseDelayMs, opts.capDelayMs);
      if (attempt === opts.maxAttempts - 1) break;
      await new Promise((r) => setTimeout(r, delay));
    }
  }

  throw lastErr instanceof Error ? lastErr : new Error('Retry failed');
}

fn은 OpenAI 호출을 감싸는 형태로 구현합니다. 예를 들어 Responses API를 쓴다면 대략 아래처럼 구성할 수 있습니다.

import OpenAI from 'openai';

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

async function callOpenAI(prompt: string) {
  return withRetry(
    async () => {
      const res = await client.responses.create({
        model: 'gpt-4.1-mini',
        input: prompt,
      });

      // SDK가 Response 객체를 직접 노출하지 않는 경우가 있어,
      // 실제 구현에서는 SDK 문서에 맞게 status/headers 접근 방식을 조정해야 합니다.
      // 여기 예제는 개념 설명용입니다.
      const fakeResponse = new Response(null, { status: 200 });

      return { data: res, response: fakeResponse };
    },
    {
      maxAttempts: 4,
      baseDelayMs: 200,
      capDelayMs: 4000,
      maxElapsedMs: 6000,
    }
  );
}

주의: OpenAI SDK가 내부적으로 fetch를 감싸기 때문에 status/headers를 어떻게 얻는지는 버전마다 다릅니다. 운영에서는 “SDK가 제공하는 에러 객체에 status가 있는지”, 또는 “직접 fetch로 호출해 헤더를 읽을지”를 먼저 결정하세요.

분산 환경에서의 동시성 제어: Redis 토큰 버킷 스케치

오토스케일링 환경에서는 인스턴스별 제한만으로는 부족합니다. 가장 흔한 해법은 Redis로 토큰 버킷(또는 리키 버킷)을 구현해 클러스터 전체 요청률을 제한하는 것입니다.

아래는 매우 단순화한 형태의 “초당 N개” 제한 예시입니다.

import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

// key 예: openai:rps:projectA
async function allowRequest(key: string, limitPerSec: number): Promise<boolean> {
  const nowSec = Math.floor(Date.now() / 1000);
  const windowKey = `${key}:${nowSec}`;

  const tx = redis.multi();
  tx.incr(windowKey);
  tx.expire(windowKey, 2);
  const [count] = (await tx.exec())!.map((r) => r as number);

  return count <= limitPerSec;
}

async function guardedCall() {
  const ok = await allowRequest('openai:rps:projectA', 20);
  if (!ok) {
    // 여기서 바로 429를 내기보다는 큐로 넘기거나, 짧게 대기 후 재시도 정책을 적용
    throw new Error('Client-side rate limited');
  }

  // OpenAI 호출
}

실전에서는 RPS뿐 아니라 토큰 기반 제한이 더 중요합니다. 토큰은 요청마다 다르므로 “예상 토큰”으로 선점하고, 실제 사용량으로 사후 보정하는 방식(낙관적/비관적)을 선택해야 합니다.

재시도 폭풍을 막는 서킷 브레이커와 적응형 동시성

429가 일정 비율 이상 나오면, 재시도를 아무리 예쁘게 해도 결국 한도 초과 상태가 지속됩니다. 이때는 서킷 브레이커를 두어 빠르게 실패시키고, 시스템을 안정화해야 합니다.

  • 최근 1분 동안 429 비율이 임계치 이상이면 “open”
  • open 상태에서는 즉시 실패 또는 큐 적재
  • 일정 쿨다운 후 “half-open”에서 소량만 시도
  • 성공률이 회복되면 “closed”로 복귀

또한 동시성은 고정값보다 AIMD(Additive Increase, Multiplicative Decrease) 같은 적응형이 효과적입니다.

  • 성공 시 동시성 +1
  • 429 발생 시 동시성 *0.5

이 방식은 네트워크 혼잡 제어와 유사하게 수렴합니다.

Idempotency: 같은 작업을 중복 처리하지 않기

재시도는 “같은 요청을 다시 보낸다”는 뜻입니다. 따라서 다음이 반드시 필요합니다.

  • 작업 단위의 고유 키(예: jobId, messageId)
  • 결과 저장(캐시 또는 DB)
  • 동일 키로 재요청 시 기존 결과를 반환하거나, 진행 중이면 대기/합류

특히 큐 기반 비동기 처리에서는 “at-least-once 전달”이 일반적이라 중복 실행이 자연스럽게 발생합니다. 이때 idempotency가 없으면 OpenAI 호출이 중복되어 비용이 증가하고, 결과가 뒤섞일 수 있습니다.

보상/재처리 설계를 더 넓은 관점에서 보고 싶다면 Saga 보상 트랜잭션 실패 재처리 설계 가이드가 도움이 됩니다.

관측(Observability): 재시도는 반드시 숫자로 관리한다

운영에서 필요한 최소 메트릭은 아래 정도입니다.

  • openai_requests_total{status}
  • openai_retries_total{reason}
  • openai_retry_delay_ms 히스토그램
  • openai_queue_depth (큐 사용 시)
  • openai_circuit_state (0, 1, 2 같은 값)
  • 사용자 요청의 p95/p99 latency

로그에는 다음을 남기면 디버깅이 빨라집니다.

  • requestId(클라이언트 요청 추적)
  • jobId(비동기 작업 추적)
  • attempt, delayMs, status, retryAfterMs
  • 모델명, 예상 토큰, 실제 토큰(가능하면)

이렇게 해두면 “429가 늘었다”가 아니라 “어떤 워크로드가 어떤 모델에서 어떤 시간대에 제한을 때렸는지”까지 내려가 원인을 찾을 수 있습니다.

실전 체크리스트

온라인 API(동기) 경로

  • 재시도는 13회, 총 예산 25초 내
  • Retry-After가 길면 즉시 실패하고 비동기 처리로 전환
  • 사용자에게는 대체 응답(요약 품질 낮추기, 캐시 결과 제공, 잠시 후 재시도 안내)

비동기 워커 경로

  • 큐로 흡수하고 워커 동시성 제한
  • Full Jitter 백오프 + 긴 maxElapsed 허용
  • idempotency 키로 중복 실행 방지
  • DLQ(Dead Letter Queue)로 영구 실패 분리

공통

  • 429 비율 급증 시 서킷 브레이커로 빠른 차단
  • Redis 등으로 분산 레이트리밋 적용(오토스케일 대비)
  • 메트릭으로 재시도와 지연을 상시 모니터링

마무리

OpenAI 429는 “가끔 나오는 예외”가 아니라, 트래픽이 성장할수록 반드시 만나는 용량 관리 이벤트입니다. 재시도는 단순한 유틸 함수가 아니라, 백오프·지터·동시성 제한·큐잉·idempotency·서킷 브레이커까지 연결된 하나의 설계 문제입니다.

한 번만 정리해두면 효과는 확실합니다. 429가 발생해도 시스템이 스스로 속도를 줄이고, 요청을 큐에 쌓아 처리하며, 사용자는 큰 장애를 체감하지 않는 구조로 바뀝니다.