- Published on
OpenAI Responses API 스트리밍 끊김·재시도 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 OpenAI Responses API를 스트리밍으로 붙이면 UX는 좋아지지만, 운영 난이도는 급격히 올라갑니다. 네트워크 단절, 프록시 타임아웃, 모바일 환경의 잦은 재연결, 서버리스 런타임 제한 같은 변수 때문에 스트림이 중간에 끊기고, 그 순간부터는 “어디까지 사용자에게 전달됐는지”와 “어디서부터 다시 받아야 하는지”가 애매해집니다.
이 글은 Responses API 스트리밍에서 끊김이 발생해도 안전하게 재시도하고, 화면에는 가능한 한 자연스럽게 이어 붙이는 설계를 다룹니다. 핵심은 다음 네 가지입니다.
- 스트리밍 이벤트를 재조립 가능한 단위로 저장하거나 캐시한다
- 재시도는 백오프 + 지터 + 한도로 제어한다
- 요청은 멱등성 키로 중복 실행을 통제한다
- 관측 지표를 통해 “끊김이 정상 범위인지”를 판단한다
관련해서 네트워크 타임아웃과 재시도 튜닝 관점은 gRPC 글의 사고방식이 그대로 적용됩니다. 필요하면 함께 참고하세요: Go gRPC 데드라인 초과? 컨텍스트·리트라이 튜닝
스트리밍이 끊기는 대표 원인
스트리밍 끊김은 대개 애플리케이션 버그가 아니라 “중간 경로” 문제입니다.
1) 리버스 프록시와 로드밸런서 타임아웃
- Nginx, ALB, Cloudflare 등에서 idle timeout이 짧으면 스트림이 끊깁니다.
- 특히 SSE는 “연결이 오래 유지되는” 전제가 있어, 기본값이 짧은 환경에서 빈번하게 터집니다.
대응
- 서버에서 주기적으로 keep-alive 성격의 데이터를 흘리거나
- 프록시 타임아웃을 늘리거나
- 스트림을 짧게 끊고 클라이언트가 폴링으로 보완하는 하이브리드 방식을 씁니다.
2) 클라이언트 네트워크 변동
모바일은 LTE, Wi‑Fi 전환 때 TCP가 끊어지는 일이 흔합니다. 사용자는 “앱이 멈췄다”고 느낍니다.
대응
- 클라이언트 재연결 로직
- 서버 측에서 “이미 생성된 토큰”을 다시 보내줄 수 있는 캐시
3) 서버리스 실행 시간 제한
Vercel, AWS Lambda 등에서 스트리밍이 런타임 제약과 충돌할 수 있습니다.
대응
- 스트리밍은 Edge 또는 장수 프로세스에서 처리
- 혹은 백엔드에서 생성 작업을 비동기로 돌리고, 프론트는 상태 API로 이어 받기
목표: 끊겨도 사용자에게는 “이어지는 것처럼”
스트리밍 복구를 설계할 때 목표를 명확히 해야 합니다.
- 목표 A: 사용자 화면에 표시된 텍스트가 중복되거나 누락되지 않게
- 목표 B: 재시도가 과도하게 OpenAI 호출을 늘리지 않게
- 목표 C: 같은 요청이 중복 실행돼도 비용 폭탄이 나지 않게
이를 위해 “스트림을 그대로 이어 받기”를 기대하기보다는, 현실적으로는 다음 중 하나로 설계합니다.
- 서버가 스트리밍 중간 결과를 저장하고, 재연결 시 그 지점부터 다시 전송
- 재시도 시 전체를 다시 생성하되, 클라이언트가 이미 받은 부분을 기준으로 중복을 제거
- 스트리밍을 포기하고, 짧은 구간 단위로 청크 생성 후 이어 붙이기
보통 1) 또는 2)가 많이 쓰이고, 비용과 구현 난이도는 2)가 낮습니다. 다만 2)는 “모델 출력이 매번 완전히 동일하지 않다”는 점 때문에 중복 제거 로직이 중요합니다.
설계 1: 서버에 스트리밍 이벤트 로그를 남겨 재전송
가장 안정적인 방식은 서버가 OpenAI 스트림을 받아서
- 이벤트를 순서대로 누적 저장하고
- 클라이언트에는 SSE로 중계하며
- 클라이언트가 끊기면
lastEventId같은 커서를 들고 재연결해 - 서버가 그 이후 이벤트만 재전송하는 구조입니다.
이때 이벤트는 “문자열 델타” 단위로 저장할 수도 있고, “완성된 텍스트 스냅샷”을 주기적으로 저장할 수도 있습니다.
이벤트 ID와 커서
MDX 빌드 에러를 피하기 위해 부등호는 인라인 코드로 표기합니다.
- 클라이언트가 마지막으로 처리한 이벤트 ID를
last_event_id로 서버에 전달 - 서버는
last_event_id이후 이벤트부터 재전송
Node.js 예시: OpenAI 스트림을 SSE로 브리지
아래 코드는 개념 예시입니다. OpenAI SDK 버전에 따라 이벤트 타입은 달라질 수 있으니, 실제로는 이벤트 이름을 로깅해서 맞추는 게 안전합니다.
import OpenAI from "openai";
import { randomUUID } from "crypto";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
type StreamEvent = {
id: string;
seq: number;
type: string;
data: unknown;
};
// 메모리 예시. 운영에서는 Redis 같은 외부 저장소 권장
const eventStore = new Map<string, StreamEvent[]>();
export async function handleChatSSE(req: Request): Promise<Response> {
const url = new URL(req.url);
const sessionId = url.searchParams.get("session_id") ?? randomUUID();
const lastEventId = Number(url.searchParams.get("last_event_id") ?? "-1");
const headers = new Headers({
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
});
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (ev: StreamEvent) => {
controller.enqueue(encoder.encode(`id: ${ev.seq}\n`));
controller.enqueue(encoder.encode(`event: ${ev.type}\n`));
controller.enqueue(encoder.encode(`data: ${JSON.stringify(ev.data)}\n\n`));
};
// 1) 재연결이면 저장된 이벤트 재전송
const cached = eventStore.get(sessionId) ?? [];
for (const ev of cached) {
if (ev.seq > lastEventId) send(ev);
}
// 이미 완료된 세션이면 여기서 종료 가능
// (운영에서는 done 플래그를 저장)
// 2) 신규 생성이 필요하면 OpenAI 스트림 시작
if (cached.length === 0) {
eventStore.set(sessionId, []);
const response = await openai.responses.create({
model: "gpt-4.1-mini",
input: "스트리밍이 끊겨도 복구되는 SSE 설계 요약해줘",
stream: true,
});
let seq = 0;
for await (const event of response) {
const ev: StreamEvent = {
id: sessionId,
seq: seq++,
type: event.type,
data: event,
};
eventStore.get(sessionId)!.push(ev);
send(ev);
}
}
controller.close();
},
});
return new Response(stream, { headers });
}
포인트
- 서버는 OpenAI 스트림을 “원본”으로 받고, 클라이언트에는 “중계”만 합니다.
eventStore는 반드시 외부 저장소로 옮기는 게 좋습니다. 프로세스 재시작 시 복구가 불가능해집니다.- 이벤트 크기가 커질 수 있으니, 일정 길이 이상이면 스냅샷만 남기고 델타는 버리는 식의 압축 정책이 필요합니다.
설계 2: 재시도 시 전체 재생성, 클라이언트에서 중복 제거
서버 상태를 최소화하고 싶다면, 끊기면 그냥 같은 프롬프트로 다시 호출하고, 클라이언트가 이미 받은 텍스트를 기준으로 이어 붙이는 방식이 있습니다.
문제는 모델 출력이 매번 동일하지 않을 수 있다는 점입니다. 따라서 “단순히 이어 붙이기”가 아니라, 다음 규칙을 둡니다.
- 클라이언트는 누적 텍스트를 가지고 있음
- 재연결 후 새로 받은 텍스트에서, 기존 텍스트와의 최장 공통 접두/접미를 찾아 중복 구간을 제거
- 겹치는 구간이 너무 작으면, 사용자에게 “재시도 중, 일부 문장이 바뀔 수 있음” 같은 안내를 하거나, 아예 스냅샷 기반으로 교체
간단한 중복 제거 함수
export function mergeByOverlap(prev: string, next: string): string {
if (!prev) return next;
if (!next) return prev;
const max = Math.min(prev.length, next.length);
// prev의 suffix와 next의 prefix가 최대한 겹치는 길이를 찾음
for (let k = max; k >= 1; k--) {
if (prev.slice(-k) === next.slice(0, k)) {
return prev + next.slice(k);
}
}
return prev + next; // 겹침이 없으면 그냥 이어 붙임
}
운영 팁
- 겹침 탐색은 O
(n^2)가 될 수 있으니, 텍스트가 길면 윈도 크기를 제한하세요. - 모델이 문단 단위로 출력을 바꾸는 경우가 있어, 겹침이 작을 때는 “전체 교체” 전략이 더 낫기도 합니다.
재시도 정책: 백오프, 지터, 한도
스트리밍이 끊길 때 즉시 무한 재시도를 걸면, 사용자도 불편하고 비용도 늘고, 장애 시에는 자가증폭이 됩니다. 재시도는 반드시 정책으로 통제합니다.
권장 기본값
- 최대 재시도 횟수: 3~5회
- 백오프: 500ms, 1s, 2s, 4s…
- 지터: 0.2~0.5 랜덤 가산
- 총 타임박스: 예를 들어 30초 내에 복구 안 되면 실패로 전환
TypeScript 재시도 유틸
type RetryOptions = {
retries: number;
baseMs: number;
maxMs: number;
jitterRatio: number;
shouldRetry?: (e: unknown) => boolean;
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function withRetry<T>(fn: () => Promise<T>, opt: RetryOptions): Promise<T> {
let attempt = 0;
let lastErr: unknown;
while (attempt <= opt.retries) {
try {
return await fn();
} catch (e) {
lastErr = e;
if (opt.shouldRetry && !opt.shouldRetry(e)) break;
if (attempt === opt.retries) break;
const exp = Math.min(opt.maxMs, opt.baseMs * Math.pow(2, attempt));
const jitter = exp * opt.jitterRatio * Math.random();
await sleep(exp + jitter);
attempt++;
}
}
throw lastErr;
}
여기서 shouldRetry는 중요합니다. 예를 들어 인증 실패나 입력 검증 오류는 재시도해도 해결되지 않습니다.
멱등성: 중복 실행을 막는 키 설계
스트리밍이 끊기면 클라이언트는 “요청이 실패한 줄 알고” 다시 요청을 보냅니다. 이때 서버가 매번 새로 OpenAI 호출을 하면 비용이 중복됩니다.
해결은 멱등성 키입니다.
- 클라이언트가
idempotency_key를 생성해 요청 헤더나 바디에 포함 - 서버는
idempotency_key로 진행 중인 작업을 조회 - 이미 실행 중이면 기존 스트림에 붙이거나, 캐시된 결과를 반환
Redis를 쓴다면
SETNX idempotency_key value EX ttl로 락 획득- 락이 있으면 기존 작업 조회
- 작업 완료 시 결과 위치를 저장
이 패턴은 결제, 주문 생성뿐 아니라 “비용이 드는 외부 API 호출”에도 그대로 적용됩니다.
타임아웃: 어디에 어떤 타임아웃을 걸어야 하나
스트리밍은 “길게 유지”하는 특성 때문에 타임아웃이 더 까다롭습니다.
권장 체크리스트
- OpenAI 호출 자체의 타임아웃: 너무 짧으면 정상 요청도 끊김
- 서버의 업스트림 타임아웃: 프록시에서 끊기지 않게 조정
- 클라이언트의 무응답 타임아웃: 일정 시간 이벤트가 없으면 재연결
클라이언트 무응답 타임아웃은 보통 “마지막 이벤트 수신 후 N초”로 둡니다.
관측: 끊김은 버그가 아니라 지표다
스트리밍 끊김을 0으로 만들기는 어렵습니다. 대신 “정상 범위”를 정의하고 관측해야 합니다.
추천 지표
- 스트림 시작 대비 정상 종료 비율
- 평균 스트림 지속 시간
- 재시도 횟수 분포
- 사용자당 OpenAI 호출 수
- 중복 실행 차단 횟수(멱등성 키로 막은 수)
로그에 남길 필드
session_id,idempotency_key- 마지막으로 전송한
event_seq - 끊김 원인 분류: 클라이언트 종료, 프록시 종료, 업스트림 오류
Next.js에서 흔한 함정: 런타임과 스트리밍의 충돌
Next.js에서 스트리밍을 구현할 때는 RSC, Route Handler, Edge 런타임 조합에 따라 동작이 달라질 수 있습니다. 특히 캐시나 스트리밍 응답이 섞이면 예기치 않은 문제가 생깁니다. 관련 이슈를 겪고 있다면 아래 글도 도움이 됩니다.
또한 UI에서 스트리밍 텍스트를 상태로 붙이다가 hydration mismatch를 만들기도 합니다. SSR과 클라이언트 렌더링 경계가 애매하면 확인해보세요.
실전 권장 아키텍처 요약
운영에서 가장 무난한 조합은 아래입니다.
- 클라이언트는 SSE로 수신
- 서버는 OpenAI 스트림을 받아 중계
- 서버는 이벤트를 Redis에 짧게 저장
- 클라이언트 재연결 시
last_event_id로 이어 받기 - 모든 요청에
idempotency_key를 붙여 중복 실행 방지 - 재시도는 백오프와 총 타임박스로 제한
이렇게 하면 “끊김이 있어도 UX는 유지”되고, “비용은 통제”되며, “장애 시에도 재시도가 폭주하지 않는” 구조를 만들 수 있습니다.
마무리
Responses API 스트리밍의 본질은 생성 자체가 아니라 “전송의 신뢰성”입니다. 끊김을 전제로 설계하면, 스트리밍은 단순한 UX 기능이 아니라 안정적인 제품 기능이 됩니다.
다음 단계로는
- Redis 기반 이벤트 저장의 메모리 상한과 TTL 정책
- 세션 단위 동시 접속 제어(동일 세션의 다중 탭)
- 스트리밍 중 사용자 취소 처리(AbortSignal)와 비용 절감
까지 확장해보면 운영 품질이 한 단계 올라갑니다.