본문으로 건너뛰기
yutils

Server-Sent Events 는 어떻게 동작할까?

text/event-stream 프로토콜, EventSource API, Last-Event-ID 기반 자동 재연결, WebSocket 대비 SSE 가 이기는 지점, 그리고 실전 한계 (단방향, 브라우저 연결 수 제한, proxy buffering).

약 9분 읽기

서버가 클라이언트에 "뭐 일어났어" 를 알려주고 싶다. 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 — 어디서 갈리나

SSEWebSocket
방향서버 → 클라이언트 단방향양방향
전송층평범한 HTTP/1.1, HTTP/2전용 ws:// 프로토콜 (HTTP Upgrade)
데이터UTF-8 텍스트만텍스트 + 바이너리
재연결브라우저 자동 + Last-Event-ID 재개직접 구현
인증·CORSHTTP 그대로 — 쿠키 / 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 가 아니지" 를 디버깅하느라 시간을 다 쓴다.

가이드 목록으로