- Published on
LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버는 멀쩡한데 LLM 스트리밍(SSE)만 붙이면 갑자기 499/502가 늘고, 사용자 화면에서는 토큰이 몇 초 나오다 툭 끊기는 상황을 한 번 겪으면 감이 옵니다. 원인은 모델이 아니라, 대개 프록시 계층의 버퍼링/타임아웃/프로토콜(HTTP/2) 상호작용입니다.
특히 SSE는 “긴 연결 + 작은 청크를 자주 flush”하는 특성 때문에, 프록시가 기본값대로 동작하면 다음이 동시에 터집니다.
- 프록시가 응답을 버퍼링해서 TTFB(첫 바이트 시간)가 커짐
- 중간에 “아무 데이터도 안 온다”고 판단해 idle timeout으로 끊음
- HTTP/2/1.1 변환 지점에서 flush가 지연되거나, 커넥션 재사용/keepalive가 꼬여 502
- 클라이언트는 기다리다 끊고 나가서 499(Client Closed Request) 증가
아래는 현업에서 가장 빨리 원인 좁히고, TTFB와 중단률을 동시에 잡는 순서대로 정리한 체크리스트입니다.
1) 먼저 지표부터: 499/502가 의미하는 “끊긴 위치”를 확정
499는 대부분 “클라이언트가 먼저 포기”
- 브라우저(EventSource)나 앱이 타임아웃, 네트워크 전환, 탭 백그라운드 등으로 연결을 닫음
- 하지만 진짜 원인은 대개 그 전에 프록시가 버퍼링/지연을 만들어 사용자 체감상 멈춘 것
502는 “프록시가 업스트림을 정상으로 못 봄”
- 업스트림 연결 실패, 업스트림이 응답을 중간에 끊음, 타임아웃 등
- SSE에서는 “오래 열린 연결”이므로 프록시 idle timeout 또는 업스트림 keepalive/HTTP2 설정이 흔한 범인
로그 상관관계(필수)
- Nginx:
$request_time,$upstream_response_time,$upstream_status,$upstream_bytes_received,$bytes_sent - Envoy:
upstream_rq_time,downstream_cx_destroy,upstream_cx_destroy,response_flags(UT, UF, DC 등)
핵심은 “TTFB가 큰지(초반 버퍼링)” vs “중간에 idle로 끊기는지(타임아웃)”를 먼저 갈라야 합니다.
2) SSE의 정석: 헤더/바디/flush가 제대로 되는지부터 확인
SSE 응답 기본 조건
Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive- 가능한 경우
X-Accel-Buffering: no(Nginx 버퍼링 힌트)
FastAPI 예시(서버가 주기적으로 flush하도록):
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio
app = FastAPI()
async def sse():
# 첫 바이트를 빨리 보내 TTFB를 강제로 낮춤
yield "event: ready\ndata: ok\n\n"
# idle timeout 회피용 heartbeat (프록시/클라이언트 모두에 유효)
while True:
yield ": ping\n\n" # SSE comment frame
await asyncio.sleep(15)
@app.get("/stream")
async def stream():
headers = {
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
}
return StreamingResponse(sse(), media_type="text/event-stream", headers=headers)
: ping\n\n같은 comment heartbeat는 UI에 표시되지 않으면서도 “데이터가 흐르고 있다”는 신호가 되어, 프록시 idle timeout과 모바일 네트워크 NAT 타임아웃을 동시에 완화합니다.
추가로 SSE/프록시 이슈 전반은 FastAPI Uvicorn에서 SSE 웹소켓 LLM 스트리밍이 프록시 뒤에서 끊길 때 Cloudflare Nginx ALB 버퍼 타임아웃 gzip으로 EventSource failed 100% 재현 해결 체크리스트도 함께 보면 원인 분해가 빨라집니다.
3) Nginx에서 가장 많이 놓치는 4가지: buffering, gzip, timeouts, http2
(1) 프록시 버퍼링 끄기: TTFB와 “중간 멈춤” 체감의 1순위
SSE는 작은 청크를 즉시 내려야 하는데, Nginx가 기본 버퍼링을 하면 모아서 보내다 첫 토큰이 늦어지고, 클라이언트는 “멈췄다”고 느껴 499로 이탈합니다.
location /stream {
proxy_pass http://llm_upstream;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # 핵심
proxy_cache off;
# SSE는 압축이 flush를 방해할 수 있음
gzip off;
add_header X-Accel-Buffering no;
}
체크 포인트
proxy_buffering off가 location 레벨에 적용되는지(상위 블록에서 덮어쓰는지)gzip on이 전역이면 SSE location에서 반드시gzip off
(2) idle timeout: “토큰이 잠깐 안 나오면 끊기는” 문제
LLM이 생각하는 구간(툴 호출, 리트리벌, 긴 프롬프트 처리)에서 10~60초 조용해질 수 있습니다. 프록시가 이를 idle로 판단하면 502/504 또는 연결 종료가 납니다.
location /stream {
proxy_pass http://llm_upstream;
proxy_read_timeout 3600s; # 업스트림에서 바이트가 늦게 와도 기다림
proxy_send_timeout 3600s;
send_timeout 3600s; # 다운스트림 전송 타임아웃
keepalive_timeout 75s;
}
주의: timeout을 무한대로만 늘리면 커넥션이 쌓여 장애가 커질 수 있습니다. 아래 “Best Practice”에서 heartbeat + 적정 timeout 조합을 권합니다.
(3) HTTP/2: 프록시가 HTTP/2를 “어디까지” 쓰는지 확인
- 클라이언트↔Nginx는 HTTP/2, Nginx↔업스트림은 HTTP/1.1인 구성이 흔함
- HTTP/2 자체가 문제라기보다, 버퍼링/압축/flow control과 결합될 때 flush가 늦어지는 케이스가 있습니다.
실전 체크
- 브라우저 개발자도구 Network에서 Response가 “pending”인데 서버 로그는 이미 토큰을 쏘는지
curl -N로 직접 확인(중간 프록시 영향 최소화)
curl -N -H "Accept: text/event-stream" https://api.example.com/stream
(4) 업스트림 keepalive/커넥션 재사용
SSE는 연결이 길게 유지되므로, 업스트림 풀/keepalive 설정이 어설프면 502가 늘 수 있습니다.
upstream llm_upstream {
server 10.0.0.10:8000;
keepalive 64;
}
4) Envoy에서 자주 터지는 지점: idle_timeout, per_try_timeout, buffering
Envoy는 기본값/필터 조합에 따라 “조용한 스트림”을 끊어버리거나, 재시도 정책이 SSE에 악영향을 줄 수 있습니다.
(1) downstream/upstream idle_timeout을 명시
- downstream: 클라이언트가 데이터를 안 읽거나 네트워크가 멈춘 경우
- upstream: 업스트림이 조용한 경우
예시(개념적으로):
route_config:
virtual_hosts:
- name: api
routes:
- match: { prefix: "/stream" }
route:
cluster: llm
timeout: 0s # 스트리밍은 route timeout 끔(또는 충분히 크게)
idle_timeout: 3600s # 스트림 idle 허용
clusters:
- name: llm
connect_timeout: 2s
common_http_protocol_options:
idle_timeout: 3600s
(2) 재시도(retry)와 per_try_timeout은 SSE에 신중
SSE는 “한 번 시작되면 중간 재시도”가 사실상 불가능합니다(중복 토큰, 끊김, 비용 증가). Envoy의 재시도가 켜져 있으면 502가 “자동 복구”되는 듯 보이다가, 실제로는 중복 생성/클라이언트 혼란을 유발할 수 있습니다.
/stream라우트는 retry를 끄거나, 최소화- 대신 애플리케이션 레벨에서 “재연결 시 resume(체크포인팅)”을 설계
스트리밍 재시도/체크포인팅은 OpenAI Responses API 스트리밍 끊김 타임아웃 완전 복구 가이드에서 설명한 패턴이 그대로 도움이 됩니다.
5) TTFB를 낮추면서 중단률도 줄이는 “2단계” 전략
현장에서 효과가 컸던 조합은 아래입니다.
1단계: “즉시 1바이트라도 보내기”로 TTFB 고정
- 요청을 받자마자
event: ready같은 프레임을 먼저 보냄 - 프록시 버퍼링이 꺼져 있지 않으면 여기서 바로 티가 납니다(여전히 늦게 도착)
2단계: heartbeat로 idle timeout을 구조적으로 제거
- 10~20초 간격
: ping\n\n - 프록시 timeout을 무한정 늘리는 대신, heartbeat 주기 < idle_timeout으로 설계
권장 예시
- heartbeat: 15초
- Nginx/Envoy idle_timeout: 60
120초 이상(환경 따라 510분)
6) 트러블슈팅: 증상별로 가장 먼저 볼 것
증상 A: 첫 토큰이 5~20초 뒤에 한 번에 몰아서 옴
proxy_buffering off적용 여부gzip off적용 여부- 업스트림 앱이 flush를 안 하는지(프레임을 yield만 하고 실제 전송이 지연되는 런타임 설정)
증상 B: 30초/60초/120초처럼 “딱 떨어지는 시간”에 끊김
- 프록시/로드밸런서 idle timeout 전형
- 해결: heartbeat +
proxy_read_timeout/Envoyidle_timeout조정
증상 C: 특정 클라이언트(모바일, 사내망)에서만 자주 499
- 네트워크 전환/NAT 타임아웃
- 해결: heartbeat 주기를 더 짧게(10~15초), 클라이언트 재연결 로직 점검
증상 D: 배포/스케일링 시점에 502가 스파이크
- 롤링 업데이트에서 장기 연결이 잘려나감(드레이닝 미흡)
- Ingress/프록시와 앱 서버의 graceful shutdown, connection draining 필요
쿠버네티스 환경이라면 Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝의 드레이닝/타임아웃 파트가 그대로 재현됩니다.
7) Best Practice 체크리스트(운영 기준)
프록시(Nginx/Envoy) 공통
-
/stream경로는 버퍼링/캐시 비활성화 - gzip 비활성화(SSE는 압축 이점보다 flush 지연 리스크가 큼)
- idle timeout은 충분히, 대신 heartbeat로 안전하게 유지
- 스트리밍 라우트는 retry 정책 최소화
- 로그에
request_time,upstream_response_time, bytes sent/received를 남겨 TTFB vs idle cut을 구분
애플리케이션
- 요청 직후 “ready 프레임”으로 TTFB 강제
- 10~20초 heartbeat
- 클라이언트 재연결 시 resume 전략(마지막 이벤트 id, 토큰 오프셋, generation id 등)
검증 방법(재현 가능한 테스트)
-
curl -N으로 스트림이 즉시 내려오는지 확인 - 프록시 on/off(직접 업스트림 호출 vs 프록시 경유) 비교
- 5분 이상 장기 연결에서 끊김 여부 확인
결론: “버퍼링 끄고, heartbeat 넣고, idle timeout을 설계”하면 같이 해결된다
LLM SSE 스트리밍에서 499/502가 늘고 응답이 끊길 때, 모델/SDK부터 의심하면 끝이 없습니다. 대부분은 프록시가 SSE를 “일반 HTTP 응답”처럼 다루면서 생기는 문제입니다.
오늘 바로 할 일은 3가지입니다.
/stream에서 proxy buffering + gzip을 확실히 끄기- **heartbeat(10~20초)**를 넣고, 프록시 idle timeout을 그보다 길게 맞추기
- HTTP/2 포함 경로에서
curl -N로 TTFB와 중간 끊김을 재현/비교하기
이 3가지만 제대로 적용해도 TTFB는 눈에 띄게 줄고, “툭 끊기는” 중단률이 함께 내려갑니다.