"SSE 가 모든 AI 채팅 앱이 token stream 에 쓰는 protocol. HTTP/1.1 위 텍스트. EventSource 가 브라우저 JS 5 줄로 처리. 한 가지 — server 가 client 한테 event push — 잘 함."
SSE 가 뭔지
Server-Sent Events 가 one-way HTTP streaming 위한 W3C 명세: server 가 장기 HTTP/1.1 (혹은 2 나 3) response 위로 client 한테 이름 붙은 event 시퀀스 push. 연결이 client 가 시작 (정상 GET) 하고 열림 유지; server 가 event 일어나면 씀.
왜 존재: server 가 push 해야 하는데 client 는 push 안 해도 되는 application class (채팅 token, real-time dashboard, log tailing, 알림) 있음. WebSocket 이 이거 가능한데 protocol-upgrade 복잡도 추가. SSE 가 그냥 특별 content type 과 body format 가진 장기 HTTP response.
Wire format — 추측보다 단순
Content-Type: text/event-stream. Body 가 single newline 으로 분리된 필드 type 셋 가진 UTF-8 텍스트, event 는 blank line 으로 분리:
event: message
data: {"role":"assistant","content":"Hi "}
id: 42
event: message
data: {"role":"assistant","content":"아빠."}
id: 43
event: done
data: {"final_id":"m_abc"}
필드:
event:— event 이름. Optional; 기본 "message".data:— payload (보통 JSON). Multi-line data 는data:줄 여러 개, \n 으로 연결.id:— 재연결 위한 event ID. 브라우저가 마지막 본 ID 기억하고 재연결 시 Last-Event-ID header 로 보냄.retry:— 연결 손실 시 client 가 재시도 전 ms 수.- blank line — 한 event 와 다음 event 분리.
브라우저 쪽 — EventSource 가 5 줄
현대 브라우저가 모든 거 처리하는 native EventSource API ship:
const es = new EventSource('/api/chat/stream');
es.addEventListener('message', (e) => render(JSON.parse(e.data)));
es.addEventListener('done', (e) => es.close());
es.onerror = () => { /* es.close() 안 하면 auto-재연결 */ };
브라우저가 재연결, Last-Event-ID, parsing, event dispatch 처리. 5 줄 쓰고 streaming UI 있음.
Server 쪽 — Async streaming response
Server-쪽 SSE 가 그냥 event 일어나면 쓰는 장기 HTTP response. FastAPI 에서:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json, asyncio
app = FastAPI()
@app.get('/api/chat/stream')
async def chat_stream():
async def event_stream():
for i, token in enumerate(['Hi', ' ', '아빠', '.']):
yield f'event: message\ndata: {json.dumps({"content": token})}\nid: {i}\n\n'
await asyncio.sleep(0.1)
yield 'event: done\ndata: {}\n\n'
return StreamingResponse(event_stream(), media_type='text/event-stream')
Body 가 텍스트 yield 하는 async generator. Starlette/FastAPI 가 각 yield 를 즉시 wire 에 flush. Client 가 각 event 도착 시 봄.
Gotcha
1. Response buffering 비활성. Nginx, Apache, 다른 reverse proxy 가 기본 response buffer — event 가 proxy 에 누적되어 response 가 완전 쓰여질 때까지. X-Accel-Buffering: no (Nginx) 혹은 등가 추가해 proxy 한테 즉시 통과 알려.
2. Chunked encoding 신경. SSE response 가 Transfer-Encoding: chunked 씀 — body 길이 사전 모름. Content-Length 설정 안 함.
3. Heartbeat 가 timeout 예방. Intermediary 가 idle 장기 연결 닫을 수 있음. 15-30s 마다 comment 줄 (: heartbeat\n\n) 보내서 연결 따뜻 유지. 브라우저가 comment 무시.
4. CORS 여전히 적용. Cross-origin SSE 가 server 가 Access-Control-Allow-Origin 보내야. EventSource API 가 다른 fetch 처럼 CORS 존중.
cwkPippa 의 SSE 현실
backend/adapters/claude.py 의 stream method 에 살아, Starlette 의 StreamingResponse 통해 event yield. Frontend 의 useChat hook (frontend/src/hooks/useChat.ts) 가 custom SSE parser 통해 소비 (EventSource 아님 — POST semantics 원함, EventSource 가 POST 미지원 — POST + 수동 EventStream parsing). JSONL ground-truth log 가 wire 로 가기 전 모든 event 캡처, 그래서 연결 끊긴 client 가 재연결 시 log 에서 대화 재 render 가능.