"SSE는 AI 응답, 알림, 로그처럼 서버에서 클라이언트로 이어지는 이벤트를 전송할 때 자주 쓰여. HTTP 응답 본문에 간단한 텍스트 형식으로 이벤트를 기록하고, 브라우저에서는 EventSource가 해석과 재연결을 맡아. 단방향 스트림이라는 한 가지 역할에 집중한 방식이야."
SSE란 무엇인가
서버 전송 이벤트(SSE)는 WHATWG HTML Living Standard에 정의된 단방향 이벤트 스트리밍 방식이야. 클라이언트가 HTTP 요청을 열면 서버는 HTTP/1.1, HTTP/2, HTTP/3 응답 본문에 이름 있는 이벤트를 차례로 기록해. 브라우저의 EventSource를 쓸 때는 클라이언트가 GET 요청으로 연결을 시작하고, 서버가 응답을 끝내거나 연결이 끊길 때까지 스트림이 이어져.
채팅 응답 토큰, 실시간 대시보드, 로그 따라 읽기, 알림처럼 서버 쪽 갱신이 중심인 기능을 위해 존재해. WebSocket도 같은 데이터를 전달할 수 있지만 양방향 프레임 연결과 별도의 연결 상태를 관리해야 해. SSE는 지정된 미디어 타입과 본문 형식을 사용하는 스트리밍 HTTP 응답이라 기존 HTTP 관측·인증 체계와 결합하기 쉬워.
전송 형식 — 줄과 빈 줄이면 충분해
Content-Type은 text/event-stream이야. 본문은 UTF-8 텍스트이며, 한 이벤트의 필드는 줄바꿈으로 나누고 이벤트 하나가 끝날 때 빈 줄을 넣어:
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:— 이벤트 이름이야. 이 필드를 생략하면 기본 이벤트 이름인 message로 전달돼.data:— 이벤트 데이터야. 흔히 JSON을 담고, 여러 줄을 쓰면 각data:값이 줄바꿈 문자로 이어져 하나의 데이터가 돼.id:— 재연결에 사용할 마지막 이벤트 ID를 갱신해. EventSource는 값을 기억했다가 재연결 요청의 Last-Event-ID 헤더에 실을 수 있어.retry:— 연결이 끊겼을 때 다시 시도하기까지 기다릴 시간을 밀리초 단위로 제안해.- 빈 줄 — 모아 둔 필드로 이벤트 하나를 전달하라는 경계야.
브라우저 — EventSource로 간결하게
브라우저는 SSE 형식을 처리하는 기본 EventSource API를 제공해:
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-재연결 */ };
EventSource가 이벤트 형식 해석, 이름별 분기, 연결 복구, Last-Event-ID 처리를 맡으므로 GET 기반 스트림은 적은 코드로 구현할 수 있어. 스트림이 끝난 뒤 재연결을 원하지 않는다면 애플리케이션이 명시적으로 연결을 닫아야 해.
서버 — 비동기 응답으로 이어 쓰기
서버의 SSE 엔드포인트는 이벤트가 생길 때마다 형식에 맞춘 텍스트를 응답 본문에 이어 쓰는 장기 HTTP 응답이야. 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')
비동기 생성기가 SSE 형식의 텍스트 조각을 내보내면 Starlette/FastAPI가 응답 본문으로 전달해. 다만 생성기의 yield 경계와 실제 네트워크 패킷이나 클라이언트의 읽기 경계가 일치한다고 보장할 수는 없어. 런타임, 압축, 프록시 버퍼링, 클라이언트 해석기가 모두 지연에 영향을 주므로 끝에서 끝까지 확인해야 해.
운영에서 만나는 함정
1. 응답 버퍼링을 꺼야 해. Nginx, Apache 같은 역방향 프록시가 작은 응답 조각을 모아 두면 이벤트가 늦게 한꺼번에 도착해. X-Accel-Buffering: no는 Nginx에 버퍼링을 끄라고 알리는 한 방법이고, 다른 중간 장치는 자체 설정을 확인해야 해.
2. HTTP 버전별 프레이밍을 구분해. 최종 본문 길이를 모르는 HTTP/1.1 응답은 Transfer-Encoding: chunked를 사용할 수 있어. HTTP/2와 HTTP/3는 자체 프레임을 사용하므로 chunked 전송 코딩을 쓰지 않아. 끝을 알 수 없는 스트림에 Content-Length를 임의로 설정해서도 안 돼.
3. 하트비트로 유휴 시간 제한을 피할 수 있어. 중간 장치는 오랫동안 바이트가 흐르지 않는 연결을 닫을 수 있어. 제한보다 짧은 간격으로 주석 줄(: heartbeat\n\n)을 보내면 연결이 살아 있음을 보여 줄 수 있고, EventSource는 그 주석을 이벤트로 전달하지 않아.
4. CORS도 그대로 적용돼. 교차 출처 SSE 요청에는 서버가 올바른 Access-Control-Allow-Origin 응답 헤더를 보내야 해. EventSource도 다른 브라우저 네트워크 API와 마찬가지로 CORS 정책을 따라.
cwkPippa에서 쓰는 SSE
backend/adapters/claude.py의 stream 메서드가 이벤트를 만들고 Starlette의 StreamingResponse가 응답 본문을 전달해. 프런트엔드의 useChat 훅(frontend/src/hooks/useChat.ts)은 POST 본문을 보내야 하므로 기본 EventSource 대신 fetch 응답 스트림과 사용자 정의 SSE 해석기를 사용해. 전송 전에 JSONL 원본 로그를 기록해 두기 때문에 클라이언트 연결이 중간에 끊겨도 저장된 대화 상태를 바탕으로 화면을 복구할 수 있어.