- Published on
OpenAI Responses API 409 499 충돌 취소 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그에 409와 499가 같이 찍히기 시작하면 팀 분위기가 급격히 나빠집니다. 사용자는 “답이 멈췄다”고 하고, 백엔드는 “우린 정상 응답 중이었다”고 하고, 프론트는 “사용자가 페이지를 닫았다”고 합니다. 특히 OpenAI Responses API를 스트리밍(SSE) 으로 붙여두면, 네트워크/프록시/브라우저 취소가 섞이면서 충돌(409) 과 클라이언트 취소(499) 가 한 덩어리처럼 보입니다.
이 글에서는 409와 499를 서로 다른 문제로 분리하고, 현업에서 재현 가능한 패턴으로 원인을 좁힌 뒤, 멱등성(idempotency)·취소 전파·리트라이·관측(Observability) 까지 포함해 “다시는 같은 장애로 밤새지 않게” 만드는 방법을 정리합니다.
> 스트리밍 끊김/타임아웃이 함께 의심된다면: OpenAI Responses API 스트리밍 끊김 타임아웃 완전 복구 가이드
409와 499를 먼저 정의하자
409 Conflict가 의미하는 것(실무 관점)
409는 “요청이 현재 리소스 상태와 충돌한다”는 뜻입니다. Responses API에서 흔한 실무 원인은 대략 아래로 수렴합니다.
- 중복 생성 요청: 같은 사용자 액션이 여러 번 전송(더블 클릭, 재전송, 프론트 재시도)
- 같은 대화/세션에 대한 동시 실행 제어 실패: 하나의 세션에 동시에 여러 응답 생성 시도
- 서버 측 상태 머신 충돌: “이미 종료된 스트림/작업에 추가 토큰을 보내려는” 류의 로직 버그
즉, 409는 네트워크가 아니라 애플리케이션 레벨 동시성/멱등성 문제인 경우가 많습니다.
499 Client Closed Request가 의미하는 것
499는 NGINX 같은 프록시가 “클라이언트가 연결을 끊었다”고 기록할 때 자주 보입니다(표준 HTTP 상태 코드는 아니지만 현장에서 매우 흔함).
- 사용자가 탭 닫음/뒤로가기
- 프론트가
AbortController.abort()로 요청 취소 - 모바일 네트워크 전환, 브라우저 절전, 프록시 idle timeout
- SSE를 프록시가 버퍼링/압축해서 끊기는 경우
499는 “OpenAI가 499를 준다”기보다, 우리 인프라/프록시가 관측한 결과인 경우가 많습니다.
장애 패턴 1: 409는 중복 요청이 만든다
재현 시나리오
- 프론트에서 전송 버튼 연타
- 네트워크가 느려 재전송 로직이 동작
- 스트리밍이 길어져 사용자가 기다리다 다시 전송
이때 백엔드가 “요청당 1회 생성”을 보장하지 않으면, 같은 입력으로 동시에 두 개의 Responses 생성이 시작되고, 내부 상태(세션 락, DB row, 캐시 키)가 충돌하면서 409가 튀기 쉽습니다.
해결 1: 멱등성 키를 강제하라(가장 강력)
가장 좋은 해법은 “같은 사용자 액션”을 식별하는 idempotency key를 만들고, 백엔드에서 그 키로 단 한 번만 생성되게 하는 것입니다.
다음은 FastAPI + Redis로 멱등성을 구현하는 전형적인 패턴입니다.
# pip install fastapi redis openai
import json
import time
import hashlib
from fastapi import FastAPI, Header, HTTPException
from redis import Redis
from openai import OpenAI
app = FastAPI()
redis = Redis(host="localhost", port=6379, decode_responses=True)
client = OpenAI()
TTL_SECONDS = 60
def stable_key(user_id: str, prompt: str, request_id: str | None):
# 프론트에서 request_id(예: uuid v4)를 보내는 게 가장 좋음
base = request_id or f"{user_id}:{prompt}"
return "idem:" + hashlib.sha256(base.encode()).hexdigest()
@app.post("/chat")
def chat(user_id: str, prompt: str, x_request_id: str | None = Header(default=None)):
key = stable_key(user_id, prompt, x_request_id)
# SETNX로 최초 1회만 통과
if not redis.set(key, "IN_PROGRESS", nx=True, ex=TTL_SECONDS):
# 이미 진행 중이거나 완료된 요청
cached = redis.get(key + ":result")
if cached:
return json.loads(cached)
raise HTTPException(status_code=409, detail="Duplicate/in-flight request")
try:
resp = client.responses.create(
model="gpt-4.1-mini",
input=prompt,
)
result = {"id": resp.id, "output_text": resp.output_text}
redis.set(key + ":result", json.dumps(result), ex=TTL_SECONDS)
return result
finally:
# IN_PROGRESS 키는 TTL로 자연 정리되지만, 빠른 해제를 원하면 삭제
redis.delete(key)
핵심은 아래 3가지입니다.
- 프론트가 request_id를 생성해서 보내면 가장 안정적(더블 클릭/재전송에도 동일 키)
- 서버는 SETNX(원자적) 로 “첫 요청만” 통과
- 결과 캐시를 두면 UX가 좋아짐(사용자는 같은 답을 즉시 받음)
해결 2: 세션 단위로 동시 실행을 막아라(락)
대화 세션(예: conversation_id) 당 한 번만 생성되게 하려면 분산 락을 둡니다.
- Redis
SET lock:conv:{id} NX EX 30 - DB row-level lock
- 애플리케이션 레벨 mutex(단일 인스턴스일 때만)
이 방식은 “같은 세션에서 동시에 두 질문을 허용할지” 정책에 따라 선택합니다.
장애 패턴 2: 499는 취소 전파가 안 돼서 비용과 409를 부른다
499 자체는 “클라이언트가 끊었다”지만, 진짜 문제는 그 다음입니다.
- 클라이언트는 끊었는데 백엔드가 계속 OpenAI 스트리밍을 읽음 → 불필요한 토큰 비용
- 백엔드가 중간 상태를 DB에 남김 → 다음 요청이 그 상태와 충돌 → 2차 409
해결 1: 클라이언트 취소를 서버에서 감지하고 즉시 중단
FastAPI/Starlette에서 스트리밍 응답을 할 때는 request.is_disconnected()를 주기적으로 확인합니다.
import asyncio
from fastapi import FastAPI, Request
from starlette.responses import StreamingResponse
from openai import OpenAI
app = FastAPI()
client = OpenAI()
@app.post("/stream")
async def stream(request: Request):
async def gen():
# OpenAI 스트리밍 예시(개념 코드)
stream = client.responses.stream(
model="gpt-4.1-mini",
input="Write a short guide",
)
try:
for event in stream:
if await request.is_disconnected():
# 클라이언트가 끊었으면 즉시 중단
break
# SSE 프레임 형태로 전달(예시)
yield f"data: {event}\n\n"
finally:
# 스트림/리소스 정리(라이브러리 제공 방식에 맞게)
try:
stream.close()
except Exception:
pass
return StreamingResponse(gen(), media_type="text/event-stream")
포인트:
- “프록시가 499를 찍었다”는 건 이미 늦은 신호일 수 있습니다.
- 서버가 취소를 빨리 감지할수록 비용과 후속 충돌이 줄어듭니다.
해결 2: 프론트는 AbortController를 표준화하고, 재시도 전에 이전 요청을 반드시 취소
프론트에서 “새 요청 시작 = 이전 요청 취소”가 안 되면, 사용자가 입력을 바꿔도 이전 요청이 계속 살아있어 서버 상태를 어지럽힙니다.
let currentController = null;
async function sendPrompt(prompt) {
if (currentController) currentController.abort();
currentController = new AbortController();
const res = await fetch('/stream', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Request-Id': crypto.randomUUID()},
body: JSON.stringify({prompt}),
signal: currentController.signal,
});
// SSE 파싱은 환경에 맞게 구현
}
이렇게 하면 서버 입장에서도 “동시에 여러 개의 생성 작업”이 생길 여지가 줄어 409도 같이 감소합니다.
장애 패턴 3: 프록시/인그레스가 스트리밍 연결을 끊어 499를 만든다
SSE/스트리밍은 프록시 기본값과 자주 충돌합니다.
proxy_read_timeout이 짧음- 버퍼링이 켜져 있어 이벤트가 즉시 flush되지 않음
- gzip/압축이 SSE에 부정적 영향을 주는 환경
인프라 레벨에서 스트리밍 안정화가 필요하면 아래 글의 체크리스트가 매우 도움이 됩니다.
- FastAPI Uvicorn에서 SSE 웹소켓 LLM 스트리밍이 프록시 뒤에서 끊길 때 Cloudflare Nginx ALB 버퍼 타임아웃 gzip으로 EventSource failed 100% 재현 해결 체크리스트
- 쿠버네티스 인그레스/업스트림 튜닝까지 포함해 보려면: Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝
409/499를 줄이는 리트라이 설계(하면 안 되는 것 포함)
하면 안 되는 리트라이
- 409에 무조건 재시도: 충돌은 재시도한다고 해결되지 않습니다. 오히려 중복 요청을 더 늘립니다.
- 취소(499) 직후 자동 재시도: 사용자가 취소한 요청을 다시 살리는 꼴이 될 수 있습니다.
해야 하는 리트라이
- 네트워크 계층의 일시 오류(예: 500/503/502)는 정책적으로 재시도 가치가 큽니다.
- 단, 스트리밍 요청은 “중간까지 받았다가 끊김”이 흔하므로 체크포인팅/재개 전략이 필요합니다.
이 부분은 별도 글로 깊게 다룬 내용이 있어 함께 보길 권합니다.
트러블슈팅 체크리스트(현장형)
1) 409가 늘었을 때
- 같은
user_id + conversation_id에서 동시 요청이 발생했는지(서버 로그 상관관계) - 프론트에서 더블 클릭/엔터 연타 방지(UI disable) 했는지
- request_id를 프론트가 생성해 보내는지, 서버가 멱등성 키를 강제하는지
- 백엔드 워커가 여러 개일 때(스케일아웃)도 멱등성이 유지되는지(로컬 메모리 락은 실패)
2) 499가 늘었을 때
- NGINX/ALB/Cloudflare 등 중간 프록시 타임아웃과 버퍼링 설정 확인
- 서버가
is_disconnected()를 보고 OpenAI 스트림을 중단하는지 - 프론트가 라우트 이동/새 요청 시 이전 요청을 abort하는지
- 모바일/사내망에서만 재현되는지(네트워크 정책)
3) 409와 499가 같이 늘었을 때(가장 흔함)
- 사용자가 “멈춘 줄 알고” 재전송 → 프록시에서는 이전 요청이 499로 종료 → 서버는 이전 작업을 정리 못해 다음 요청과 충돌(409)
- 해결 순서:
- 스트리밍 안정화(프록시/타임아웃)
- 취소 전파(서버에서 즉시 중단)
- 멱등성 키/락으로 중복 생성 방지
Best Practice 아키텍처 요약
- 프론트
- 전송 버튼 disable +
AbortController표준화 - 모든 생성 요청에
X-Request-Id부여
- 전송 버튼 disable +
- 백엔드
X-Request-Id기반 멱등성(SETNX + 결과 캐시)- 세션 단위 동시성 정책(락) 명확화
- 스트리밍 중
is_disconnected()로 취소 감지 후 즉시 중단
- 인프라
- SSE 버퍼링/압축/타임아웃 튜닝
- 499는 “클라이언트 취소”이지만, 실제로는 프록시가 끊는 경우가 많으므로 계층별 로그 상관관계 필수
- 관측
- request_id를 프론트→백엔드→OpenAI 호출까지 전파
- 409/499를 별도 알람으로 묶지 말고, 원인별 대시보드(중복률, 취소율, 평균 스트림 지속시간)로 분리
결론
Responses API에서 409는 대개 중복 요청/동시성 제어 실패의 신호이고, 499는 클라이언트(또는 프록시)가 스트리밍 연결을 끊었다는 관측값입니다. 둘이 동시에 늘어날 때는 “끊김 → 재전송 → 충돌”의 연쇄가 거의 정답입니다.
오늘 바로 할 수 있는 액션은 3가지입니다.
- 프론트에
X-Request-Id와AbortController를 표준으로 넣기 - 백엔드에 Redis 기반 멱등성 키(SETNX)로 409를 구조적으로 제거하기
- 스트리밍 취소 전파(
is_disconnected)와 프록시 SSE 튜닝으로 499를 줄이기
이 3가지만 적용해도 409/499는 “가끔 보이는 로그” 수준으로 떨어지고, 사용자 체감 품질과 비용이 같이 개선됩니다.