서버가 클라이언트에 "뭐 일어났어" 를 알려주고 싶다. WebSocket 까지 가긴 무겁고, polling 은 낭비. Server-Sent Events (SSE)는 평범한 HTTP 응답 위에 텍스트 stream 을 흘려서 단방향 push 를 구현한다. text/event-stream media type · EventSource API · 자동 재연결. 이 가이드는 SSE 의 protocol · 한계 · WebSocket 과의 분기점을 정리한다.
SSE 가 푸는 문제
서버 → 브라우저 단방향 push 가 필요하다:
- 알림, 진행률, 라이브 피드, AI 토큰 스트리밍, 대시보드 업데이트
선택지:
1. polling — 1초마다 GET → 지연 + 낭비
2. long-polling — GET 띄워두고 이벤트 시 응답 → 매 이벤트마다 재연결
3. WebSocket — 양방향 socket, 무겁고 proxy/방화벽 자주 막힘
4. SSE — HTTP 위 단방향 stream + 자동 재연결 + Last-Event-ID 재개
대부분의 "서버 → 클라이언트" 알림은 단방향이라 SSE 가 충분.Protocol — text/event-stream
평범한 HTTP 응답인데 Content-Type: text/event-stream 으로 브라우저에 "이건 stream 이다" 신호. 응답 body 는 닫히지 않고 계속 흐르며, 줄 단위로 텍스트 frame 을 보낸다.
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: hello
data: {"user": "jade", "msg": "hi"}
event: ping
data: 1
id: 42
data: 진척 50%
retry: 3000
data: 3초 후 재연결 권장
(연결 유지, 계속 흘림...)각 메시지는 빈 줄 (\n\n) 로 구분. 한 메시지 안에서는 콜론으로 시작하는 4 가지 field 만 의미:
data:— 본문. 여러 줄이면 줄별로data:반복하면 개행 포함 한 문자열로 합쳐짐.event:— 이벤트 이름. 안 박으면 defaultmessage. 클라이언트는 이 이름으로 dispatch.id:— 이벤트 ID. 클라이언트가 마지막 본 ID 를 기억해 재연결 시Last-Event-ID헤더로 보냄.retry:— 재연결 대기 시간 (ms). EventSource 의 기본 재시도 간격 override.
EventSource — 클라이언트 API
const es = new EventSource("/api/events");
es.onmessage = (e) => {
// event: 가 없는 메시지
console.log(e.data);
};
es.addEventListener("ping", (e) => {
// event: ping 인 메시지만
console.log("ping", e.data);
});
es.onerror = (err) => {
// 자동 재연결 중 — 명시적으로 닫지 않으면 EventSource 가 알아서 재시도
};
// 명시 종료
es.close();핵심: 재연결은 브라우저가 자동 처리. 연결이 끊기면 EventSource 가 retry 간격 (default 약 3 초) 뒤 다시 GET 을 던지고, 마지막으로 본 id: 를 Last-Event-ID 헤더로 함께 보낸다. 서버는 이 ID 이후의 이벤트만 다시 흘리면 됨 — 자연스러운 재개 (resume) 가 protocol 에 박혀 있다.
Last-Event-ID — 재개의 핵심
서버 측 흐름:
GET /api/events
← (Last-Event-ID 없음) → ID 1 부터 흘림
(연결 끊김, 클라이언트 마지막 본 id = 42)
GET /api/events
Last-Event-ID: 42
← ID 43 부터 흘림 (43, 44, ...)
→ 클라이언트 코드 한 줄 안 짜고 "끊긴 동안 일어난 일" 받을 수 있음.
주의:
- 서버가 ID 별 이벤트를 어딘가에 갖고 있어야 함 (Redis Streams,
Kafka, ring buffer 등)
- 무한 보관은 비현실 — TTL 또는 "최근 N 분만" 정책 필요
- ID 가 monotonic 일 필요는 없지만 unique + 비교 가능해야 보관·조회 쉬움SSE vs WebSocket — 어디서 갈리나
| 축 | SSE | WebSocket |
|---|---|---|
| 방향 | 서버 → 클라이언트 단방향 | 양방향 |
| 전송층 | 평범한 HTTP/1.1, HTTP/2 | 전용 ws:// 프로토콜 (HTTP Upgrade) |
| 데이터 | UTF-8 텍스트만 | 텍스트 + 바이너리 |
| 재연결 | 브라우저 자동 + Last-Event-ID 재개 | 직접 구현 |
| 인증·CORS | HTTP 그대로 — 쿠키 / Authorization 헤더 / CORS 동일 | 다른 모델 (handshake 시 쿠키만) |
| 프록시·방화벽 | HTTP 라 거의 통과 | 일부 corp 프록시·게이트웨이가 막음 |
| 적합 시나리오 | 알림, 진행률, AI 토큰 stream, live feed | 채팅, 게임, 협업 편집 |
실시간 패턴 4 종 (polling / long-polling / SSE / WebSocket) 의 장단점 비교는 webhooks-vs-polling 가이드 참조.
HTTP/1.1 의 6 연결 제한 — 실제 SSE 의 가장 큰 함정
브라우저는 origin 당 동시 연결 수 제한:
- HTTP/1.1: 약 6 (Chrome / Firefox / Safari 비슷)
- SSE 가 연결 1 개를 항상 점유
증상: 같은 origin 의 다른 탭 6 개 열면 7 번째 탭의 fetch 가 멈춤
→ 사용자가 "SSE 켜진 후 사이트가 느려졌어요"
해결:
1. HTTP/2 또는 HTTP/3 로 서빙 — 한 connection 위 multiplex,
제한 풀림 (Cloudflare / nginx 1.13.10+ / Caddy 기본)
2. 서브도메인 분리 — events.example.com 에 별도 origin
3. shared worker — 한 탭이 받아서 BroadcastChannel 로 공유
→ production SSE 는 HTTP/2 가 사실상 prerequisite.Proxy buffering — 두 번째로 흔한 함정
nginx, AWS ALB, Cloudflare, corporate proxy:
기본은 응답을 일정량 모아서 한꺼번에 전달 (효율 ↑)
→ SSE 의 "한 줄씩 즉시" 흐름이 N 초 모인 후 한꺼번에 도착
→ 클라이언트 입장에선 "live 가 아님"
해결 (nginx 예시):
proxy_buffering off;
proxy_cache off;
proxy_set_header X-Accel-Buffering no;
# 또는 응답 헤더로:
X-Accel-Buffering: no
Cloudflare:
- 일반 plan 은 response buffering 강제
- Enterprise 의 "Disable Buffering" 옵션 또는 WebSocket / HTTP/2 push
- 우회: 응답 헤더 Cache-Control: no-transform + 본문에 padding bytes
(실전: 2KB 정도 padding 첫 chunk 로 flush 강제)
증상 디버깅:
curl -N https://api.example.com/events # -N 은 buffering 끄기
→ curl 에서도 지연되면 서버 / proxy 문제
→ 브라우저만 지연되면 브라우저 buffer (HTTPS 미사용 시 흔함)Heartbeat / keep-alive — 죽은 연결 감지
SSE 연결은 TCP 위. 중간 NAT / load balancer 가 idle timeout 으로
조용히 끊을 수 있음 (보통 60-300 초).
→ 서버 입장에선 "보냈는데 도착했나" 모름
→ 클라이언트는 onerror 안 뜨고 그냥 데이터 안 옴
해결: 주기적 heartbeat 줄 박기
매 15-30 초마다:
: keepalive\n\n // 콜론 시작 = comment, 무시되지만 packet 전송
또는:
event: ping\n
data: 1\n\n
→ TCP 가 살아있음 확인 + idle timeout reset.SSE 가 안 맞는 곳
- 양방향 통신 — 채팅, 게임, 협업 편집. SSE 는 단방향이라 클라이언트 → 서버는 별도 fetch (overhead).
- 바이너리 데이터 — SSE 는 UTF-8 텍스트만. 이미지 stream 등은 base64 인코딩 필요 (33% overhead) → 그냥 WebSocket.
- IE / 일부 임베디드 브라우저 — EventSource 미지원. 현 시점에선 거의 무시 가능하지만 enterprise 환경 확인.
- 매우 짧은 burst — 1 초 이내로 끝나는 알림이라면 그냥 polling 한 번이 더 단순.
실전 use case
- AI 토큰 스트리밍 — OpenAI / Anthropic API 의 streaming 응답이 SSE 포맷. 클라이언트가 토큰 단위로 받아 화면 업데이트.
- 배포·CI 진행률 — Vercel / Netlify 가 빌드 로그 live 스트리밍에 SSE 사용.
- 주식 / 가상화폐 시세 — 단방향 push 의 전형. 다만 초당 수천 건 이상은 WebSocket 의 binary frame 이 효율적.
- 대시보드 metric — Grafana Live 가 SSE 옵션 제공.
흔한 함정
- HTTPS 가 사실상 필수 — HTTP 평문에선 브라우저가 chunk 를 더 늦게 flush 하는 케이스가 있음 (HTTP/2 는 HTTPS 위에서만).
- EventSource 의 withCredentials — 쿠키 / Authorization 헤더 보내려면
new EventSource(url, { withCredentials: true })+ 서버는Access-Control-Allow-Credentials: true+ 정확한 origin (CORS 와 동일 — 상세는cors-explained). - Authorization 헤더 못 박음 — EventSource API 의 한계. URL 쿼리로 token 넣거나 (로그 노출 위험), 쿠키 사용하거나, fetch + ReadableStream 으로 직접 구현 (그러면 자동 재연결 손해).
- data: 안 박으면 무시 —
event:또는id:만 있고data:없으면 EventSource 가 dispatch 하지 않음 (spec). - JSON 줄바꿈 주의 — JSON.stringify 결과는 한 줄 이지만 들여쓰기 출력 등 다중 줄이면 줄마다
data:반복 필요. 안 그러면 깨짐. - 서버 측 연결 한계 — node.js / Go 등은 thousand concurrent connection OK 지만, 전통 thread-per-request 서버 (Apache prefork 등) 는 빠르게 고갈.
마무리
SSE 는 "WebSocket 이 과한 단방향 시나리오" 의 정확한 답. 평범한 HTTP 위에서 동작하고, 재연결과 Last-Event-ID 재개가 protocol 에 박혀 있어 코드가 짧다.
production 도입 전 체크리스트: HTTP/2 또는 별도 origin, proxy buffering off, heartbeat, 이벤트 ID 보관. 이 4 가지를 안 챙기면 "왜 live 가 아니지" 를 디버깅하느라 시간을 다 쓴다.