- Published on
OpenAI 429·5xx 재시도, Idempotency 키로 중복 결제 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI API를 호출하다 보면 429(Rate limit)나 5xx(일시적 장애)로 실패하는 순간이 반드시 옵니다. 문제는 “재시도” 자체가 아니라, 재시도 때문에 같은 작업이 두 번 수행되는 것입니다. 예를 들어 결제 영수증 생성, DB에 결과 저장, 사용자에게 알림 전송 같은 부수 효과가 있는 처리는 중복 실행되면 치명적입니다.
이 글은 OpenAI 호출을 안전하게 재시도하기 위한 실전 설계를 다룹니다.
- 어떤 상태코드에서 재시도해야 하는지
- 백오프와 지터를 어떻게 적용하는지
- Idempotency 키로 중복 실행을 어떻게 막는지
- 응답 저장, 타임아웃, 관측성까지 포함한 운영 패턴
관련해서 OpenAI 요청 검증/에러 처리도 함께 정리해두면 좋습니다. 예를 들어 Responses API에서 파라미터 오류를 재시도로 해결하려다 더 악화되는 경우가 많습니다. 필요하면 아래 글도 같이 참고하세요.
- OpenAI Responses API 400 invalid_request_error 원인과 해결
- OpenAI Responses API 400 invalid_tool_output 해결법
1) 429와 5xx는 “재시도 대상”이지만 조건이 있다
재시도 권장 케이스
429: 분당/초당 요청량 또는 토큰 제한 초과500,502,503,504: 서버/게이트웨이/일시적 장애- 네트워크 타임아웃, 연결 리셋 등 전송 계층 오류
재시도 비권장 케이스
400계열의 대부분: 파라미터 오류, 스키마 오류, 정책 위반 등은 재시도해도 동일하게 실패합니다.401/403: 인증/권한 문제404: 엔드포인트/리소스 문제
핵심은 이겁니다.
- 재시도는 “일시적” 실패에만 적용
- “영구적” 실패(입력 오류)는 즉시 실패 처리하고, 원인을 로깅/알림으로 남겨야 합니다.
2) 백오프는 필수: 고정 딜레이는 더 큰 장애를 만든다
429나 503에서 고정 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를 사용할 때의 한 가지 패턴입니다.
- 재시도 대상:
429와5xx 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) 체크리스트: 운영에서 사고를 줄이는 최종 점검
- 재시도 대상은
429와5xx로 제한했는가 400류 입력 오류를 재시도하지 않도록 분기했는가Retry-After헤더를 우선 적용하는가- 지수 백오프와 지터가 있는가
- 재시도 횟수/총 시간 예산이 있는가
Idempotency-Key를 재시도 전체에 걸쳐 동일하게 유지하는가- 서버 측도
jobId유니크 및 상태 전이로 멱등성을 보장하는가 - 재시도 횟수와 실패율을 메트릭/트레이싱으로 관측하는가
10) 결론: “재시도”의 완성은 멱등성이다
OpenAI에서 429와 5xx는 흔하고, 재시도는 사실상 필수입니다. 하지만 재시도를 붙이는 순간부터는 중복 실행을 통제해야 하고, 그 핵심이 Idempotency-Key입니다.
정리하면 다음 조합이 가장 안전합니다.
429/5xx만 재시도- 지수 백오프 + 지터 +
Retry-After존중 - 요청에는
Idempotency-Key - 서버에는
jobId기반 멱등 처리(유니크 키 + 상태 전이) - 메트릭/트레이싱으로 재시도 비용을 가시화
이 구조를 잡아두면, 장애 상황에서도 “성공률”과 “비용/중복 위험”을 동시에 관리할 수 있습니다.