Published on

OpenAI 429/Rate Limit 대응 - 재시도·백오프·큐잉

Authors

서버에서 OpenAI API를 호출하다 보면 가장 자주 마주치는 장애 중 하나가 HTTP 429 (Too Many Requests / Rate Limit) 입니다. 429는 “요청을 너무 빨리/너무 많이 보냈다”는 신호이지만, 단순히 sleep(1)로 땜질하면 스파이크 트래픽, 동시성 증가, 배치 작업에서 곧바로 다시 터집니다.

이 글에서는 429를 시스템적으로 다루는 방법을 정리합니다.

  • 재시도는 언제/어떻게 해야 하는가
  • 지수 백오프 + 지터(jitter)로 왜 ‘동시 재시도 폭주’를 막는가
  • 애초에 429가 나지 않게 큐잉/워크풀로 호출량을 평탄화하는 방법
  • 운영에서 필요한 관측(로그/메트릭) 포인트

> 참고: 429는 OpenAI만의 문제가 아니라, 외부 API를 붙이는 모든 서비스에서 반복되는 패턴입니다. 특히 쿠버네티스/EKS 환경에서 트래픽이 순간적으로 튀면 5xx/4xx가 연쇄적으로 발생할 수 있는데, 이런 장애 진단 관점은 EKS에서 Envoy 503 UF·URX 원인과 해결 10분도 함께 보면 좋습니다.

1) OpenAI 429의 본질: “실패”가 아니라 “흐름 제어 신호”

429는 보통 아래 상황에서 발생합니다.

  • 짧은 시간에 요청이 몰림(동시성 폭증)
  • 스트리밍/긴 응답으로 인해 연결이 오래 유지되며 동시 사용량이 증가
  • 배치/크론이 같은 시각에 몰려 실행
  • 클라이언트가 재시도 폭주(서로 같은 타이밍에 재시도)

핵심은 429를 일시적(transient) 오류로 보고, “성공할 때까지 무한 재시도”가 아니라 정책 기반 재시도 + 상한 + 큐잉으로 통제해야 한다는 점입니다.

2) 재시도 정책: 무엇을 재시도하고, 무엇은 즉시 실패할 것인가

재시도는 만능이 아닙니다. 다음 기준이 실무에서 안전합니다.

2.1 재시도 대상

  • 429: 대표적인 재시도 대상
  • 408/409/5xx(일부): 네트워크/일시 장애 성격이면 재시도 가능

2.2 재시도하면 안 되는 것

  • 인증/권한(401/403): 키 문제, 권한 문제는 재시도해도 해결되지 않음
  • 검증 실패(400): 프롬프트/파라미터 오류는 즉시 실패 후 수정 필요

2.3 반드시 필요한 상한

  • 최대 재시도 횟수
  • 최대 대기 시간(예: 전체 20~60초)
  • 요청 타임아웃(서버에서 무한 대기 방지)

3) 지수 백오프 + 지터: “다 같이 재시도”를 피하는 핵심

429가 났을 때 모든 워커가 동일하게 sleep(1) 후 재시도하면, 1초 뒤에 다시 한 번 동시에 몰려 또 429가 납니다(동기화된 재시도 폭주).

이를 막는 정석이 Exponential Backoff + Jitter 입니다.

  • 백오프(지수 증가): 0.5s → 1s → 2s → 4s …
  • 지터(랜덤 흔들기): 각 시도마다 ±랜덤을 섞어 재시도 타이밍을 분산

3.1 Node.js(Typescript) 예제: 429 재시도 + 지터

아래 예제는 fetch 기반으로 429/5xx에 대해 재시도하며, Retry-After 헤더가 있으면 우선 존중합니다.

type RetryOptions = {
  maxAttempts: number;      // 총 시도 횟수(최초 1회 포함)
  baseDelayMs: number;      // 초기 딜레이
  maxDelayMs: number;       // 딜레이 상한
  timeoutMs: number;        // 요청 타임아웃
};

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

function calcJitteredBackoff(attempt: number, base: number, max: number) {
  // attempt: 1부터 시작(첫 재시도)
  const exp = Math.min(max, base * 2 ** (attempt - 1));
  // full jitter: 0 ~ exp 사이 랜덤
  return Math.floor(Math.random() * exp);
}

async function fetchWithTimeout(input: RequestInfo, init: RequestInit, timeoutMs: number) {
  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const res = await fetch(input, { ...init, signal: controller.signal });
    return res;
  } finally {
    clearTimeout(t);
  }
}

export async function callOpenAIWithRetry(
  url: string,
  init: RequestInit,
  opt: RetryOptions
) {
  let lastErr: unknown;

  for (let attempt = 1; attempt <= opt.maxAttempts; attempt++) {
    try {
      const res = await fetchWithTimeout(url, init, opt.timeoutMs);

      if (res.ok) return res;

      // 재시도 대상 판별
      const retryable = res.status === 429 || (res.status >= 500 && res.status <= 599);
      if (!retryable) {
        const body = await res.text().catch(() => "");
        throw new Error(`Non-retryable status=${res.status} body=${body}`);
      }

      // 마지막 시도면 실패
      if (attempt === opt.maxAttempts) {
        const body = await res.text().catch(() => "");
        throw new Error(`Retry exhausted status=${res.status} body=${body}`);
      }

      // Retry-After 우선
      const ra = res.headers.get("retry-after");
      let waitMs: number | null = null;
      if (ra) {
        const sec = Number(ra);
        if (!Number.isNaN(sec)) waitMs = sec * 1000;
      }

      if (waitMs == null) {
        waitMs = calcJitteredBackoff(attempt, opt.baseDelayMs, opt.maxDelayMs);
      }

      await sleep(waitMs);
      continue;
    } catch (e) {
      lastErr = e;
      // 네트워크 오류/Abort 등도 일시 오류로 간주할지 정책 결정
      if (attempt === opt.maxAttempts) break;
      const waitMs = calcJitteredBackoff(attempt, opt.baseDelayMs, opt.maxDelayMs);
      await sleep(waitMs);
    }
  }

  throw lastErr;
}

포인트

  • Retry-After가 있으면 이를 우선 적용
  • 지터는 full jitter(0~exp 랜덤)가 운영에서 무난
  • timeoutMs는 반드시 둬서 “재시도 + 무한 대기” 조합을 막기

4) “재시도만”으로는 부족하다: 큐잉/워크풀로 429를 예방

재시도는 사후 대응입니다. 트래픽이 지속적으로 한도를 넘으면 재시도는 오히려 비용과 지연만 늘립니다. 이때 필요한 게 큐잉(Queueing)동시성 제한(Concurrency Limit) 입니다.

4.1 단일 프로세스에서의 간단한 큐: 동시성 N 제한

Node.js에서는 p-limit 같은 라이브러리로 간단히 “동시 호출 개수”를 제한할 수 있습니다.

import pLimit from "p-limit";

const limit = pLimit(5); // 동시에 5개만 OpenAI 호출

async function runJobs(jobs: Array<() => Promise<unknown>>) {
  return Promise.all(
    jobs.map((job) =>
      limit(async () => {
        // 여기서 callOpenAIWithRetry 같은 래퍼로 감싸기
        return job();
      })
    )
  );
}

이 방식은 단일 인스턴스에서는 효과가 좋지만, 서버가 여러 대로 스케일아웃되면 인스턴스별로 5개씩 총량이 늘어나 다시 429가 날 수 있습니다.

4.2 분산 환경에서는 “전역 제한”이 필요

스케일아웃된 환경(EKS/서버리스/다중 워커)에서는 다음 중 하나를 고려합니다.

  • 중앙 큐(SQS/RabbitMQ/Kafka/Redis Streams)
  • 토큰 버킷/리키 버킷을 Redis로 전역 구현
  • API Gateway/Envoy 레이트리밋 필터로 경계에서 제한

특히 MSA에서 결제/주문처럼 “한 번만 처리되어야 하는 작업”을 큐로 흘릴 때는 중복 처리 방지가 중요합니다. 이 관점은 MSA 사가 실패로 중복결제 터질 때 Outbox로 막기에서 다룬 Outbox 패턴과도 연결됩니다(큐잉은 재시도와 항상 짝을 이룹니다).

5) 백프레셔(Backpressure): 큐가 쌓일 때 시스템을 보호하는 법

큐잉을 넣으면 429는 줄지만, 대신 큐 적체라는 새로운 문제가 생깁니다. 따라서 백프레셔 전략이 필요합니다.

  • 큐 길이가 임계치 초과 시: 새 요청을 429/503으로 빠르게 거절(서버 보호)
  • 우선순위 큐: 사용자 요청(온라인)과 배치(오프라인)를 분리
  • 요청 단위 비용 기반 스케줄링: “긴 작업”이 “짧은 작업”을 굶기지 않게

운영에서는 “OpenAI 호출이 느려져서 워커가 막히고, 그 여파로 다른 컴포넌트가 연쇄 장애”가 자주 발생합니다. 쿠버네티스라면 이게 결국 재시작 루프로 번지기도 하니, 장애가 반복될 경우 Kubernetes CrashLoopBackOff 10가지 원인과 15분 진단 같은 체크리스트 관점으로도 함께 점검하는 게 좋습니다.

6) 설계 체크리스트: 실전에서 놓치기 쉬운 디테일

6.1 Idempotency(멱등성) 고려

재시도는 동일 요청을 여러 번 보낼 수 있습니다. 따라서

  • 가능하면 요청 키(작업 ID) 를 두고 결과를 캐시/저장
  • 서버 측에서 동일 작업을 중복 수행하지 않도록 방지

6.2 부분 실패 처리

스트리밍 응답에서 중간에 끊기면 “부분 결과”가 남습니다.

  • 재시도 시 동일 프롬프트를 그대로 보내면 결과가 달라질 수 있음
  • 사용자에게는 “재시도 중” 상태를 명확히 표시
  • 중요 업무라면 결과를 단계적으로 저장(체크포인트)

6.3 관측(Observability)

429 대응은 튜닝의 영역입니다. 다음 지표를 최소로 잡으세요.

  • 429 발생률(모델/엔드포인트/테넌트별)
  • 재시도 횟수 분포(p50/p95)
  • 큐 길이, 큐 대기 시간
  • OpenAI 호출 레이턴시(p95/p99)

로그에는 다음이 있으면 원인 분석이 빨라집니다.

  • status, request_id(있다면), attempt, wait_ms, queue_delay_ms

7) 권장 아키텍처 예시(요약)

7.1 간단한 웹앱(단일 서버)

  • 동시성 제한(p-limit)
  • 429/5xx 재시도(지수 백오프 + 지터)
  • 타임아웃/서킷 브레이커(간단히라도)

7.2 프로덕션 MSA/스케일아웃

  • 중앙 큐(SQS/Kafka 등)로 작업 분리
  • 워커 풀에서 제한된 동시성으로 OpenAI 호출
  • 전역 레이트리밋(필요 시 Redis 토큰 버킷)
  • 백프레셔(큐 길이 기반 거절/우선순위)
  • 멱등성/중복 방지(Outbox/작업 ID)

8) 마무리: 429는 “조절하라”는 신호다

OpenAI 429는 단순 오류가 아니라, 시스템이 외부 자원을 사용하는 방식에 대해 “속도를 조절하라”고 말하는 신호입니다.

  • 재시도는 필수지만, 지수 백오프 + 지터가 없으면 재시도가 또 다른 장애를 만듭니다.
  • 트래픽이 스파이크를 가진다면, 근본 처방은 큐잉과 동시성 제한입니다.
  • 운영 단계에서는 메트릭 기반 튜닝(429율, 재시도 분포, 큐 대기)을 통해 “안정성과 비용”의 균형점을 찾아야 합니다.

원한다면 사용 중인 스택(Next.js/Express/FastAPI/Spring), 배포 형태(EKS/서버리스), 트래픽 패턴(동시 사용자/배치 주기)을 알려주면 그 조건에 맞춘 레이트리밋/큐잉 설계와 코드(예: Redis 토큰 버킷, SQS 워커, BullMQ)로 더 구체화해드릴 수 있습니다.