Published on

OpenAI 429/RateLimitError 재시도·백오프·큐 설계

Authors

서버에서 OpenAI API를 호출하다 보면 가장 흔하게 마주치는 장애가 429 Too Many Requests(혹은 SDK 예외로 RateLimitError)입니다. 많은 팀이 “재시도 몇 번 걸면 되겠지”로 시작하지만, 트래픽이 조금만 커져도 재시도 폭풍(retry storm)으로 더 빨리 한도를 소진하고, 사용자 지연이 늘고, 결국 다운스트림까지 연쇄 장애가 납니다.

이 글에서는 429가 왜 발생하는지(요청 수/토큰/동시성/버스트), 어떤 재시도 정책이 안전한지(지수 백오프 + 지터 + Retry-After), 그리고 큐(Queue)로 호출을 평탄화(smoothing) 해서 “429가 나도 서비스는 안정적으로” 운영하는 패턴을 정리합니다.

> 비슷한 결의 글로, 과부하 응답에 대한 재시도·백오프 설계는 Claude API 529 Overloaded 재시도·백오프 설계도 함께 보면 좋습니다. 또한 재시도로 인해 타임아웃이 누적되면 gRPC나 내부 호출에서 지연이 폭증할 수 있는데, 이런 경우 EKS에서 gRPC DEADLINE_EXCEEDED 폭증 해결 같은 관점(타임아웃/리트라이 상호작용)도 참고할 만합니다.

429/RateLimitError의 실체: “요청 수”만의 문제가 아니다

OpenAI의 레이트리밋은 보통 아래 축이 함께 걸립니다(계정/프로젝트/모델/조직 정책에 따라 다름).

  • RPM (Requests Per Minute): 분당 요청 수
  • TPM (Tokens Per Minute): 분당 토큰 사용량(입력+출력)
  • 동시성(Concurrency): 동시에 처리 가능한 인플라이트 요청 수(명시/암묵)
  • 버스트(Burst): 짧은 시간에 몰리는 스파이크

따라서 “요청을 1초에 10개로 제한했는데도 429가 난다”는 상황이 흔합니다. 이유는 다음 중 하나일 가능성이 큽니다.

  1. TPM이 먼저 찼다: 프롬프트가 길거나, max_output_tokens가 크거나, 스트리밍에서 출력이 많이 나오는 경우
  2. 동시성 제한에 걸렸다: 한 번에 너무 많은 요청을 동시에 날림
  3. 재시도 자체가 트래픽을 증폭: 실패한 요청이 일정 간격으로 다시 몰리며 RPM/TPM을 더 빨리 소진
  4. 다중 워커/파드가 각자 제한: 각 인스턴스가 “자기 기준”으로만 제한하면 전체 합이 한도를 넘음(분산 레이트리밋 문제)

핵심은 429를 “에러”로만 보지 말고, 시스템이 현재 처리 가능한 속도를 초과했다는 신호로 봐야 한다는 점입니다.

재시도 기본기: Retry-After + 지수 백오프 + 지터

429 대응에서 가장 먼저 해야 할 일은 “무조건 즉시 재시도”를 금지하는 것입니다. 안전한 재시도는 아래 3요소를 갖습니다.

  • Retry-After 존중: 응답 헤더에 Retry-After(초)가 있으면 최우선으로 따름
  • 지수 백오프(Exponential Backoff): 재시도 간격을 기하급수적으로 늘림
  • 지터(Jitter): 여러 요청이 같은 시점에 재시도되는 동기화(thundering herd)를 방지

(Node.js/TypeScript) 429 전용 재시도 래퍼

아래 예시는 OpenAI 호출을 감싸는 재시도 유틸입니다. 포인트는 다음입니다.

  • 429/일시적 네트워크 오류만 재시도
  • Retry-After가 있으면 우선 적용
  • 백오프에 지터를 섞음
  • 전체 작업에 상한(최대 시도 횟수, 최대 대기 시간)을 둠
// retry.ts
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

function parseRetryAfterSeconds(headers: Headers | Record<string, string> | undefined) {
  if (!headers) return undefined;
  // fetch Headers
  // @ts-ignore
  const v = typeof (headers as any).get === "function"
    ? (headers as any).get("retry-after")
    : (headers as any)["retry-after"] || (headers as any)["Retry-After"];

  if (!v) return undefined;
  const n = Number(v);
  return Number.isFinite(n) ? n : undefined;
}

function fullJitter(baseMs: number, capMs: number) {
  // AWS Architecture Blog에서 자주 쓰는 Full Jitter 형태
  const temp = Math.min(capMs, baseMs);
  return Math.floor(Math.random() * temp);
}

export async function withRateLimitRetry<T>(
  fn: () => Promise<T>,
  opts?: {
    maxAttempts?: number;
    baseDelayMs?: number;
    maxDelayMs?: number;
    maxTotalDelayMs?: number;
    // OpenAI SDK 에러 형태를 정확히 모를 때를 대비해 판별 훅 제공
    isRetryable?: (e: any) => boolean;
    getHeaders?: (e: any) => any; // e.response?.headers 등
  }
): Promise<T> {
  const maxAttempts = opts?.maxAttempts ?? 6;
  const baseDelayMs = opts?.baseDelayMs ?? 500;
  const maxDelayMs = opts?.maxDelayMs ?? 20_000;
  const maxTotalDelayMs = opts?.maxTotalDelayMs ?? 60_000;

  let attempt = 0;
  let totalDelay = 0;

  while (true) {
    try {
      return await fn();
    } catch (e: any) {
      attempt++;

      const status = e?.status ?? e?.response?.status;
      const retryableByStatus = status === 429 || (status >= 500 && status <= 599);
      const retryable = opts?.isRetryable ? opts.isRetryable(e) : retryableByStatus;

      if (!retryable || attempt >= maxAttempts) throw e;

      const headers = opts?.getHeaders ? opts.getHeaders(e) : (e?.response?.headers ?? e?.headers);
      const ra = parseRetryAfterSeconds(headers);

      // 지수 백오프: base * 2^(attempt-1)
      const exp = baseDelayMs * Math.pow(2, attempt - 1);
      const backoffMs = ra != null ? ra * 1000 : fullJitter(exp, maxDelayMs);

      totalDelay += backoffMs;
      if (totalDelay > maxTotalDelayMs) throw e;

      await sleep(backoffMs);
    }
  }
}

이 정도만 해도 “429가 떴을 때 즉시 재시도해서 더 큰 429를 만든다”는 최악의 패턴은 피할 수 있습니다.

하지만 여기까지는 증상 완화에 가깝습니다. 트래픽이 계속 한도를 넘는 구조라면 재시도는 지연만 늘릴 뿐입니다. 다음 단계는 큐로 평탄화하는 것입니다.

큐(Queue)로 429를 ‘흡수’하기: 버퍼링과 평탄화

429는 “지금은 처리 못하니 나중에 다시 와라”에 가깝습니다. 그렇다면 애플리케이션은 두 가지 중 하나를 선택해야 합니다.

  • (A) 사용자 요청을 실패 처리하고 “잠시 후 재시도”를 사용자에게 넘김
  • (B) 서버가 대기열을 만들고, 가능한 속도로 순차 처리(서버가 재시도를 흡수)

대부분의 프로덕션에서는 (B)가 필요합니다. 특히 배치/비동기 작업, 챗봇의 백그라운드 요약/분류, 문서 임베딩 생성 같은 워크로드는 큐가 정답에 가깝습니다.

큐를 도입하면 얻는 효과:

  • 버스트 트래픽을 큐에 적재해 다운스트림(OpenAI)을 보호
  • 워커 수를 조절해 동시성 제한을 강제
  • 429가 나도 워커가 백오프하며 처리하므로 사용자 API는 빠르게 응답(accepted) 가능

큐 설계 체크리스트

  • 작업 단위 정의: 프롬프트/입력/모델/파라미터를 포함한 “한 번의 호출”
  • 멱등성 키(idempotency key): 같은 작업이 중복 처리되지 않도록 키를 둠(특히 재시도/워커 재시작 시)
  • 가시성 타임아웃(visibility timeout): 워커가 작업을 가져간 뒤 죽으면 재처리
  • DLQ(Dead Letter Queue): 영구 실패(4xx 중 정책상 재시도 불가 등)는 격리
  • 백프레셔(backpressure): 큐 길이가 임계치를 넘으면 신규 요청을 제한하거나 degrade

멱등성과 중복처리는 분산 시스템에서 매우 흔한 함정입니다. 이벤트/메시지 중복을 다룰 때의 사고방식은 Kafka Exactly-Once 깨질 때 중복처리 방지 전략에서 다룬 전략과 유사합니다.

(실전) BullMQ로 “OpenAI 호출 워커” 만들기

Redis 기반 BullMQ는 Node 생태계에서 큐/워커를 빠르게 구성하기 좋습니다. 아래는 전형적인 구조입니다.

  • API 서버: 요청을 받으면 큐에 작업을 넣고 202 Accepted로 빠르게 응답
  • 워커: 큐에서 작업을 꺼내 OpenAI 호출, 429면 백오프 재시도

1) 작업 enqueue (API 서버)

// queue.ts
import { Queue } from "bullmq";

export const openaiQueue = new Queue("openai-jobs", {
  connection: {
    host: process.env.REDIS_HOST,
    port: Number(process.env.REDIS_PORT ?? 6379),
  },
});
// api.ts (Express 예시)
import express from "express";
import crypto from "crypto";
import { openaiQueue } from "./queue";

const app = express();
app.use(express.json());

app.post("/summaries", async (req, res) => {
  const { text } = req.body as { text: string };

  // 멱등성 키: 동일 텍스트 요약 요청이 반복될 때 중복 작업 방지에 활용 가능
  const idempotencyKey = crypto.createHash("sha256").update(text).digest("hex");

  const job = await openaiQueue.add(
    "summarize",
    { text, idempotencyKey },
    {
      jobId: idempotencyKey, // BullMQ 레벨에서 중복 add 방지
      removeOnComplete: 1000,
      removeOnFail: 1000,
    }
  );

  res.status(202).json({ jobId: job.id });
});

app.listen(3000);

2) 워커에서 처리 + 429 백오프

// worker.ts
import { Worker } from "bullmq";
import OpenAI from "openai";
import { withRateLimitRetry } from "./retry";

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

const worker = new Worker(
  "openai-jobs",
  async (job) => {
    const { text } = job.data as { text: string; idempotencyKey: string };

    // 핵심: 워커 동시성(concurrency)을 제한해 동시 인플라이트를 통제
    const result = await withRateLimitRetry(
      async () => {
        const resp = await client.responses.create({
          model: "gpt-4.1-mini",
          input: `다음 텍스트를 5줄로 요약해줘:\n\n${text}`,
        });
        return resp;
      },
      {
        maxAttempts: 8,
        baseDelayMs: 800,
        maxDelayMs: 30_000,
        maxTotalDelayMs: 120_000,
        getHeaders: (e) => e?.response?.headers,
      }
    );

    // TODO: DB에 저장하거나, 콜백/웹훅/폴링으로 사용자에게 전달
    return { output: result.output_text };
  },
  {
    connection: {
      host: process.env.REDIS_HOST,
      port: Number(process.env.REDIS_PORT ?? 6379),
    },
    concurrency: 3, // 매우 중요: 여기서 동시성 상한을 박아야 함
  }
);

worker.on("failed", (job, err) => {
  console.error("job failed", job?.id, err);
});

이 구조의 장점은 명확합니다.

  • API 서버는 OpenAI 지연/429에 덜 영향 받음
  • 워커가 동시성을 통제하므로 레이트리밋을 구조적으로 준수
  • 429가 나도 워커가 백오프하며 진행(큐가 완충)

단점도 있습니다.

  • 사용자에게 즉시 결과를 못 주면 폴링/웹훅/UI 상태 관리가 필요
  • 큐가 길어지면 처리 지연이 증가(하지만 이는 “실패”보다 관리 가능한 형태의 지연)

토큰 기반(TPM)까지 고려한 스로틀링: “요청 수 제한”만으로는 부족

RPM만 제한하면 TPM이 터져 429가 날 수 있습니다. 특히 요약/분석처럼 입력이 길거나 출력이 긴 작업은 TPM이 지배합니다.

실전에서는 아래 중 하나를 추가합니다.

  • 작업별 토큰 예산 추정: 입력 길이로 대략적인 토큰을 추정해 큐에서 ‘큰 작업’을 느리게
  • 모델/파라미터 조정: max_output_tokens 상한을 낮추고, 필요 시 여러 턴으로 나눔
  • 우선순위 큐: 짧은 작업(저토큰)을 먼저 처리해 체감 지연을 낮춤(Shortest Job First 유사)

간단한 토큰 추정은 완벽할 필요가 없습니다. “긴 문서는 느리게 흘린다” 정도만 해도 TPM 폭주가 크게 줄어듭니다.

재시도 폭풍을 막는 운영 규칙 6가지

  1. 클라이언트 재시도와 서버 재시도를 중복하지 말기
    • 프론트/모바일이 재시도하고 서버도 재시도하면 증폭됩니다. 한 레이어에서만 책임지세요.
  2. 동시성 상한을 먼저 걸기
    • 백오프는 ‘시간’을 늘리지만 동시성은 ‘압력’을 줄입니다. 둘 다 필요하되 우선순위는 동시성입니다.
  3. 재시도 가능한 오류만 재시도
    • 429, 5xx, 네트워크 오류 위주. 4xx(권한/파라미터)까지 재시도하면 큐만 막힙니다.
  4. 최대 총 대기 시간(Deadline)을 둬라
    • 무한 재시도는 장애를 숨기고 비용만 키웁니다.
  5. 서킷 브레이커(Circuit Breaker) 고려
    • 일정 비율 이상 429가 지속되면 잠시 신규 enqueue를 제한하거나, degrade 응답을 반환.
  6. 관측성(Observability)을 먼저 심기
    • 429 비율, 재시도 횟수 분포, 큐 길이, 작업 처리 지연, TPM 추정치를 대시보드로 봐야 튜닝이 가능합니다.

빠른 진단: 429가 늘었을 때 어디부터 볼까?

  • 큐가 없다면: 먼저 호출 경로에 재시도/백오프가 “동시에” 걸리는지 확인(여러 레이어 중복)
  • 큐가 있다면: 큐 길이 증가가 “유입 증가”인지 “처리량 감소”인지 분리
    • 처리량 감소라면 워커 장애/네트워크/타임아웃/모델 변경(토큰 증가)을 의심
  • 최근 배포가 있었다면: 프롬프트 길이 증가, max_output_tokens 증가, 스트리밍 처리 버그(중복 호출) 같은 회귀를 의심

워커가 계속 죽거나 재시작되면 큐 지연이 폭증하며 429와 섞여 보일 수 있습니다. 이런 경우에는 프로세스/서비스 레벨에서 재시작 루프를 먼저 잡아야 합니다(예: systemd 서비스가 계속 재시작될 때 진단 체크리스트).

결론: 429는 “재시도”가 아니라 “흐름 제어” 문제다

OpenAI 429/RateLimitError는 단순한 예외 처리가 아니라, 시스템이 처리 가능한 속도를 넘어섰다는 신호입니다. 안정적인 해법은 다음 순서로 접근하는 것이 좋습니다.

  1. Retry-After + 지수 백오프 + 지터로 재시도 폭풍을 방지
  2. 동시성 상한을 강제(프로세스/워커 레벨)
  3. 큐로 버스트를 흡수하고, 사용자 요청 경로를 비동기로 분리
  4. TPM까지 고려해 작업 크기를 조절(토큰 예산/우선순위)
  5. 지표 기반 튜닝으로 “429=운영 가능한 지연” 상태를 만든다

이 패턴을 적용하면 429가 완전히 사라지지 않더라도, 서비스는 훨씬 예측 가능해지고(지연은 늘어도 실패는 줄고), 비용과 운영 스트레스가 함께 내려갑니다.