Published on

OpenAI 429·rate_limit 재시도·백오프 설계 가이드

Authors

서버에서 OpenAI API를 붙이다 보면 가장 자주 마주치는 장애 중 하나가 429rate_limit입니다. 에러 자체는 “요청을 너무 많이 보냈다”는 단순한 의미지만, 실제 운영 환경에서는 다음 문제가 한꺼번에 터집니다.

  • 재시도 로직이 없어서 사용자 요청이 즉시 실패한다
  • 재시도를 무작정 걸어 비용이 폭증하거나, 더 큰 트래픽 폭풍(thundering herd)을 만든다
  • 여러 워커/파드가 동시에 백오프에서 깨어나 다시 동시에 때리며 2차 장애가 난다
  • 동일 요청이 중복 실행되어 DB 업데이트/결제/알림 같은 사이드이펙트가 중복된다

이 글에서는 OpenAI 429·rate_limit을 “정상적인 운영 이벤트”로 다루는 관점에서, 재시도·백오프·지터·동시성 제어·서킷 브레이커·아이템포턴시까지 포함한 설계를 정리합니다.

429·rate_limit의 의미를 분해하기

OpenAI 계열 API에서 429는 보통 다음 케이스로 나뉩니다.

  1. 순수 rate limit 초과
  • 일정 시간당 요청 수(RPS) 또는 토큰 처리량(TPM)을 초과
  • 순간 스파이크(배치, 팬아웃, 재시도 폭풍)에서 자주 발생
  1. 동시 요청(concurrency) 과다
  • 요청 수 자체는 적어도, 동시에 수행되는 요청이 많아 제한에 걸림
  1. 조직/프로젝트 단위 제한 및 버짓 제약
  • 프로젝트 설정의 제한과 충돌

핵심은 429를 “조금 기다리면 성공 가능한 일시 오류”로 취급하되, 무지성 재시도는 더 큰 장애를 만든다는 점입니다.

재시도 설계의 기본 원칙

재시도는 다음 4가지를 반드시 함께 고려해야 합니다.

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

  • 재시도 권장: 429, 408, 409(일부), 500, 502, 503, 504
  • 재시도 비권장: 400(요청 자체 문제), 401/403(인증/권한), 404(리소스), 422(검증)

단, 400이라도 스키마/툴 호출 구조가 잘못된 경우처럼 “코드 수정”이 필요한 케이스가 있습니다. 이런 경우는 재시도가 아니라 입력 검증을 강화해야 합니다. (유사한 맥락으로는 Claude Tool Use 400 에러, JSON Schema로 해결 글의 접근이 도움이 됩니다.)

2) 재시도는 몇 번까지 할 것인가

  • 사용자 동기 요청: 보통 2~4회 내에서 타임아웃 예산을 맞추기
  • 비동기 작업/큐: 5~10회 이상도 가능하나, 총 지연 상한을 두기

3) 백오프는 어떤 형태로 할 것인가

  • 지수 백오프(exponential backoff): base * 2^attempt
  • 지터(jitter): 여러 인스턴스가 동시에 깨어나는 문제를 완화

4) 재시도에도 아이템포턴시가 필요한가

LLM 호출은 “같은 프롬프트라도 결과가 달라질 수 있음”을 떠나서, 호출 이후의 사이드이펙트(저장, 결제, 발송)가 중복되면 치명적입니다.

  • 재시도는 “동일 작업의 중복 실행”을 유발할 수 있으므로
  • idempotency key 또는 작업 키 기반 중복 제거가 필요합니다

백오프·지터 패턴: 정답은 ‘Full Jitter’에 가깝다

가장 널리 권장되는 패턴 중 하나는 아래와 같은 Full Jitter 계열입니다.

  • 지수 백오프로 상한(max)을 만든 뒤
  • 0부터 그 값 사이의 랜덤 값을 실제 대기 시간으로 선택

이 방식은 동시성 높은 환경에서 “같은 타이밍에 다시 몰리는 현상”을 크게 줄입니다.

백오프 계산 예시(의사 코드)

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

여기서 baseDelay는 보통 200ms~1s, maxDelay10s~60s 범위에서 잡습니다.

Node.js(Typescript) 예제: 429 재시도 + Full Jitter + 타임아웃 예산

아래 코드는 다음을 포함합니다.

  • 429 및 일부 5xx에만 재시도
  • Full Jitter 백오프
  • 전체 요청에 대한 타임아웃 예산(deadline)
  • 재시도 횟수 제한
type RetryOptions = {
  maxAttempts: number; // 총 시도 횟수(최초 1회 포함)
  baseDelayMs: number;
  maxDelayMs: number;
  deadlineMs: number; // 전체 예산
};

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

function isRetryableStatus(status: number) {
  return status === 429 || status === 408 || status === 409 || (status >= 500 && status <= 504);
}

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

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

  for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
    const elapsed = Date.now() - startedAt;
    if (elapsed > opts.deadlineMs) {
      throw new Error(`retry_deadline_exceeded after ${elapsed}ms`);
    }

    try {
      return await fn();
    } catch (err: any) {
      lastErr = err;

      const status = err?.status ?? err?.response?.status;
      if (typeof status !== "number" || !isRetryableStatus(status)) {
        throw err;
      }

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

      const delay = fullJitterDelayMs(attempt - 1, opts.baseDelayMs, opts.maxDelayMs);
      const remaining = opts.deadlineMs - (Date.now() - startedAt);
      const sleepMs = Math.min(delay, Math.max(0, remaining));

      if (sleepMs <= 0) {
        throw new Error("retry_deadline_exceeded before next attempt");
      }

      await sleep(sleepMs);
    }
  }

  throw lastErr;
}

fn 내부에서는 OpenAI 호출을 수행하되, 실패 시 status를 포함한 에러를 던지도록(또는 라이브러리 에러에서 status를 읽도록) 연결하면 됩니다.

동시성 제어: 재시도보다 먼저 “안 때리기”가 중요

백오프는 사후 대응입니다. 429를 줄이는 가장 효과적인 방법은 사전 제어입니다.

1) 프로세스 내부 동시성 제한(세마포어)

단일 인스턴스에서 동시에 OpenAI로 나가는 요청 수를 제한합니다.

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

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

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

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

const openaiSem = new Semaphore(5); // 인스턴스당 동시 5개 제한

async function callOpenAIWithLimit<T>(fn: () => Promise<T>) {
  await openaiSem.acquire();
  try {
    return await fn();
  } finally {
    openaiSem.release();
  }
}

이 방식은 간단하지만, 멀티 인스턴스(K8s) 환경에서는 전체 동시성 제어가 되지 않습니다.

2) 분산 환경에서는 큐 또는 중앙 레이트리미터

  • 작업 큐(예: SQS, RabbitMQ, Kafka)로 흡수하고 워커 수를 제어
  • Redis 기반 토큰 버킷/리키 버킷으로 전역 제한

K8s에서 트래픽 스파이크로 파드가 급격히 늘어나면, 재시도 로직이 오히려 CrashLoopBackOff로 이어질 수 있습니다. 장애 시 “재시도 폭풍 + 리소스 고갈”이 같이 오기 때문입니다. 운영 관점의 체크리스트는 K8s CrashLoopBackOff 원인별 진단·해결 체크리스트도 함께 참고하면 좋습니다.

Retry-After가 있으면 최우선으로 존중

일부 응답은 Retry-After 힌트를 줄 수 있습니다. 있다면 다음이 일반적인 우선순위입니다.

  1. Retry-After 기반 대기
  2. 없으면 Full Jitter 백오프

주의할 점은 Retry-After가 과도하게 길거나, 전체 타임아웃 예산을 초과할 수 있다는 것입니다. 이때는 “사용자 요청을 실패 처리하고 비동기 재처리로 넘기는” 설계가 낫습니다.

아이템포턴시: 중복 호출과 중복 사이드이펙트 분리하기

재시도는 결과적으로 “같은 의미의 작업을 여러 번 실행”하게 만듭니다. 따라서 다음을 분리해야 합니다.

  • LLM 호출 자체의 중복: 비용 증가, 레이트리밋 악화
  • 호출 이후 처리의 중복: 데이터 정합성 깨짐(더 위험)

권장 패턴

  • 요청 단위로 requestId를 생성
  • requestId를 키로 결과를 캐시/저장
  • 동일 requestId 재요청 시 기존 결과를 반환

예를 들어 DB에 request_id 유니크 인덱스를 두고, “이미 처리된 요청이면 그대로 반환”하는 방식이 안전합니다.

MSA에서 보상 트랜잭션을 설계할 때도 결국 핵심은 “중복 실행과 부분 실패를 전제로 정합성을 유지”하는 것입니다. 같은 맥락으로 MSA 사가 보상 트랜잭션 설계 실패 7가지를 보면, 재시도/중복/부분 실패를 어떻게 시스템적으로 다루는지 감이 잡힙니다.

서킷 브레이커: 429 폭주 시 ‘빠르게 실패’로 시스템 보호

429가 지속적으로 발생한다는 것은, 지금은 “성공 확률이 낮은 구간”일 가능성이 큽니다. 이때 모든 요청이 백오프를 돌며 리소스를 점유하면:

  • 워커 스레드/이벤트 루프가 지연되고
  • 큐가 밀리며
  • 사용자 체감은 더 나빠집니다

서킷 브레이커는 다음 상태를 가집니다.

  • closed: 정상 호출
  • open: 일정 시간 즉시 실패(또는 폴백)
  • half-open: 소수의 탐색 요청만 통과시켜 회복 여부 확인

운영에서는 “최근 N초 동안 429 비율이 X% 이상이면 open” 같은 정책이 실용적입니다.

관측(Observability): 재시도는 반드시 지표로 남겨야 한다

재시도는 성공률을 올리지만, 조용히 비용과 지연을 늘립니다. 따라서 다음 메트릭을 최소로 수집하세요.

  • openai_requests_total (status별)
  • openai_retries_total (attempt별)
  • openai_backoff_sleep_ms (분포)
  • openai_latency_ms (p50/p95/p99)
  • 429_ratio (시간창 기준)

로그에는 반드시 다음 필드를 넣는 것이 좋습니다.

  • requestId
  • attempt
  • status
  • backoffMs
  • model
  • estimatedTokens 또는 입력 길이

실전 체크리스트

장애를 줄이는 순서

  1. 동시성 제한(세마포어/큐)
  2. Full Jitter 백오프 + 재시도 횟수 제한
  3. Retry-After 존중
  4. 아이템포턴시 키로 중복 부작용 제거
  5. 서킷 브레이커로 폭주 구간 차단
  6. 지표/알람으로 429 비율과 재시도 비용 가시화

흔한 실수

  • 고정 딜레이(예: 매번 1s)로 재시도해서 동시 재시작을 유발
  • 재시도 무한 루프
  • 사용자 동기 요청에 과도한 재시도(UX 악화)
  • 성공/실패만 보고 재시도 횟수와 백오프 시간을 관측하지 않음

마무리

OpenAI 429·rate_limit은 “가끔 생기는 예외”가 아니라, 트래픽이 있는 서비스라면 언젠가 반드시 겪는 정상 이벤트에 가깝습니다. 재시도는 필요하지만, 제대로 설계하지 않으면 레이트리밋을 더 악화시키고 시스템 전체를 느리게 만듭니다.

  • 먼저 동시성을 제어하고
  • Full Jitter 백오프로 재시도 폭풍을 막고
  • 아이템포턴시로 중복 사이드이펙트를 차단하며
  • 서킷 브레이커와 관측으로 운영 안정성을 확보

이 네 가지를 갖추면 429는 장애가 아니라 “조절 가능한 부하 신호”로 바뀝니다.