Published on

OpenAI 429·5xx 재시도, Idempotency 키로 중복 결제 막기

Authors

서버에서 OpenAI API를 호출하다 보면 429(Rate limit)나 5xx(일시적 장애)로 실패하는 순간이 반드시 옵니다. 문제는 “재시도” 자체가 아니라, 재시도 때문에 같은 작업이 두 번 수행되는 것입니다. 예를 들어 결제 영수증 생성, DB에 결과 저장, 사용자에게 알림 전송 같은 부수 효과가 있는 처리는 중복 실행되면 치명적입니다.

이 글은 OpenAI 호출을 안전하게 재시도하기 위한 실전 설계를 다룹니다.

  • 어떤 상태코드에서 재시도해야 하는지
  • 백오프와 지터를 어떻게 적용하는지
  • Idempotency 키로 중복 실행을 어떻게 막는지
  • 응답 저장, 타임아웃, 관측성까지 포함한 운영 패턴

관련해서 OpenAI 요청 검증/에러 처리도 함께 정리해두면 좋습니다. 예를 들어 Responses API에서 파라미터 오류를 재시도로 해결하려다 더 악화되는 경우가 많습니다. 필요하면 아래 글도 같이 참고하세요.

1) 429와 5xx는 “재시도 대상”이지만 조건이 있다

재시도 권장 케이스

  • 429: 분당/초당 요청량 또는 토큰 제한 초과
  • 500, 502, 503, 504: 서버/게이트웨이/일시적 장애
  • 네트워크 타임아웃, 연결 리셋 등 전송 계층 오류

재시도 비권장 케이스

  • 400 계열의 대부분: 파라미터 오류, 스키마 오류, 정책 위반 등은 재시도해도 동일하게 실패합니다.
  • 401/403: 인증/권한 문제
  • 404: 엔드포인트/리소스 문제

핵심은 이겁니다.

  • 재시도는 “일시적” 실패에만 적용
  • “영구적” 실패(입력 오류)는 즉시 실패 처리하고, 원인을 로깅/알림으로 남겨야 합니다.

2) 백오프는 필수: 고정 딜레이는 더 큰 장애를 만든다

429503에서 고정 1초 재시도를 여러 인스턴스가 동시에 수행하면, 순간적으로 트래픽이 더 폭증해 “회복 불가능한 폭주”가 생깁니다. 따라서 다음을 권장합니다.

  • 지수 백오프(Exponential Backoff)
  • 지터(Jitter, 랜덤 분산)
  • Retry-After 헤더가 있으면 그것을 우선

권장 공식(예시)

  • 기본 지연: baseDelayMs * 2^attempt
  • 지터: random(0, delay) 또는 delay * random(0.5, 1.5)
  • 최대 지연: 예를 들어 10s 또는 30s

3) Idempotency 키가 필요한 진짜 이유

재시도에서 가장 무서운 상황은 **“서버는 처리 완료했는데 클라이언트만 실패로 인지”**하는 경우입니다.

  • 요청은 OpenAI에 도착했고 처리도 완료
  • 하지만 응답을 받기 전에 네트워크가 끊김
  • 클라이언트는 타임아웃으로 실패 처리
  • 동일 요청을 재시도
  • 결과적으로 같은 작업이 중복 수행될 수 있음

여기서 Idempotency 키는 “같은 키로 같은 요청을 여러 번 보내도, 서버가 같은 결과로 취급”하도록 만드는 장치입니다. 즉, 재시도에 의해 발생하는 중복 실행/중복 과금/중복 저장을 줄이는 핵심 안전장치입니다.

주의: Idempotency 키는 만능이 아닙니다. “요청 본문이 동일”하다는 가정이 깨지면(예: 타임스탬프 포함, 메시지 일부 변경) 서버 입장에서는 다른 요청이 될 수 있습니다. 따라서 키 생성 규칙과 요청 바디의 안정성이 함께 설계되어야 합니다.

4) Idempotency 키 설계: 어떻게 만들어야 안전한가

좋은 Idempotency 키는 다음 특성을 가집니다.

  • 업무 단위로 유일: 예를 들어 “주문 orderId에 대한 요약 생성”이면 orderId가 핵심
  • 재시도 동안 동일: 같은 작업의 재시도는 동일 키를 사용
  • 충돌 가능성 낮음: UUID 또는 안정적 해시
  • 민감정보 미포함: 이메일/전화번호 같은 PII를 그대로 넣지 않기

추천 패턴 1: 도메인 키 기반

  • idem:order-summary:{orderId}

추천 패턴 2: 안정적 해시 기반

  • idem:{sha256(canonicalJson(payload))}

여기서 canonicalJson은 필드 순서/공백 등이 달라도 동일한 의미면 동일 문자열이 되도록 정규화하는 것을 의미합니다.

5) Node.js 예제: 429·5xx 재시도 + Idempotency 키

아래 예시는 OpenAI JavaScript SDK를 사용할 때의 한 가지 패턴입니다.

  • 재시도 대상: 4295xx
  • Retry-After가 있으면 우선
  • 지수 백오프 + 지터
  • 요청 헤더에 Idempotency-Key 포함
import OpenAI from "openai";
import crypto from "crypto";

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

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

function jitteredBackoffMs(attempt, baseMs = 300, capMs = 10_000) {
  const exp = Math.min(capMs, baseMs * Math.pow(2, attempt));
  const jitter = Math.floor(Math.random() * exp);
  return Math.min(capMs, jitter);
}

function makeIdempotencyKey({ userId, jobId, payload }) {
  // PII를 그대로 넣지 않고, 안정적으로 재현 가능한 입력으로 해시를 만듭니다.
  const h = crypto
    .createHash("sha256")
    .update(JSON.stringify({ userId, jobId, payload }))
    .digest("hex");
  return `idem-${jobId}-${h}`;
}

async function callWithRetry({ userId, jobId, inputText }) {
  const payload = {
    model: "gpt-4.1-mini",
    input: inputText,
  };

  const idempotencyKey = makeIdempotencyKey({ userId, jobId, payload });

  const maxAttempts = 6;

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      const res = await client.responses.create(payload, {
        headers: {
          "Idempotency-Key": idempotencyKey,
        },
      });
      return res;
    } catch (err) {
      const status = err?.status;
      const retryAfter = Number(err?.response?.headers?.get?.("retry-after"));

      const retryable =
        status === 429 ||
        (status >= 500 && status <= 599) ||
        status === undefined; // 네트워크/타임아웃 등

      if (!retryable) throw err;
      if (attempt === maxAttempts - 1) throw err;

      const delay = Number.isFinite(retryAfter)
        ? retryAfter * 1000
        : jitteredBackoffMs(attempt);

      await sleep(delay);
    }
  }
}

포인트

  • Idempotency-Key재시도 루프 밖에서 한 번만 생성해야 합니다.
  • payload가 재시도마다 바뀌면(예: 프롬프트에 현재 시간 삽입) “같은 키로 다른 요청”이 될 수 있으니, 업무 단위 입력을 고정하거나 키 생성에 포함되는 입력을 엄격히 정의하세요.

6) 멱등성은 OpenAI만으로 완성되지 않는다: 우리 시스템도 멱등해야 한다

OpenAI 호출만 멱등해도, 아래가 멱등하지 않으면 여전히 중복 문제가 발생합니다.

  • 결과를 DB에 저장하는 로직
  • 사용자에게 푸시/메일을 보내는 로직
  • 후속 워크플로우를 실행하는 큐 메시지 발행

권장하는 서버 측 패턴은 “요청 단위 레코드”를 먼저 만들고 상태를 전이시키는 방식입니다.

예시: 작업 테이블로 중복 실행 방지

  • jobId를 유니크 키로 저장
  • 상태: PENDING / RUNNING / SUCCEEDED / FAILED
  • SUCCEEDED면 동일 요청이 와도 저장된 결과를 반환
-- job_id에 유니크 제약을 둬서 중복 생성 자체를 막습니다.
create table ai_jobs (
  job_id varchar(64) primary key,
  user_id varchar(64) not null,
  status varchar(16) not null,
  result_json text null,
  created_at timestamp not null,
  updated_at timestamp not null
);

이 패턴을 쓰면,

  • OpenAI가 일시적으로 실패해도 같은 jobId로 재시도 가능
  • 클라이언트가 중복 클릭/중복 요청을 보내도 서버가 흡수
  • 최종적으로 “한 번만 처리”되는 업무 의미를 보장하기 쉬워집니다.

7) 타임아웃과 재시도는 한 세트로 설계해야 한다

타임아웃이 너무 짧으면 “서버는 처리 중인데 클라이언트만 포기”가 자주 발생하고, 재시도가 늘면서 더 많은 부하를 만듭니다. 반대로 너무 길면 워커가 묶여서 전체 처리량이 떨어집니다.

실무 권장 방향:

  • 클라이언트 HTTP 타임아웃: 예를 들어 30s 전후(업무 성격에 맞게)
  • 재시도 횟수 제한: 예를 들어 5회 내외
  • 전체 예산(Deadline) 설정: 예를 들어 “총 60초 넘기면 실패”

이런 “예산 기반 재시도”는 인프라 장애 상황에서 시스템이 스스로를 보호하는 데 특히 효과적입니다. Cloud Run처럼 순간 503이나 콜드 스타트가 섞이는 환경에서는 타임아웃/재시도 설계가 안정성에 직접 영향을 줍니다.

8) 관측성: 재시도는 보이지 않으면 비용 폭탄이 된다

재시도는 성공률을 올리지만, 실패율이 높아진 순간 비용과 지연을 폭발시킬 수 있습니다. 최소한 아래는 지표로 뽑아야 합니다.

  • 요청 수, 성공 수, 실패 수
  • 상태코드별 카운트(429, 5xx, 4xx)
  • 재시도 횟수 분포(0회, 1회, 2회…)
  • 백오프 대기 시간 누적
  • Idempotency 키 충돌/중복 감지 횟수(서버 작업 테이블 기반)

MSA라면 분산 추적을 붙여 “어떤 요청이 어떤 재시도 루프를 탔는지”를 한 번에 봐야 원인 분석이 빨라집니다.

9) 체크리스트: 운영에서 사고를 줄이는 최종 점검

  • 재시도 대상은 4295xx로 제한했는가
  • 400류 입력 오류를 재시도하지 않도록 분기했는가
  • Retry-After 헤더를 우선 적용하는가
  • 지수 백오프와 지터가 있는가
  • 재시도 횟수/총 시간 예산이 있는가
  • Idempotency-Key를 재시도 전체에 걸쳐 동일하게 유지하는가
  • 서버 측도 jobId 유니크 및 상태 전이로 멱등성을 보장하는가
  • 재시도 횟수와 실패율을 메트릭/트레이싱으로 관측하는가

10) 결론: “재시도”의 완성은 멱등성이다

OpenAI에서 4295xx는 흔하고, 재시도는 사실상 필수입니다. 하지만 재시도를 붙이는 순간부터는 중복 실행을 통제해야 하고, 그 핵심이 Idempotency-Key입니다.

정리하면 다음 조합이 가장 안전합니다.

  • 429/5xx만 재시도
  • 지수 백오프 + 지터 + Retry-After 존중
  • 요청에는 Idempotency-Key
  • 서버에는 jobId 기반 멱등 처리(유니크 키 + 상태 전이)
  • 메트릭/트레이싱으로 재시도 비용을 가시화

이 구조를 잡아두면, 장애 상황에서도 “성공률”과 “비용/중복 위험”을 동시에 관리할 수 있습니다.