콘텐츠로 이동

Server-Sent Events: 단방향 스트리밍

ChatGPT가 단어를 하나씩 타이핑하듯 보여주는 경험을 본 적 있을 것이다. 그 뒤에는 WebSocket이 아니라 Server-Sent Events(SSE) 가 동작하고 있는 경우가 많다. SSE는 서버에서 클라이언트로만 데이터를 흘려보내는 단방향 스트림이다.

SSE는 Content-Type: text/event-stream으로 응답하는 일반 HTTP 연결이다. 서버는 연결을 끊지 않고 데이터를 계속 쓴다.

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: 안녕하세요\n
\n
data: {"token": "안녕", "index": 0}\n
\n
id: 42\n
data: 세 번째 이벤트\n
\n
event: done\n
data: [DONE]\n
\n

각 필드의 역할:

필드필수설명
data:이벤트 데이터. 여러 줄이면 data: 를 반복
id:아니오이벤트 ID. 재연결 시 Last-Event-ID 헤더로 전송됨
event:아니오커스텀 이벤트 타입. 기본값은 message
retry:아니오재연결 대기 시간(ms). 기본값은 브라우저마다 다름

빈 줄(\n\n)이 이벤트 경계다. 한 이벤트 안에 data: 줄이 여러 개면 줄바꿈으로 합쳐진다.

SSE의 강점 중 하나는 브라우저가 재연결을 자동으로 처리한다는 점이다.

연결 끊김 감지
retry 값(기본 ~3초) 대기
새 연결 시도 + Last-Event-ID 헤더 포함
서버가 해당 ID 이후 이벤트부터 재전송

WebSocket은 재연결 로직을 애플리케이션 코드로 직접 구현해야 한다. SSE는 브라우저 EventSource API가 이를 무료로 제공한다.

LLM 스트리밍에 SSE가 적합한 이유

섹션 제목: “LLM 스트리밍에 SSE가 적합한 이유”
요구사항 WebSocket SSE
-------------------------------------------------
서버→클라이언트 토큰 가능 가능
클라이언트→서버 메시지 가능 불가 (HTTP POST 별도)
HTTP 인프라 그대로 불가 가능
nginx/CDN 프록시 호환 설정 필요 기본 동작
자동 재연결 직접 구현 브라우저 내장
프로토콜 업그레이드 필요 불필요
구현 복잡도 높음 낮음

LLM 채팅에서 사용자 입력은 HTTP POST 한 번으로 충분하고, 응답 토큰은 서버→클라이언트 단방향이다. 양방향이 필요 없으므로 SSE가 더 단순하고 인프라 친화적이다. OpenAI API와 Anthropic API 모두 스트리밍에 SSE를 사용하는 이유가 여기에 있다.

import asyncio
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
async def token_generator(prompt: str):
"""HuggingFace 모델에서 토큰을 하나씩 yield하는 제너레이터."""
# 실제 서비스에서는 transformers TextIteratorStreamer 사용
tokens = ["안녕", "하세요", "! ", "오늘", "", " 도와", "드릴", "까요", "?"]
for i, token in enumerate(tokens):
payload = json.dumps({"token": token, "index": i}, ensure_ascii=False)
yield f"data: {payload}\n\n"
await asyncio.sleep(0.05) # 실제 추론 지연 시뮬레이션
yield "data: [DONE]\n\n"
@app.post("/stream")
async def stream_response(body: dict):
prompt = body.get("prompt", "")
return StreamingResponse(
token_generator(prompt),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # nginx 버퍼링 비활성화
},
)

X-Accel-Buffering: no 헤더는 nginx에게 이 응답을 버퍼링하지 말라고 지시한다. 이 헤더가 없으면 nginx가 응답을 모아서 한꺼번에 전송해 스트리밍 효과가 사라진다.

const source = new EventSource("/stream-get"); // EventSource는 GET만 지원
source.addEventListener("message", (event) => {
if (event.data === "[DONE]") {
source.close();
return;
}
const { token } = JSON.parse(event.data);
appendToken(token); // UI에 토큰 추가
});
source.addEventListener("error", () => {
// 네트워크 오류 시 브라우저가 자동 재연결 시도
console.log("연결 오류, 재연결 중...");
});

EventSource는 GET 요청만 지원한다. POST 바디로 프롬프트를 보내야 하는 경우에는 fetch API로 SSE 스트림을 직접 읽는 방식을 사용한다 (05 챕터에서 자세히 다룬다).

location /stream {
proxy_pass http://backend:8000;
proxy_buffering off; # 서버→nginx 버퍼링 비활성화
proxy_cache off;
proxy_set_header Connection "";
proxy_http_version 1.1;
proxy_read_timeout 300s; # 긴 스트림을 위한 타임아웃 연장
chunked_transfer_encoding on;
}

proxy_buffering off를 설정하지 않으면 nginx가 백엔드 응답을 버퍼에 모았다가 한꺼번에 보내서 실시간 스트리밍이 깨진다.

  • SSE는 text/event-stream 포맷의 HTTP 응답으로, data: / id: / event: / retry: 필드를 빈 줄로 구분한다
  • 브라우저 EventSource가 자동 재연결을 처리하므로 WebSocket 대비 구현이 단순하다
  • LLM 토큰 스트리밍은 단방향이므로 SSE가 WebSocket보다 적합하다
  • FastAPI에서 StreamingResponseasync generator를 조합해 구현한다
  • nginx에서는 proxy_buffering offX-Accel-Buffering: no 헤더가 필수다