Published on

OpenAI API 429 재시도·백오프 패턴 실전 가이드

Authors

운영 환경에서 OpenAI API를 붙이다 보면 어느 순간 429 응답을 마주칩니다. 로컬에서는 잘 되는데 트래픽이 늘거나 배치가 겹치면 갑자기 실패율이 튀고, 단순 재시도만 넣으면 더 많은 429를 유발하는 악순환이 생깁니다.

이 글은 429 Rate limit을 “에러”가 아니라 “흐름 제어 신호”로 보고, 재시도·백오프를 어떻게 설계해야 안정적으로 처리량을 끌어올릴 수 있는지 정리합니다. 특히 Retry-After를 존중하는 방식, 지수 백오프에 지터를 섞는 이유, 멱등성·중복 요청 방지, 그리고 관측 지표까지 실전 관점으로 다룹니다.

관련해서 장애를 좁혀가는 디버깅 관점은 React 렌더 폭주? useEffect 의존성 루프 디버깅 글의 “폭주 패턴을 먼저 의심한다”는 접근이 API 호출 폭주에도 그대로 적용됩니다.

429가 의미하는 것: 실패가 아니라 과부하 신호

429는 서버가 “지금은 더 못 받는다”라고 말하는 상태입니다. OpenAI API에서는 보통 다음과 같은 상황에서 발생합니다.

  • 순간 QPS가 계약된 한도 또는 계정/모델별 제한을 초과
  • 토큰 처리량(TPM)이 급증해 제한을 초과
  • 동시 요청이 많아 내부 큐가 밀림
  • 동일 키로 여러 워커가 동시에 폭주

중요한 포인트는 429를 만났을 때 “즉시 다시 쏘기”가 최악의 선택일 수 있다는 점입니다. 많은 클라이언트가 동일한 타이밍에 재시도하면 서버는 더 바빠지고, 결과적으로 429가 더 늘어납니다. 그래서 백오프와 지터가 필요합니다.

재시도 설계의 기본 원칙

1) Retry-After가 있으면 최우선으로 따른다

서버가 Retry-After 헤더로 “몇 초 후에 다시 와라”라고 알려주는 경우가 있습니다. 이 값이 있다면 클라이언트 임의의 백오프보다 우선합니다.

  • Retry-After: 2 라면 최소 2초 대기
  • 일부 시스템은 날짜 형식도 사용하지만, 보통 초 단위가 많습니다

2) 지수 백오프 + 지터가 기본값

지수 백오프는 대기 시간을 base * 2^attempt 형태로 늘립니다. 여기에 지터(랜덤)를 섞어 동시 재시도 스파이크를 분산합니다.

대표적인 지터 방식:

  • Full Jitter: sleep = random(0, cap)
  • Equal Jitter: sleep = cap/2 + random(0, cap/2)
  • Decorrelated Jitter: 이전 sleep을 기반으로 랜덤하게 변화

실전에서는 Full Jitter가 구현이 간단하고 효과가 좋아 많이 씁니다.

3) 재시도 횟수와 최대 대기 상한을 둔다

무한 재시도는 장애를 숨기고 비용만 늘립니다.

  • 최대 재시도 maxAttempts 예: 5
  • 최대 대기 maxDelayMs 예: 20_000
  • 전체 타임아웃(예: 60초)도 함께 고려

4) 멱등성: “같은 요청을 두 번 처리해도 안전한가”

재시도는 중복 호출을 만들 수 있습니다. 특히 생성 API 호출은 결과가 매번 달라질 수 있고, 과금도 중복될 수 있습니다.

  • 가능하면 요청에 “업무 키”를 포함해 중복을 감지
  • 큐 기반 처리라면 메시지 ID로 중복 제거
  • 저장소에 “요청 ID 처리 완료”를 기록하고 재시도 시 확인

이 부분은 DB 락/데드락 문제와도 유사합니다. 재시도 자체는 필요하지만, 중복 실행을 제어하지 않으면 더 큰 문제를 만들 수 있습니다. 데드락 재시도 패턴은 MySQL InnoDB 데드락 로그로 10분 재현·해결 글의 접근과 비슷하게 “재시도는 하되, 원인과 부작용을 통제”하는 게 핵심입니다.

어떤 상태 코드를 재시도할 것인가

권장 분류(일반적인 HTTP 클라이언트 관점):

  • 재시도 권장: 429, 500, 502, 503, 504
  • 조건부 재시도: 네트워크 타임아웃, 연결 리셋
  • 재시도 비권장: 400, 401, 403, 404 (요청 자체가 잘못됐을 가능성이 큼)

인증/권한 문제는 재시도로 해결되지 않는 경우가 대부분입니다. 401/403을 재시도하고 있다면 토큰/권한/스코프 문제를 먼저 의심해야 합니다. 비슷한 원인 분해 방식은 Spring Security 6 JWT 401/403 원인 9가지 글처럼 “원인군을 빠르게 분류”하는 게 중요합니다.

패턴 1: Retry-After 우선 + 지수 백오프(Full Jitter)

아래는 Node.js에서 재시도 래퍼를 만드는 예시입니다. 포인트는 다음입니다.

  • Retry-After가 있으면 해당 값으로 대기
  • 없으면 지수 백오프 + Full Jitter
  • maxAttemptsmaxDelayMs로 상한
  • 재시도 대상 상태 코드만 재시도

Node.js 예제(공용 fetch 래퍼)

import OpenAI from "openai";

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

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function parseRetryAfterSeconds(err) {
  // openai SDK 에러 객체 구조는 버전에 따라 다를 수 있어 방어적으로 접근
  const headers = err?.response?.headers;
  const ra = headers?.get?.("retry-after") ?? headers?.["retry-after"];
  if (!ra) return null;

  const seconds = Number(ra);
  return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
}

function isRetryableStatus(status) {
  return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
}

async function withRetry(fn, opts = {}) {
  const {
    maxAttempts = 5,
    baseDelayMs = 250,
    maxDelayMs = 20_000,
  } = opts;

  let attempt = 0;

  while (true) {
    try {
      return await fn();
    } catch (err) {
      attempt += 1;

      const status = err?.status ?? err?.response?.status;
      const retryable = status ? isRetryableStatus(status) : true; // 네트워크 에러는 status가 없을 수 있음

      if (!retryable || attempt >= maxAttempts) {
        throw err;
      }

      const retryAfterSec = parseRetryAfterSeconds(err);
      if (retryAfterSec !== null) {
        const waitMs = Math.min(maxDelayMs, Math.ceil(retryAfterSec * 1000));
        await sleep(waitMs);
        continue;
      }

      // 지수 백오프 상한(cap)
      const cap = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
      // Full Jitter
      const waitMs = Math.floor(Math.random() * cap);

      await sleep(waitMs);
    }
  }
}

export async function createResponse(input) {
  return withRetry(() =>
    client.responses.create({
      model: "gpt-4.1-mini",
      input,
    })
  );
}

운영 팁

  • baseDelayMs는 너무 작으면 폭주를 막지 못하고, 너무 크면 지연이 커집니다. 보통 200~500ms에서 시작해 관측 후 조정합니다.
  • maxAttempts는 “사용자 요청”과 “배치 처리”에서 다르게 잡는 게 좋습니다.
    • 사용자 요청: 2~4회 정도로 짧게
    • 배치/비동기 작업: 5~8회로 길게

패턴 2: 토큰 버킷(클라이언트 측 레이트 리미터) + 재시도

재시도는 사후 대응입니다. 더 좋은 방식은 “애초에 한도를 넘지 않게” 만드는 것입니다.

  • 프로세스 1개면 in-memory 토큰 버킷으로도 효과가 큼
  • 워커 여러 대면 Redis 같은 공유 저장소 기반 리미터 필요

Node.js 예제(Bottleneck로 동시성 및 속도 제한)

import Bottleneck from "bottleneck";
import OpenAI from "openai";

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

// 예: 초당 5건, 동시 2건으로 제한
const limiter = new Bottleneck({
  minTime: 200, // 200ms 간격
  maxConcurrent: 2,
});

async function callOpenAI(input) {
  return client.responses.create({
    model: "gpt-4.1-mini",
    input,
  });
}

export async function createResponseLimited(input) {
  return limiter.schedule(() => callOpenAI(input));
}

이 방식은 429를 완전히 없애진 못하지만, “폭주 패턴”을 상당히 줄여줍니다. 특히 다수의 사용자 요청이 동시에 몰리는 서비스에서 효과가 큽니다.

패턴 3: Python에서의 재시도(tenacity 사용)

Python은 tenacity가 재시도 표준 라이브러리처럼 많이 쓰입니다. 아래 예시는

  • 4295xx 재시도
  • Retry-After 우선
  • 없으면 지수 백오프 + 랜덤 지터

를 구현한 형태입니다.

Python 예제

import os
import time
import random
from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

RETRYABLE = {429, 500, 502, 503, 504}

def retry_after_seconds(err):
    headers = getattr(getattr(err, "response", None), "headers", None)
    if not headers:
        return None
    ra = headers.get("retry-after")
    if not ra:
        return None
    try:
        sec = float(ra)
        return sec if sec >= 0 else None
    except ValueError:
        return None

def call_with_retry(input_text, max_attempts=5, base_delay=0.25, max_delay=20.0):
    attempt = 0
    while True:
        try:
            return client.responses.create(
                model="gpt-4.1-mini",
                input=input_text,
            )
        except Exception as err:
            attempt += 1
            status = getattr(err, "status_code", None) or getattr(err, "status", None)

            if status is not None and status not in RETRYABLE:
                raise
            if attempt >= max_attempts:
                raise

            ra = retry_after_seconds(err)
            if ra is not None:
                time.sleep(min(max_delay, ra))
                continue

            cap = min(max_delay, base_delay * (2 ** attempt))
            sleep_s = random.random() * cap  # full jitter
            time.sleep(sleep_s)

resp = call_with_retry("Summarize this text...")
print(resp.output_text)

재시도에서 자주 터지는 함정 6가지

1) “모든 에러 재시도”

400 같은 요청 오류를 재시도하면 비용만 늘고 장애가 길어집니다. 재시도 대상 코드를 명확히 제한하세요.

2) 동시 재시도 스파이크(Thundering herd)

백오프만 있고 지터가 없으면, 많은 워커가 동일한 주기로 동시에 깨어납니다. 지터는 선택이 아니라 필수입니다.

3) 재시도 중복 과금/중복 처리

업무적으로 멱등하지 않은 작업(예: 결제, 티켓 발급, 사용자 메시지 발송)에 재시도를 그대로 적용하면 큰 사고가 납니다.

  • 생성 요청 결과를 저장하고, 동일 입력이면 캐시
  • 요청 ID를 발급하고 저장소에 “처리됨” 기록

4) 스트리밍 응답 도중 끊김

스트리밍은 “부분 성공”이 존재합니다. 중간에 끊기면 같은 프롬프트로 재시도할 때 결과가 달라질 수 있습니다.

  • 스트리밍 중간 결과를 사용자에게 이미 보여줬다면, 재시도 정책을 다르게 가져가야 함
  • 가능하면 비스트리밍으로 재시도 안정성을 확보하거나, 사용자 경험 정책을 먼저 정의

5) 백오프가 너무 길어 사용자 경험 붕괴

사용자 요청에 20초 이상 대기 재시도를 걸면, 성공하더라도 UX는 실패에 가깝습니다.

  • 사용자 요청: 짧게 재시도하고 빠르게 폴백(예: “잠시 후 다시 시도”) 제공
  • 비동기 처리: 길게 재시도하고 큐로 흡수

6) 관측(Observability) 부재

재시도는 장애를 “조용히” 숨깁니다. 다음 지표를 반드시 남기세요.

  • 429 발생률(모델별, 엔드포인트별)
  • 재시도 횟수 분포(요청당 평균/상위 백분위)
  • 최종 실패율(재시도 후에도 실패)
  • 대기 시간 누적(사용자 체감 지연)

로그에는 최소한 아래를 포함하는 것을 권합니다.

  • request_id 또는 업무 키
  • attempt
  • status
  • wait_ms
  • retry_after 존재 여부

권장 레시피: 실무에서 가장 무난한 조합

  1. 클라이언트 측 속도 제한(동시성 제한 + 최소 간격)
  2. 서버 힌트인 Retry-After 존중
  3. 지수 백오프 + Full Jitter
  4. 최대 재시도/최대 대기/전체 타임아웃 상한
  5. 멱등성 키 또는 중복 제거 장치
  6. 429 및 재시도 지표 대시보드화

이 조합이면 “가끔 429가 나도 서비스가 안정적으로 회복”하는 상태를 만들 수 있습니다. 반대로 429가 계속 난다면 재시도 코드를 더 똑똑하게 만드는 것보다, 호출량 자체를 줄이거나(캐시, 배치 합치기), 워크로드를 평탄화(큐잉)하거나, 계정/모델/프로젝트 단위의 한도 설계를 재검토하는 것이 우선입니다.

마무리

429는 피할 수 없는 운영 신호입니다. 핵심은 “재시도는 하되, 폭주를 만들지 않고, 중복을 만들지 않으며, 관측 가능하게” 만드는 것입니다. Retry-After를 존중하고, 지수 백오프에 지터를 섞고, 동시성 제한까지 결합하면 대부분의 레이트 리밋 이슈는 안정적으로 제어할 수 있습니다.

다음 단계로는

  • 호출 경로에서 캐시를 어디에 둘지
  • 배치/사용자 요청을 분리해 다른 정책을 적용할지
  • 멀티 워커 환경에서 Redis 기반 리미터를 둘지

를 정하면, 비용과 안정성 모두에서 한 단계 성숙한 아키텍처로 갈 수 있습니다.