
챗봇 UI를 만들다 보면 LLM 응답을 한 번에 받는 대신 토큰 단위로 실시간으로 흘려보내야 할 때가 옵니다. 이 글은 FastAPI에서 LLM 응답 스트리밍을 구현하는 두 가지 방법, 즉 SSE(Server-Sent Events)와 WebSocket을 실제 동작하는 코드로 비교하고, 실전에서 스트리밍이 끊기는 원인과 각 방식이 통하지 않는 상황을 구체적으로 짚습니다. FastAPI SSE와 WebSocket 중 어느 쪽이 정말로 끊김 없이 흘러가는지, 조건을 붙여서 답합니다.
FastAPI SSE 구현: StreamingResponse로 토큰을 흘려보내는 방법
FastAPI는 Starlette의 StreamingResponse를 그대로 노출합니다. 비동기 제너레이터가 만들어내는 문자열을 순서대로 클라이언트에 흘려보내는 구조이며, media_type을 text/event-stream으로 지정하면 SSE 규격을 따르게 됩니다. SSE 메시지는 data: <내용>\n\n 형태로 끝에 빈 줄이 하나 더 있어야 브라우저의 EventSource가 이벤트 경계를 인식합니다.
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio, json
app = FastAPI()
async def token_generator(prompt: str):
tokens = ["안녕", "하세요", ",", " 저는", " AI", "입니다", "."]
for token in tokens:
chunk = {"delta": token}
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.05)
yield "data: [DONE]\n\n"
@app.get("/stream")
async def stream(prompt: str):
return StreamingResponse(
token_generator(prompt),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
실무에서는 이 제너레이터 안에서 직접 토큰을 만들지 않고, OpenAI·Anthropic 같은 LLM API가 이미 SSE로 내려주는 응답을 그대로 중계하는 경우가 많습니다. httpx.AsyncClient의 스트리밍 컨텍스트를 쓰면 업스트림 청크를 받는 즉시 클라이언트로 재전송할 수 있습니다.
import httpx
async def proxy_stream(payload: dict, api_key: str):
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
"https://api.openai.com/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json=payload,
) as upstream:
async for line in upstream.aiter_lines():
if line:
yield f"{line}\n\n"
여기서 X-Accel-Buffering: no 헤더는 nginx 같은 리버스 프록시가 응답을 버퍼링하지 않도록 지시하는 관례적인 헤더입니다. 이 헤더가 없으면 프록시가 청크를 모았다가 한꺼번에 내보내면서, 클라이언트 입장에서는 “스트리밍인데 뚝뚝 끊겨서 온다”는 현상이 나타납니다.

FastAPI WebSocket 구현: 양방향 연결로 스트리밍하기
WebSocket은 HTTP 업그레이드 핸드셰이크 이후 별도의 프로토콜(RFC 6455)로 전환되는 IETF의 웹소켓 프로토콜 표준 문서를 따르는 양방향 통신입니다. FastAPI에서는 @app.websocket 데코레이터로 엔드포인트를 정의하고, accept() 이후 send_text()/receive_text()로 메시지를 주고받습니다.
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
import asyncio
app = FastAPI()
@app.websocket("/ws/chat")
async def chat_ws(websocket: WebSocket):
await websocket.accept()
try:
while True:
prompt = await websocket.receive_text()
tokens = ["응답", "을", " 생성", "중", "입니다", "."]
for token in tokens:
await websocket.send_text(token)
await asyncio.sleep(0.05)
await websocket.send_text("[DONE]")
except WebSocketDisconnect:
pass
이 구조의 핵심은 클라이언트가 응답을 받는 도중에도 같은 연결로 메시지를 보낼 수 있다는 점입니다. 예를 들어 사용자가 “생성 중단” 버튼을 누르면, 별도의 HTTP 요청 없이 같은 소켓에 "cancel" 같은 제어 메시지를 보내 서버 쪽 루프를 즉시 멈출 수 있습니다. SSE는 서버→클라이언트 단방향이라 이런 중단 신호를 보내려면 별도의 REST 엔드포인트나 AbortController로 연결 자체를 끊는 방식을 써야 합니다.
스트리밍이 끊기는 진짜 원인: 버퍼링과 미들웨어, 프록시 타임아웃
FastAPI SSE vs WebSocket을 비교할 때 “어느 쪽이 코드상 가능한가”보다 중요한 건 배포 환경에서 실제로 끊김 없이 도달하느냐입니다. 코드가 맞아도 아래 세 지점 중 하나라도 걸리면 두 방식 모두 끊깁니다.
첫째, GZipMiddleware 같이 응답 본문 전체를 감싸서 압축하는 미들웨어를 스트리밍 라우터에 그대로 적용하면, 미들웨어가 청크를 모아뒀다가 한 번에 압축해서 내보내는 방향으로 동작하기 쉬워 스트리밍 효과가 사라집니다. 스트리밍 엔드포인트는 해당 미들웨어 적용 대상에서 빼거나, 경로 기준으로 조건부 적용해야 합니다.
둘째, nginx 같은 리버스 프록시는 기본적으로 업스트림 응답을 버퍼링합니다. proxy_buffering off;를 설정하지 않으면 SSE와 WebSocket 업그레이드 요청 모두 영향을 받으며, WebSocket의 경우 proxy_http_version 1.1;과 Upgrade/Connection 헤더 전달 설정이 빠지면 업그레이드 자체가 실패합니다.

셋째, 프록시·로드밸런서의 유휴 타임아웃입니다. nginx의 proxy_read_timeout 기본값은 60초이고, 이보다 긴 LLM 응답(느린 모델, 긴 답변)이면 중간에 연결이 끊깁니다. SSE는 재요청으로 복구가 가능하지만 응답을 처음부터 다시 받아야 하고, WebSocket은 재연결 로직을 직접 구현해야 이어받기가 가능합니다. 즉 어느 프로토콜을 쓰든 이 타임아웃 값을 스트리밍 응답 시간보다 여유 있게 늘리는 작업이 선행되어야 합니다.
브라우저 EventSource는 왜 프롬프트를 POST로 못 보내나요?
브라우저 내장 EventSource 객체는 위키백과의 서버-전송 이벤트(SSE) 설명에 나오듯 GET 요청만 지원하고 커스텀 헤더나 요청 본문을 붙일 수 없습니다. 그래서 사용자가 입력한 긴 프롬프트나 대화 히스토리를 body에 담아 보내야 하는 LLM 채팅 요청에는 EventSource를 그대로 쓸 수 없습니다.
실무에서는 두 가지로 우회합니다. 하나는 fetch()로 POST 요청을 보내고 응답 본문을 ReadableStream으로 직접 읽으면서 data: 라인을 파싱하는 방식이고, 다른 하나는 fetch-event-source 같은 라이브러리로 POST 기반 SSE 클라이언트를 구현하는 방식입니다. 두 방법 모두 EventSource가 기본 제공하던 자동 재연결(retry) 기능은 없기 때문에, 연결이 끊겼을 때 재시도 로직을 직접 넣어야 합니다. 이 지점은 WebSocket과 사실상 같은 부담이라, “SSE는 브라우저가 알아서 재연결해준다”는 이야기는 GET 기반의 단순한 이벤트 구독에만 해당하고 LLM 채팅처럼 POST 본문이 필요한 경우에는 적용되지 않습니다.
채팅엔 SSE, 중단·양방향 제어가 필요하면 WebSocket을 더하십시오
두 방식을 코드와 배포 조건까지 함께 놓고 보면 아래처럼 정리됩니다.
| 항목 | SSE (StreamingResponse) | WebSocket |
|---|---|---|
| 통신 방향 | 서버 → 클라이언트 단방향 | 양방향 |
| 기반 프로토콜 | HTTP/1.1 그대로 사용 | HTTP Upgrade 후 RFC 6455 프로토콜로 전환 |
| 중간 생성 취소 | 별도 REST 호출 또는 fetch 연결 종료 | 같은 소켓으로 제어 메시지 전송 |
| 브라우저 자동 재연결 | GET 기반 EventSource만 지원, POST는 직접 구현 | 기본 제공 없음, 직접 구현 |
| 프록시 설정 부담 | 버퍼링 해제(proxy_buffering off, X-Accel-Buffering) |
버퍼링 해제 + Upgrade 헤더 전달 설정 |
| 유휴 타임아웃 영향 | 받음 (재요청으로 복구 가능) | 받음 (재연결 로직 필요) |
결론적으로 순수하게 LLM 응답을 화면에 흘려 보여주기만 하는 챗봇이라면, 구현 복잡도가 낮고 일반 HTTP 인프라와 궁합이 좋은 SSE 쪽이 유지보수 부담이 적습니다. 반면 생성 중간에 사용자가 개입해 중단시키거나, 여러 참여자가 실시간으로 메시지를 주고받는 구조(예: 협업 편집, 멀티유저 채팅)라면 WebSocket이 자연스럽습니다. “끊김 없이 흘리는 쪽”은 프로토콜 선택보다 프록시 버퍼링 해제와 타임아웃 설정을 제대로 했는지에 더 크게 좌우된다는 점을 먼저 확인하는 것이 순서입니다. 다음 단계로는 실제 배포 환경의 nginx 설정에서 proxy_buffering과 proxy_read_timeout 값을 확인하고, 로컬에서 재현되지 않는 끊김 현상이라면 프록시 레이어부터 점검해 보시기 바랍니다.
