Published on

FastAPI Uvicorn에서 SSE 웹소켓 LLM 스트리밍이 프록시 뒤에서 끊길 때 Cloudflare Nginx ALB 버퍼 타임아웃 gzip으로 EventSource failed 100% 재현 해결 체크리스트

Authors

서론

LLM 스트리밍을 붙이면 처음엔 “로컬에서는 잘 되는데, 배포만 하면 가끔(혹은 항상) 끊긴다”가 시작입니다. 프론트에서는 EventSource failed(SSE) 혹은 웹소켓 close code 1006 같은 애매한 증상만 남고, 서버 로그는 조용하거나 “client disconnected” 정도로 끝나죠.

이 글은 FastAPI+Uvicorn에서 SSE/웹소켓 스트리밍이 Cloudflare/Nginx/AWS ALB 같은 프록시 뒤에서 끊기는 문제를 100% 재현하고, 원인을 버퍼링(buffering), 타임아웃(timeout), gzip/압축, keep-alive/HTTP 버전 관점에서 하나씩 제거하는 실전 체크리스트입니다.


문제를 “100% 재현”하는 최소 조건

끊김 문제는 대부분 “스트리밍이 스트리밍답지 않게” 중간 장비에서 모아서(buffer) 한 번에 보내거나, idle로 판단해 timeout으로 끊거나, 압축(gzip) 때문에 flush가 지연되면서 발생합니다.

아래 중 하나라도 걸리면 SSE는 쉽게 죽습니다.

  • 프록시가 응답을 버퍼링해서 클라이언트에 바로 전달하지 않음
  • gzip이 켜져 있어 작은 chunk가 flush되지 않음
  • 60초/100초 같은 idle timeout이 있어 “조용한 스트림”을 끊어버림
  • HTTP/2 변환 구간에서 예상치 못한 버퍼링/flow control

재현용 FastAPI SSE 엔드포인트

아래 코드는 일부러 작은 토큰을 천천히 흘려보내 “중간 프록시가 버퍼링하면 바로 티가 나게” 만든 예제입니다.

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio

app = FastAPI()

@app.get("/sse")
async def sse():
    async def gen():
        # SSE는 최초에 헤더 전송 후, 가능한 빨리 한 번 흘려주는 게 좋습니다.
        yield "event: ready\ndata: ok\n\n"

        # 일부 프록시는 너무 작은 바이트는 버퍼에 쌓아두기도 합니다.
        # 1초마다 토큰을 흘려보내면서 문제를 재현합니다.
        for i in range(1, 300):
            yield f"data: token-{i}\n\n"
            await asyncio.sleep(1)

    headers = {
        "Cache-Control": "no-cache",
        "Connection": "keep-alive",
        # nginx 버퍼링을 끄는 힌트(nginx 설정이 우선이지만 도움이 됩니다)
        "X-Accel-Buffering": "no",
    }

    return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)

프론트 재현 코드

const es = new EventSource("/sse");

es.onmessage = (e) => {
  console.log("msg:", e.data);
};

es.onerror = (e) => {
  console.error("EventSource failed", e);
  es.close();
};

재현 포인트

  • 로컬(직접 Uvicorn)에서는 1초마다 로그가 찍힘
  • 프록시 뒤에서는 한참 조용하다가 한 번에 몰아서 오거나, 특정 시점에 EventSource failed로 끊김

1단계 서버(Uvicorn/FastAPI)에서 먼저 확인할 것

프록시 문제처럼 보여도, 서버가 스트리밍을 “진짜로” flush하고 있는지부터 확정해야 합니다.

Uvicorn 실행 옵션

개발/운영에서 옵션 차이로 문제가 생기기도 합니다.

uvicorn app:app \
  --host 0.0.0.0 --port 8000 \
  --proxy-headers \
  --timeout-keep-alive 75
  • --proxy-headers: ALB/Nginx 뒤에서 클라이언트 정보를 제대로 받기 위함(직접 끊김 원인은 아니지만 로그/디버깅에 중요)
  • --timeout-keep-alive: keep-alive 관련 튜닝 (SSE는 “한 연결을 오래 유지”하므로, 너무 짧으면 불리)

Gunicorn+Uvicorn 조합이라면

Gunicorn을 앞에 두면 워커 타임아웃이 “스트리밍을 작업으로 보고” 끊을 수 있습니다.

gunicorn -k uvicorn.workers.UvicornWorker app:app \
  --bind 0.0.0.0:8000 \
  --timeout 0 \
  --graceful-timeout 30 \
  --keep-alive 75
  • --timeout 0: 스트리밍 요청을 워커 타임아웃으로 죽이지 않도록(환경에 맞게 조정)

운영에서 502/504 및 스트리밍 끊김을 더 넓게 다루는 튜닝은 이 글도 같이 보면 좋습니다: Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝


2단계 SSE가 프록시에서 죽는 “가장 흔한 3대 원인”

원인 A: Nginx(또는 Ingress)가 응답을 버퍼링

SSE는 한 번에 큰 응답을 주는 게 아니라, 작은 chunk를 계속 흘립니다. 그런데 Nginx가 기본값으로 upstream 응답을 버퍼링하면:

  • 클라이언트는 아무것도 못 받음(“멈춘 것처럼 보임”)
  • 어느 순간 버퍼가 차거나 연결 정책에 걸려 끊김
  • 브라우저는 EventSource failed

해결: Nginx에서 버퍼링 끄기

location /sse {
    proxy_pass http://app_upstream;

    proxy_http_version 1.1;
    proxy_set_header Connection "";

    # 핵심
    proxy_buffering off;
    proxy_cache off;

    # 타임아웃도 함께(스트리밍은 길게)
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;

    # SSE는 보통 gzip 끄는 게 안전
    gzip off;

    # 일부 환경에서 chunked가 중요
    chunked_transfer_encoding on;
}

추가로, FastAPI에서 X-Accel-Buffering: no를 보내도 되지만 Nginx 설정이 우선입니다.

원인 B: gzip/압축이 스트리밍 flush를 막음

gzip이 켜져 있으면 작은 조각들이 압축 버퍼에 쌓이고, 일정 크기/조건이 되어야 내려가서 “스트리밍이 아닌 것처럼” 보입니다. 특히 Cloudflare나 Nginx가 자동으로 압축을 걸면 SSE가 망가지는 케이스가 많습니다.

해결: SSE 경로는 gzip/브로틀리 비활성

  • Nginx: gzip off;
  • (가능하면) CDN: 해당 경로는 압축 제외

체감 증상은 보통 이렇습니다.

  • 로컬: 토큰이 1초마다 잘 옴
  • 배포: 10~30초 정도 묶였다가 우르르 오거나, 그 전에 끊김

원인 C: idle timeout(Cloudflare/ALB/Nginx)로 연결이 끊김

SSE는 “계속 이벤트가 흐른다”가 전제인데, 실제 LLM은 생각보다 조용한 구간이 생깁니다.

  • 첫 토큰 생성까지 10~30초
  • RAG 검색/리랭킹 때문에 잠깐 멈춤
  • 모델이 긴 문장을 만들며 flush 빈도가 낮음

이때 중간 장비가 “idle”로 판단하면 연결을 끊고, 브라우저는 EventSource failed를 띄웁니다.

해결 1: 서버에서 heartbeat 주기적으로 보내기

SSE는 코멘트 라인(:)을 heartbeat로 자주 씁니다.

import asyncio
import time

async def sse_gen(llm_stream):
    last = time.monotonic()
    async for token in llm_stream:
        yield f"data: {token}\n\n"
        last = time.monotonic()

        # 너무 촘촘하면 오버헤드, 너무 뜸하면 idle timeout
        # 보통 10~15초에 한 번은 무조건 뭔가를 흘리게 설계

    yield "event: done\ndata: [DONE]\n\n"

async def gen_with_heartbeat(inner_gen, interval=15):
    last_send = time.monotonic()
    async for chunk in inner_gen:
        yield chunk
        last_send = time.monotonic()

        # inner_gen이 토큰을 내는 동안에는 괜찮음

    # inner_gen이 오래 멈추는 구조라면, 별도 태스크로 heartbeat를 섞는 방식이 더 안전합니다.

운영에서는 “토큰이 없을 때도 15초마다 : ping\n\n을 보낸다” 같은 구조가 가장 확실합니다.

해결 2: 프록시 타임아웃을 스트리밍에 맞게 늘리기

  • Nginx: proxy_read_timeout / proxy_send_timeout
  • ALB: Idle timeout (기본 60s가 많음)
  • Cloudflare: 플랜/제품별로 제한이 다르고, 특정 기능(프록시/워커/터널) 조합에서 스트리밍 제약이 생길 수 있음

3단계 Cloudflare 뒤에서 SSE가 끊길 때 체크리스트

Cloudflare는 “원 서버와 브라우저 사이에 한 겹 더” 생기는 구조라서, 아래가 자주 문제를 만듭니다.

체크 1: 응답이 진짜 text/event-stream인가

브라우저 devtools Network에서 응답 헤더를 확인합니다.

  • Content-Type: text/event-stream
  • Cache-Control: no-cache
  • (가능하면) Connection: keep-alive

Cloudflare가 캐시/최적화로 건드리면 SSE가 일반 응답처럼 변형될 때가 있습니다.

체크 2: 압축/자동 최적화 기능이 SSE 경로에 적용되는가

  • 자동 gzip/brotli
  • HTML/JS 최적화 기능(일부는 응답 변형)

가능하면 /sse, /stream 같은 경로는 예외 규칙으로 두고, 압축/캐시/변형을 끕니다.

체크 3: “조용한 구간”에 heartbeat가 있는가

Cloudflare/브라우저/중간망 어디서든 idle로 판단하면 끊길 수 있으니, 서버에서 : ping\n\n를 10~15초마다 보내는 방식이 최후의 보루입니다.


4단계 AWS ALB 뒤에서 웹소켓/SSE가 끊길 때

ALB Idle Timeout

ALB는 idle timeout이 짧으면(기본값을 그대로 두면) 스트리밍이 끊깁니다.

  • 해결: 타겟 그룹/ALB의 Idle timeout을 스트리밍 길이에 맞게 상향
  • 그래도 “토큰이 한동안 안 나오는 구간”이 있으면 끊길 수 있으니 heartbeat 병행

HTTP/2 ↔ HTTP/1.1 변환 구간

클라이언트-ALB는 HTTP/2, ALB-백엔드는 HTTP/1.1일 수 있습니다. 이때 버퍼링/flow control 이슈가 드물게 나타납니다.

  • SSE는 일반적으로 HTTP/1.1에서 예측 가능성이 높습니다.
  • 웹소켓은 업그레이드가 필요하니, 업그레이드 헤더가 프록시에서 잘 전달되는지 확인합니다.

5단계 Nginx에서 웹소켓이 끊길 때 필수 설정

SSE와 달리 웹소켓은 업그레이드가 핵심입니다.

location /ws {
    proxy_pass http://app_upstream;

    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;

    # 웹소켓은 보통 버퍼링이 큰 문제는 아니지만,
    # 환경에 따라 응답 지연이 있으면 off가 도움이 되기도 합니다.
    proxy_buffering off;
}

웹소켓도 결국 “오래 유지되는 연결”이라 timeout이 짧으면 끊깁니다.


트러블슈팅: 증상별로 원인을 빠르게 좁히는 방법

증상 1: 로컬에서는 실시간인데, 배포에서는 10~30초 묶였다가 한꺼번에 온다

  • 1순위: gzip/압축
  • 2순위: Nginx proxy_buffering

진단:

curl -N -v https://your.domain/sse
  • -N은 curl 버퍼링을 끔
  • 여기서도 chunk가 즉시 안 오면 중간에서 버퍼링 중일 확률이 큼

증상 2: 정확히 60초/100초 근처에서 끊긴다

  • 1순위: ALB idle timeout, Nginx proxy_read_timeout, CDN 제한
  • 해결: timeout 상향 + heartbeat

증상 3: 첫 토큰이 늦을 때만 자주 끊긴다

  • 1순위: “첫 바이트까지” 시간이 길어 프록시가 upstream을 실패로 판단
  • 해결: ready 이벤트를 먼저 보내고, 이후 토큰을 흘리기

예:

yield "event: ready\ndata: ok\n\n"
# 그 다음 실제 체인 준비

증상 4: 서버는 계속 생성 중인데 클라이언트는 끊기고, 백엔드에 CancelledError가 잔뜩 남는다

  • 클라이언트 연결이 끊기면 서버 코루틴이 취소되는 것은 정상입니다.
  • 하지만 취소 처리가 엉키면 리소스 누수/경고가 남습니다.

특히 asyncio 경고가 반복된다면 이 글의 패턴이 그대로 적용됩니다: Python asyncio Task was destroyed but it is pending 경고 원인 5가지와 완벽 해결법


Best Practice: LLM 스트리밍을 “프록시 친화적으로” 만드는 설계

1) SSE는 “작게, 자주, 규칙적으로”

  • 토큰이 없을 때도 10~15초마다 heartbeat
  • 이벤트 포맷을 엄격히: data: ...\n\n

2) 스트리밍 경로는 인프라에서 특별 취급

  • /sse, /ws, /stream 같은 경로를 분리
  • 그 경로만:
    • gzip/brotli off
    • proxy_buffering off
    • timeout 크게
    • cache off

3) 장애 시 재시도 전략은 “중복 생성”을 막아야 함

EventSource는 자동 재연결을 합니다. 서버가 idempotent하지 않으면 “같은 질문이 두 번 실행”되며 비용이 폭발할 수 있습니다.

  • Last-Event-ID를 활용하거나
  • 요청에 stream_id를 부여하고 서버에서 중복 실행 방지

LLM 호출이 재시도/큐잉과 결합되면 비용/지연이 더 커지니, 레이트리밋 대응도 같이 설계하는 것이 안전합니다: OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기


실전 체크리스트 한 장 요약

애플리케이션(FastAPI/Uvicorn)

  • Content-Type: text/event-stream 확인
  • Cache-Control: no-cache 설정
  • (가능하면) X-Accel-Buffering: no 헤더 추가
  • 첫 응답을 빨리 보내기(ready 이벤트)
  • 10~15초 heartbeat(: ping\n\n)로 idle timeout 회피
  • Gunicorn이면 워커 timeout이 스트리밍을 죽이지 않게 조정

Nginx/Ingress

  • proxy_buffering off; (가장 중요)
  • gzip off; (SSE 경로에서는 강력 권장)
  • proxy_read_timeout/proxy_send_timeout 충분히 크게
  • 웹소켓은 Upgrade/Connection 헤더 설정

Cloudflare/CDN

  • 스트리밍 경로 캐시/변형/압축 예외 처리
  • 끊김이 “정확한 시간”에 발생하면 플랜/제품 제한 및 idle 정책 확인
  • 그래도 불안하면 heartbeat는 필수

AWS ALB

  • Idle timeout 상향
  • 웹소켓 업그레이드/HTTP 버전 경로 확인

결론

SSE/웹소켓 LLM 스트리밍이 프록시 뒤에서 끊기는 문제는 “코드가 틀려서”라기보다, 중간 장비가 스트리밍을 일반 HTTP 응답처럼 다뤄서 생기는 경우가 대부분입니다. 해결의 핵심은 단순합니다.

  1. 버퍼링 끄기(proxy_buffering off) 2) 압축 끄기(SSE 경로 gzip off) 3) 타임아웃 늘리기 + heartbeat로 조용한 구간 제거

지금 운영 환경에서 /sse 또는 /ws 경로 하나를 잡고, curl -N으로 “1초마다 진짜로 내려오는지”부터 확인하세요. 그 다음 Nginx/ALB/Cloudflare 순서로 버퍼·gzip·timeout을 체크리스트대로 지우면 EventSource failed는 재현만큼이나 깔끔하게 사라집니다.