Server-Sent Events: 단방향 스트리밍
ChatGPT가 단어를 하나씩 타이핑하듯 보여주는 경험을 본 적 있을 것이다. 그 뒤에는 WebSocket이 아니라 Server-Sent Events(SSE) 가 동작하고 있는 경우가 많다. SSE는 서버에서 클라이언트로만 데이터를 흘려보내는 단방향 스트림이다.
text/event-stream 포맷
섹션 제목: “text/event-stream 포맷”SSE는 Content-Type: text/event-stream으로 응답하는 일반 HTTP 연결이다. 서버는 연결을 끊지 않고 데이터를 계속 쓴다.
HTTP/1.1 200 OKContent-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive
data: 안녕하세요\n\ndata: {"token": "안녕", "index": 0}\n\nid: 42\ndata: 세 번째 이벤트\n\nevent: done\ndata: [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를 사용하는 이유가 여기에 있다.
FastAPI StreamingResponse 구현
섹션 제목: “FastAPI StreamingResponse 구현”import asyncioimport jsonfrom fastapi import FastAPIfrom 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가 응답을 모아서 한꺼번에 전송해 스트리밍 효과가 사라진다.
브라우저 EventSource 클라이언트
섹션 제목: “브라우저 EventSource 클라이언트”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 챕터에서 자세히 다룬다).
nginx 설정
섹션 제목: “nginx 설정”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에서
StreamingResponse와async generator를 조합해 구현한다 - nginx에서는
proxy_buffering off와X-Accel-Buffering: no헤더가 필수다