Published on

OpenAI 429/Rate Limit 재시도·큐잉 패턴 7가지

Authors

OpenAI API를 붙인 서비스가 트래픽을 타기 시작하면, 가장 먼저 현실로 다가오는 장애가 429 RateLimit입니다. 문제는 429가 단순히 “잠깐 기다렸다 다시 요청해라”가 아니라는 점입니다. 같은 코드를 써도 어떤 요청은 즉시 회복되지만, 어떤 요청은 재시도 폭풍을 만들며 더 큰 장애로 번집니다.

이 글은 재시도 로직을 넘어, 시스템 관점에서 429를 다루는 재시도·큐잉 패턴 7가지를 정리합니다. 각 패턴은 단독으로도 쓸 수 있지만, 실제로는 2~3개를 조합해 안정성을 올리는 경우가 많습니다.

추가로, 기본 재시도·백오프 구현은 아래 글에서 더 자세히 다룹니다.


0) 429를 다루기 전에: 원인 분류부터

429는 크게 두 부류로 나눠 접근하는 게 좋습니다.

  • 단기 버스트: 순간적으로 동시 요청이 튀어서 제한에 걸림
  • 지속 과부하: 평균 처리량 자체가 한도보다 높거나, 토큰 소비가 과도함

단기 버스트는 재시도·지터·클라이언트 레이트리밋만으로도 잘 잡힙니다. 반면 지속 과부하는 큐잉, 워커 풀, 토큰 버짓, 우선순위 같은 구조적 대응이 필요합니다.

또한 OpenAI 응답에 Retry-After 같은 힌트가 있다면 최대한 존중하는 편이 좋습니다. 힌트가 없을 때만 지수 백오프를 적용하세요.


1) 패턴 1: 지수 백오프 + 풀 지터(Full Jitter)

가장 기본이자 가장 효과적인 패턴입니다. 하지만 “지수 백오프만” 쓰면 여러 인스턴스가 동일한 타이밍에 재시도하면서 동기화된 재시도 폭풍이 발생할 수 있습니다. 이를 막는 핵심이 지터(jitter) 입니다.

  • 백오프: base * 2^attempt
  • 지터: 0부터 backoff 사이 랜덤 대기(Full Jitter)

Node.js 예시

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

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

function isRetryable429(err: any) {
  const status = err?.status ?? err?.response?.status;
  return status === 429;
}

function clamp(n: number, min: number, max: number) {
  return Math.max(min, Math.min(max, n));
}

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

  for (let attempt = 0; attempt < opt.maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      lastErr = err;
      if (!isRetryable429(err)) throw err;

      const exp = opt.baseDelayMs * Math.pow(2, attempt);
      const backoff = clamp(exp, 0, opt.maxDelayMs);
      const jitter = Math.floor(Math.random() * backoff);

      await sleep(jitter);
    }
  }

  throw lastErr;
}

적용 포인트

  • maxAttempts는 작게(예: 3~6) 시작하고, 실패 시 상위 레이어에서 큐잉/폴백으로 넘기는 편이 안전합니다.
  • 같은 요청을 무한 재시도하지 말고, 총 대기 시간 상한을 두세요.

2) 패턴 2: Retry-After 우선 + 백오프 보조

서버가 Retry-After를 내려주면, 그 값이 현재 서버 혼잡도를 반영할 가능성이 큽니다. 클라이언트가 임의로 계산한 백오프보다 더 정확할 때가 많습니다.

Python 예시

import time
import random

def retry_after_seconds(headers: dict) -> float | None:
  ra = headers.get("retry-after") or headers.get("Retry-After")
  if not ra:
    return None
  try:
    return float(ra)
  except:
    return None

def call_with_retry(fn, max_attempts=5, base=0.5, cap=10.0):
  last = None
  for attempt in range(max_attempts):
    try:
      return fn()
    except Exception as e:
      last = e
      status = getattr(e, "status", None)
      headers = getattr(e, "headers", {}) or {}
      if status != 429:
        raise

      ra = retry_after_seconds(headers)
      if ra is not None:
        time.sleep(ra)
        continue

      backoff = min(cap, base * (2 ** attempt))
      time.sleep(random.random() * backoff)

  raise last

적용 포인트

  • Retry-After가 너무 길면(예: 수십 초 이상) 즉시 재시도보다 큐로 넘기기가 낫습니다.
  • 일부 환경에서는 헤더 접근이 어려울 수 있으니, SDK 예외 객체 구조를 먼저 확인하세요.

3) 패턴 3: 클라이언트 측 레이트 리미터(토큰 버킷/리키 버킷)

재시도는 사후 대응이고, 레이트 리미터는 사전 예방입니다. 특히 여러 API 서버 인스턴스가 있고 각 인스턴스가 독립적으로 OpenAI를 호출한다면, 인스턴스 수만큼 호출량이 곱해져 429가 터집니다.

가장 단순한 방식은 프로세스 내부에서 RPS를 제한하는 것이고, 더 나아가면 Redis 같은 중앙 저장소로 분산 레이트 리밋을 구현합니다.

Node.js(프로세스 내부) 예시: 간단한 토큰 버킷

class TokenBucket {
  private tokens: number;
  private lastRefill: number;

  constructor(
    private capacity: number,
    private refillPerSec: number
  ) {
    this.tokens = capacity;
    this.lastRefill = Date.now();
  }

  private refill() {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    const add = elapsed * this.refillPerSec;
    this.tokens = Math.min(this.capacity, this.tokens + add);
    this.lastRefill = now;
  }

  async take(n = 1) {
    while (true) {
      this.refill();
      if (this.tokens >= n) {
        this.tokens -= n;
        return;
      }
      await new Promise((r) => setTimeout(r, 50));
    }
  }
}

const bucket = new TokenBucket(10, 5); // 최대 10, 초당 5개 충전

async function callOpenAI(req: any) {
  await bucket.take(1);
  // 여기서 실제 OpenAI 호출
}

적용 포인트

  • 429가 나면 재시도하기 전에 리미터 파라미터부터 재점검하세요.
  • 분산 환경이라면 프로세스 내부 버킷만으로는 부족할 수 있습니다.

4) 패턴 4: 중앙 큐 + 워커 풀(Backpressure)로 흡수

지속 과부하 상황에서는 “즉시 처리”를 포기하고 큐로 흡수하는 게 정답일 때가 많습니다.

구조는 다음과 같습니다.

  • API 서버: 요청을 받으면 작업을 큐에 넣고 202 Accepted 또는 작업 ID를 반환
  • 워커: 제한된 동시성으로 큐를 소비하며 OpenAI 호출
  • 저장소: 결과를 저장하고 폴링/웹훅으로 전달

BullMQ(Queue) + 워커 동시성 제한 예시

import { Queue, Worker } from "bullmq";

const queue = new Queue("openai-jobs", {
  connection: { host: "localhost", port: 6379 },
});

export async function enqueueJob(payload: any) {
  const job = await queue.add("chat", payload, {
    attempts: 5,
    backoff: { type: "exponential", delay: 500 },
    removeOnComplete: true,
  });
  return job.id;
}

const worker = new Worker(
  "openai-jobs",
  async (job) => {
    // 동시성은 Worker 옵션으로 제한
    // 여기서 OpenAI 호출 + 429 재시도 래핑
    return { ok: true };
  },
  {
    connection: { host: "localhost", port: 6379 },
    concurrency: 8,
  }
);

적용 포인트

  • 큐는 429를 “없애는” 게 아니라, 사용자 요청 경로에서 분리합니다.
  • 워커 동시성은 계정 한도, 모델, 토큰 사용량에 맞춰 조절해야 합니다.
  • 큐가 길어질 때를 대비해 작업 TTL, 취소, 우선순위가 필요합니다.

5) 패턴 5: 우선순위 큐 + 공정성(Fairness) 스케줄링

B2B SaaS나 멀티테넌트 환경에서는 특정 고객의 폭주가 전체를 망가뜨립니다. 이때 필요한 게 우선순위공정성입니다.

  • VIP 요청은 먼저 처리
  • 일반 요청은 지연되더라도 굶지 않게(Starvation 방지)
  • 테넌트별 쿼터를 적용해 “한 고객이 모두 먹는 상황”을 방지

구현 아이디어

  • 큐를 vip, default, bulk로 분리하고 워커를 분리 운영
  • 또는 하나의 큐에서 priority 필드를 사용
  • 테넌트별로 별도 큐를 두고 라운드 로빈으로 소비

BullMQ 우선순위 예시

await queue.add("chat", payload, {
  priority: 1, // 숫자가 낮을수록 우선
});

await queue.add("chat", payload, {
  priority: 10,
});

적용 포인트

  • 공정성은 “정책”이므로, 기술 구현 전에 제품 요구사항(유료 플랜, SLA)을 먼저 확정해야 합니다.
  • 우선순위를 넣어도 워커가 단일이면, 특정 작업 유형이 워커를 오래 점유해 역효과가 날 수 있습니다. 작업 유형별 워커 분리가 안전합니다.

6) 패턴 6: 동시성 제한(세마포어) + 적응형 컨트롤(AIMD)

레이트 리미터가 “초당 몇 건”이라면, 동시성 제한은 “동시에 몇 개”입니다. OpenAI 호출은 네트워크 지연, 토큰 길이에 따라 처리 시간이 크게 달라지므로, 동시성 관리가 더 직접적으로 체감됩니다.

여기에 AIMD(Additive Increase, Multiplicative Decrease) 같은 적응형 제어를 붙이면, 한도를 모르는 상황에서도 자동으로 안정점에 수렴시킬 수 있습니다.

  • 성공이 지속되면 동시성 +1
  • 429가 발생하면 동시성 *0.5

Node.js 세마포어 + AIMD 예시(개념)

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

  constructor(public limit: number) {}

  async acquire() {
    if (this.inFlight < this.limit) {
      this.inFlight++;
      return;
    }
    await new Promise<void>((resolve) => this.queue.push(resolve));
    this.inFlight++;
  }

  release() {
    this.inFlight--;
    const next = this.queue.shift();
    if (next) next();
  }
}

const sem = new Semaphore(8);

async function guardedCall(fn: () => Promise<any>) {
  await sem.acquire();
  try {
    const res = await fn();
    // additive increase
    sem.limit = Math.min(64, sem.limit + 1);
    return res;
  } catch (e: any) {
    const status = e?.status ?? e?.response?.status;
    if (status === 429) {
      // multiplicative decrease
      sem.limit = Math.max(1, Math.floor(sem.limit * 0.5));
    }
    throw e;
  } finally {
    sem.release();
  }
}

적용 포인트

  • AIMD는 요동칠 수 있으니, 조정 주기를 두거나(예: 10초마다) 이동평균을 쓰는 게 좋습니다.
  • 동시성 제한은 큐잉 패턴과 궁합이 좋습니다. 워커 내부에서 세마포어를 쓰면 더 안정적입니다.

7) 패턴 7: 캐시·중복 제거·요청 합치기(Coalescing)

429의 많은 부분은 “실제로는 같은 일을 반복”하는 데서 옵니다.

  • 같은 프롬프트/입력에 대해 사용자 여러 명이 동시에 요청
  • 프론트엔드 재시도로 동일 요청이 중복 발사
  • 백엔드에서 타임아웃 후 재시도했는데, 원래 요청이 늦게 성공

해결책은 다음 조합입니다.

  • Idempotency Key: 같은 작업은 한 번만 처리
  • Request coalescing: 동일 키 요청이 들어오면 기존 처리에 합류
  • 결과 캐시: 짧은 TTL이라도 효과가 큼

Node.js: 인메모리 coalescing 예시

const inFlight = new Map<string, Promise<any>>();

function keyOf(payload: any) {
  // 실제로는 stable stringify 또는 해시를 권장
  return JSON.stringify(payload);
}

export async function coalescedCall(payload: any, fn: () => Promise<any>) {
  const key = keyOf(payload);
  const existing = inFlight.get(key);
  if (existing) return existing;

  const p = (async () => {
    try {
      return await fn();
    } finally {
      inFlight.delete(key);
    }
  })();

  inFlight.set(key, p);
  return p;
}

적용 포인트

  • 인메모리는 단일 인스턴스에서만 유효합니다. 분산 환경에서는 Redis 락이나 작업 테이블로 확장하세요.
  • 캐시는 프롬프트/사용자 컨텍스트에 따라 개인정보가 섞일 수 있으니, 키 설계와 보안 정책을 먼저 정리해야 합니다.

조합 추천: 어떤 상황에 어떤 패턴을 섞을까

1) 소규모 서비스, 가끔 429

  • 패턴 1(지수 백오프 + 풀 지터)
  • 패턴 2(Retry-After 우선)
  • 패턴 3(프로세스 내부 레이트 리미터)

2) 트래픽이 커지고 사용자 대기 허용 가능

  • 패턴 4(중앙 큐 + 워커 풀)
  • 패턴 6(워커 동시성 제한 + AIMD)
  • 패턴 7(중복 제거)

3) 멀티테넌트, 특정 고객 폭주가 잦음

  • 패턴 5(우선순위/공정성)
  • 패턴 4(큐)
  • 패턴 3(테넌트별 레이트 리밋)

AWS STS 같은 다른 API에서도 429는 비슷한 양상으로 나타납니다. 원인과 패턴이 닮아 있어 참고가 됩니다.


운영 체크리스트: 재시도만으로는 부족한 지점들

  • 관측성: 429 비율, 재시도 횟수, 평균 대기 시간, 큐 적체 길이, 워커 처리율을 분리해서 봅니다.
  • 타임아웃: 호출 타임아웃이 너무 짧으면 성공 가능한 요청을 실패로 바꿔 재시도 폭풍을 유발합니다.
  • 폴백: 큐가 과도하게 밀리면 “나중에 처리”로 전환하거나, 간단 응답(요약 수준 축소 등)으로 degrade를 고려합니다.
  • 배포 전략: 새 버전 배포 직후 캐시 미스가 늘며 호출량이 급증할 수 있습니다. 워밍업이나 점진 배포가 도움이 됩니다.

비슷한 맥락으로, 실패-재시도 루프는 시스템을 재시작 루프로 몰아넣는 문제와도 닮았습니다. 장애가 “자동 복구”라는 이름으로 증폭되지 않게 설계해야 합니다.


마무리

429를 안정적으로 처리하는 핵심은 “몇 번 재시도”가 아니라, 재시도는 짧게, 과부하는 큐로, 전체는 레이트 리밋과 동시성으로 제어하는 구조를 갖추는 것입니다.

정리하면 다음 7가지를 체크하세요.

  1. 지수 백오프 + 풀 지터
  2. Retry-After 우선
  3. 클라이언트(또는 분산) 레이트 리미터
  4. 중앙 큐 + 워커 풀로 backpressure
  5. 우선순위/공정성 스케줄링
  6. 동시성 제한 + AIMD 적응형 제어
  7. 캐시/중복 제거/요청 합치기

이 중 2~3개만 제대로 결합해도, 429는 “가끔 뜨는 에러”가 아니라 “시스템이 흡수 가능한 신호”로 바뀝니다.