Published on

OpenAI 429·Rate Limit 10분 해결 가이드

Authors

서버에서 OpenAI API를 붙이다 보면 어느 날 갑자기 429 Too Many Requests가 쏟아지고, “10분 정도 지나면 또 되더라” 같은 증상을 겪습니다. 이 현상은 단순히 요청을 줄이면 끝나는 문제가 아니라, 한도 종류(요청 수, 토큰 수), 버스트 트래픽, 동시성, 재시도 설계, 큐잉 전략이 맞물려 발생합니다.

이 글은 “10분 막힘” 패턴을 기준으로, 원인 진단부터 코드 레벨 해결책까지 한 번에 정리합니다.

1) 429가 의미하는 것: Rate limit은 하나가 아니다

OpenAI의 429는 보통 아래 중 하나입니다.

  • RPM 제한: 분당 요청 수가 초과
  • TPM 제한: 분당 토큰 사용량이 초과
  • 동시 처리 제한: 계정/프로젝트/모델별 동시 요청이 과도
  • 쿼터 소진: 월/일 예산 또는 크레딧/한도 소진(이 경우 메시지가 다름)

“10분 뒤에 풀린다”는 체감은 대개 다음과 같은 상황에서 나옵니다.

  • 분 단위 제한이 연속으로 걸려서 백오프 없이 계속 두드리며 실패를 누적시키는 경우
  • 내부적으로 버스트 트래픽이 주기적으로 반복되는 경우(크론, 배치, 이벤트 리스너)
  • 큐가 쌓여서 한 번에 몰아치고 다시 잠잠해지는 파형이 생기는 경우

핵심은 “429가 났다”가 아니라 어떤 한도를 어떻게 넘고 있는지를 먼저 분류하는 것입니다.

2) 원인 진단 체크리스트: 로그에서 무엇을 봐야 하나

2.1 응답 바디와 헤더를 반드시 기록

429 대응의 시작은 “관측”입니다. 최소한 아래를 로깅하세요.

  • status, request_id
  • 에러 메시지(가능하면 원문)
  • Retry-After 헤더(있으면 최우선)
  • 모델명, max_output_tokens, 입력 길이(대략 토큰)
  • 사용자/테넌트 키(누가 폭주시켰는지)

Node.js 예시(에러 로깅):

import OpenAI from "openai";

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

async function callLLM(prompt: string) {
  try {
    return await client.responses.create({
      model: "gpt-4.1-mini",
      input: prompt,
    });
  } catch (e: any) {
    const status = e?.status;
    const headers = e?.headers;
    console.error("openai_error", {
      status,
      requestId: headers?.["x-request-id"],
      retryAfter: headers?.["retry-after"],
      message: e?.message,
    });
    throw e;
  }
}

주의: Next.js MDX 빌드 환경에서는 본문에 부등호가 노출되면 문제가 되지만, 코드 블록은 안전합니다. 다만 본문 텍스트에서 -> 같은 표기는 인라인 코드로 감싸는 습관을 들이세요.

2.2 “요청 수 초과” vs “토큰 수 초과” 구분

  • RPM 초과는 짧은 요청을 너무 많이 보내는 패턴
  • TPM 초과는 요청 수는 적어도 프롬프트가 길거나 출력 토큰이 커서 토큰이 폭증하는 패턴

진단 팁:

  • 같은 시간대에 429가 나는데 입력/출력이 길수록 더 잘 난다면 TPM 가능성이 큽니다.
  • “짧은 질의가 폭주”라면 RPM/동시성 쪽이 유력합니다.

3) “10분 막힘”을 만드는 대표 패턴 5가지

3.1 무지성 재시도(즉시 재시도)로 실패 폭풍 만들기

429가 났는데 즉시 재시도하면, 성공 확률은 오히려 떨어지고 더 많은 429를 생성합니다. 결국 시스템은 10분 동안 계속 실패하다가, 트래픽이 우연히 잦아들면 “풀린 것처럼” 보입니다.

해결은 아래 2가지 세트입니다.

  • Retry-After 존중
  • 지수 백오프 + 지터

이 주제는 재시도/중복 방지까지 같이 다루는 글이 따로 있으니 함께 보세요.

3.2 배치/크론이 10분 단위로 몰아치는 경우

예: 10분마다 도는 작업이 한 번에 1,000건을 처리하며 LLM 호출을 동시 실행.

해결:

  • 배치 작업을 **스트리밍 처리(큐 기반)**로 바꾸고
  • 워커 수를 제한하고
  • 한도에 맞춰 토큰 버짓 기반 스케줄링을 합니다.

3.3 사용자 단위 제한 없이 “한 사용자 폭주”가 전체를 죽이는 경우

멀티 테넌트 서비스에서 특정 고객이 대량 요청을 보내면, 전체 프로젝트 한도가 소진되어 다른 고객도 같이 429를 받습니다.

해결:

  • 사용자/조직별 rate limiter
  • 공용 풀에 넣기 전에 테넌트별 큐로 분리

3.4 프롬프트가 길어져 TPM 초과

로그/대화 히스토리를 무제한으로 붙이거나, RAG에서 검색 결과를 과도하게 붙이면 TPM이 빨리 찹니다.

해결:

  • 히스토리 요약(summarize)
  • 검색 결과 상한(문서 수, 글자 수)
  • max_output_tokens 상한 설정

3.5 스트리밍 연결이 쌓여 동시성 한도 초과

SSE 스트리밍을 쓰면 연결이 오래 유지됩니다. 프록시/로드밸런서 설정이 미묘하면 응답이 느려져 동시 연결이 더 쌓이고, 결국 429와 함께 499/502도 섞여 나옵니다.

4) 해결 전략 A: 백오프와 재시도(필수)

4.1 지수 백오프 + 지터 + 최대 대기

아래는 Node.js에서 많이 쓰는 패턴입니다.

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

function expoBackoffMs(attempt: number) {
  const base = 500; // 0.5s
  const cap = 30_000; // 30s
  const exp = Math.min(cap, base * 2 ** attempt);
  const jitter = Math.floor(Math.random() * 250);
  return exp + jitter;
}

async function retry<T>(fn: () => Promise<T>) {
  const maxAttempts = 6;
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (e: any) {
      const status = e?.status;
      const retryAfter = Number(e?.headers?.["retry-after"]);
      const isRetryable = status === 429 || (status >= 500 && status <= 599);

      if (!isRetryable || attempt === maxAttempts - 1) throw e;

      const waitMs = Number.isFinite(retryAfter)
        ? retryAfter * 1000
        : expoBackoffMs(attempt);

      await sleep(waitMs);
    }
  }
  throw new Error("unreachable");
}

포인트:

  • Retry-After가 있으면 그것을 우선합니다.
  • 429는 “잠깐 쉬어라”는 의미이므로, 빠른 재시도는 오히려 독입니다.

4.2 중복 실행/중복 결제 방지: Idempotency 키

재시도를 넣으면 같은 요청이 여러 번 실행될 수 있습니다. 특히 네트워크 타임아웃이나 5xx에서 재시도하면 “서버는 성공했는데 클라이언트만 실패로 인지”하는 경우가 생깁니다.

이때 Idempotency 키를 요청 단위로 부여해 중복 청구/중복 처리를 막습니다. 자세한 구현은 아래 글을 참고하세요.

5) 해결 전략 B: 동시성 제한(서버에서 가장 효과 큼)

백오프만으로는 “근본적으로 동시에 너무 많이 보내는 문제”를 못 잡습니다. 서버에서 동시 요청 수를 제한하세요.

5.1 p-limit로 간단히 동시성 제한

import pLimit from "p-limit";
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
const limit = pLimit(5); // 동시 5개로 제한

async function run(prompts: string[]) {
  const tasks = prompts.map((p) =>
    limit(() =>
      client.responses.create({
        model: "gpt-4.1-mini",
        input: p,
        max_output_tokens: 300,
      })
    )
  );
  return await Promise.allSettled(tasks);
}

동시성 제한은 다음 효과가 있습니다.

  • RPM/동시성 한도에 직접적으로 대응
  • 스트리밍/느린 응답으로 연결이 쌓이는 현상 완화
  • 장애 시 재시도 폭풍을 줄임

5.2 웹 요청 경로에서는 “큐 + 워커”가 더 안전

API 요청이 들어올 때마다 LLM을 즉시 호출하면, 트래픽 스파이크에서 429가 폭발합니다.

권장 구조:

  • HTTP 요청은 빠르게 202 Accepted 또는 작업 ID를 반환
  • 작업은 큐(예: SQS, Redis, RabbitMQ)에 넣고
  • 워커가 제한된 동시성으로 처리

이렇게 하면 “10분 막힘” 같은 파형이 훨씬 줄어듭니다.

6) 해결 전략 C: 토큰 버짓 기반으로 TPM 초과를 막기

TPM 제한이 문제라면 “요청 수”가 아니라 “토큰 총량”을 제어해야 합니다.

6.1 max_output_tokens 상한은 기본

출력 토큰 상한을 두지 않으면, 특정 프롬프트에서 출력이 길어져 TPM을 순식간에 소진합니다.

await client.responses.create({
  model: "gpt-4.1-mini",
  input: prompt,
  max_output_tokens: 400,
});

6.2 입력 토큰도 상한을 두고, 초과 시 요약

대화 히스토리나 문서 본문이 길어질 때는 “그대로 넣기” 대신 요약 단계를 둡니다.

  • 히스토리가 N 토큰을 넘으면 요약본으로 교체
  • RAG 결과는 상위 K개만, 각 문서도 길이 제한

요약 호출 자체도 토큰을 쓰므로, 대규모 트래픽에서는 “요약도 큐로” 처리하는 게 안전합니다.

7) 운영에서 자주 놓치는 디테일

7.1 클라이언트 타임아웃이 너무 짧아 재시도만 늘어나는 경우

LLM 응답이 느릴 때 클라이언트 타임아웃이 짧으면, 실제로는 서버가 처리 중인데 클라이언트가 끊고 재시도합니다. 이 패턴은 429를 더 악화시킵니다.

  • 타임아웃을 현실적으로 조정
  • 스트리밍이면 프록시 버퍼링/idle timeout도 함께 점검

7.2 배포 후 특정 경로만 폭주: 캐시/중복 호출 점검

서버 렌더링, 웹훅, 리트라이 미들웨어가 겹치면 한 사용자 액션이 LLM을 2~3번 호출하는 경우가 있습니다.

  • 요청 단위로 “LLM 호출 횟수”를 메트릭화
  • 동일 입력에 대한 캐시(짧은 TTL이라도) 적용

7.3 장애 시 보상 트랜잭션이 중복 실행되는 경우

LLM 호출 실패를 보상 로직으로 처리할 때, 재시도와 결합되면 보상 트랜잭션이 중복 실행될 수 있습니다. 분산 트랜잭션을 운영한다면 아래 글도 같이 보면 좋습니다.

8) 실전 처방전: “10분 막힘”을 1시간 안에 줄이는 순서

아래 순서대로 적용하면 체감 개선이 빠릅니다.

  1. 429 응답의 메시지/헤더/요청 메타데이터 로깅 추가
  2. 429 및 5xx에 대해 Retry-After 우선 + 지수 백오프 + 지터 적용
  3. 서버 전역 동시성 제한(예: 5~20부터 시작, 모델/트래픽에 맞게 조정)
  4. 사용자/테넌트별 rate limit 적용(한 고객이 전체를 죽이지 않게)
  5. max_output_tokens 상한 + 프롬프트 길이 상한 + 요약 도입
  6. 트래픽이 크면 큐 기반 비동기 처리로 전환

이 조합이 갖춰지면, “10분 기다리면 된다” 같은 운빨 운영에서 벗어나 예측 가능한 처리량과 실패율을 만들 수 있습니다.

9) 마무리

OpenAI 429는 단순 오류가 아니라 “시스템이 현재 처리할 수 있는 속도보다 더 빨리 밀어 넣고 있다”는 신호입니다. 그래서 해결도 단순 재시도가 아니라, **백오프(시간), 동시성(공간), 토큰 버짓(자원)**을 함께 다뤄야 합니다.

특히 “10분 단위로 풀리는” 현상은 대개 재시도 폭풍이나 주기적 버스트가 원인인 경우가 많으니, 이 글의 체크리스트대로 관측부터 정리해 보세요.