HTTP 응답을 끝내지 않고 계속 흘려보내는 단방향 푸시 방식.
생김새
GET /events HTTP/1.1
Accept: text/event-stream
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
id: 42
event: price
data: {"symbol":"AAPL","price":231.5}
retry: 3000
: 이 줄은 주석이다 — 프록시 버퍼를 밀어내는 하트비트로 쓴다
형식 규칙
- 필드는 field: value 형태, 이벤트는 '빈 줄' 로 끝난다
- data 가 여러 줄이면 줄마다 data: 를 붙인다 (수신 측에서 \n 으로 이어진다)
- id 를 주면 브라우저가 기억한다
- retry 는 재연결 대기 시간(ms)
브라우저가 대신 해 주는 일
const es = new EventSource("/events");
es.addEventListener("price", (e) => render(JSON.parse(e.data)));
연결이 끊기면 브라우저가 알아서 다시 붙는다 이때 마지막으로 받은 id 를 Last-Event-ID 헤더로 보낸다
- 서버는 그 다음 것부터 보내면 된다. 유실 복구가 프로토콜에 들어 있다
WebSocket 은 재연결·재개를 전부 직접 짜야 한다 — 이 차이가 생각보다 크다
지금 SSE가 다시 쓰이는 이유 — LLM 토큰 스트리밍
- 서버가 토큰을 생성하는 대로 밀어 준다. 클라이언트는 받기만 한다
- 정확히 SSE 의 모양이다
주요 LLM API 의 스트리밍 응답이 text/event-stream 으로 나간다 브라우저 챗 UI, 서버 간 중계 모두 같은 형식을 쓴다
"WebSocket 은 과하고 폴링은 느린" 자리를 SSE 가 정확히 메운다
반드시 걸리는 함정 — 프록시 버퍼링
리버스 프록시가 응답을 모았다가 한꺼번에 보내면 스트리밍이 '한참 뒤 한꺼번에' 도착한다 → 스트리밍이 아니게 된다
- nginx — proxy_buffering off;
- 또는 응답에 X-Accel-Buffering: no 헤더
- 압축 — gzip 이 버퍼링을 유발할 수 있다 → 이 엔드포인트만 끄거나 flush 보장
- CDN — 스트리밍 응답은 캐시·버퍼 대상에서 제외한다
"로컬에선 되는데 배포하면 안 된다" 의 1순위 원인이다
HTTP 버전이 성능을 가른다
-
HTTP/1.1 — 브라우저는 오리진당 동시 연결이 6개로 제한된다
- SSE 연결 하나가 그 자리를 계속 차지한다
- 탭을 여러 개 열면 다른 요청이 막힌다
-
HTTP/2+ — 하나의 연결에 스트림을 다중화하므로 이 제한이 사라진다
- SSE 를 쓸 거면 HTTP/2 이상을 전제로 두는 편이 안전하다
무엇을 못 하나
- 단방향이다 — 클라이언트가 보낼 것이 있으면 별도 POST 를 쓴다
- (실무에서는 이 조합으로 충분한 경우가 많다)
- 텍스트만 나른다 — 바이너리는 base64 로 감싸야 해서 33% 커진다
- EventSource 는 커스텀 헤더를 못 붙인다 (Authorization 불가)
- 쿠키를 쓰거나, fetch + ReadableStream 으로 직접 파싱한다
- (요즘은 이 방식이 오히려 흔하다 — 헤더·POST 본문·중단 제어가 자유롭다)
- 쿠키를 쓰거나, fetch + ReadableStream 으로 직접 파싱한다
선택 기준
- 서버 → 클라이언트 단방향 — SSE
- 양방향이 잦다 (채팅·협업 편집) — WebSocket
- 초당 수십 회 이상 · 저지연 — WebSocket
- 갱신이 드물다 (분 단위) — 그냥 폴링
면접 함정
- ❌ "SSE는 구식이다" → 단방향 스트리밍에서는 지금도 최선이고, LLM 스트리밍으로 오히려 쓰임이 늘었다.
- ❌ "응답이 200이면 잘 흐른다" → 프록시 버퍼링에 막히면 200인 채로 아무것도 안 온다.