실시간 1편 — SSE와 WebSocket은 무엇이 다른가
실시간 채널이 필요해졌을 때 SSE와 WebSocket 중 무엇을 골라야 할까. 프로토콜 차이, 인증·재연결·유실 복구 관점의 비교, 그리고 선택 기준 세 가지를 정리했다.
사내 협업 피드백 툴을 만들면서 실시간 채널을 붙일 일이 생겼다. 누군가 노트를 남기면 다른 사람 화면에 바로 떠야 했다. 처음엔 SSE(Server-Sent Events)로 시작했고, 몇 달 뒤 WebSocket으로 갈아탔다.
그 과정에서 알게 된 건, 둘의 차이가 "단방향이냐 양방향이냐" 한 줄로 요약되지 않는다는 거였어요. 이 글은 그 판단에 필요한 개념 토대를 깐다. 구현 코드는 2편과 3편에서 다룬다.
출발점: 폴링
회사 코드를 노출할 수 없으니, 이 글에서는 예시를 모두 이해하기 쉬운 도메인으로 변경했습니다.
가장 단순한 실시간은 폴링(polling)이다. 클라이언트가 3초마다 GET /notes를 날린다. 구현은 쉽지만 변경이 없어도 요청이 계속 나가고, 최악의 경우 변경 발생부터 화면 반영까지 폴링 주기만큼 지연된다.
롱폴링(long polling)은 서버가 응답을 들고 있다가 이벤트가 생기면 내려주는 방식이다. 지연은 줄지만 이벤트마다 연결을 새로 맺는 비용이 그대로 남는다. 요청 헤더, TCP 왕복, 서버의 대기 커넥션 관리까지.
그래서 결국 "연결을 하나 열어두고 계속 흘려보내는" 방식으로 간다. 그 자리에 후보가 둘 있다. SSE와 WebSocket.
SSE — HTTP 위의 단방향 스트림
SSE는 새 프로토콜이 아니다. 그냥 HTTP 응답을 닫지 않는 것이다.
서버가 Content-Type: text/event-stream으로 응답을 열고, 연결을 유지한 채 이벤트를 계속 흘린다. 방향은 서버 → 클라이언트 단방향이다.
wire 포맷은 이게 전부예요. 평문 텍스트다.
event: note.generaldata: {"ts":1730000000}
: this is a comment (heartbeat)
retry: 3000event:가 이벤트 이름, data:가 페이로드, 빈 줄이 이벤트 구분자다. :로 시작하는 줄은 주석인데, 파서가 무시하므로 연결 유지용 heartbeat로 쓴다. retry:는 재연결 대기 시간을 서버가 클라이언트에게 지시하는 필드다.
브라우저 API는 EventSource다. 여기에 SSE의 가장 큰 무기가 들어 있다. 재연결이 브라우저에 내장되어 있다. 연결이 끊기면 retry: 값만큼 기다렸다가 알아서 다시 붙는다.
이때 마지막으로 받은 이벤트의 id: 값을 Last-Event-ID 헤더에 실어 보내서, 서버가 끊긴 지점부터 재개할 수 있게 한다. 유실 복구가 프로토콜 차원에 있는 셈이다.
대신 제약이 뚜렷하다.
텍스트만 보낸다. 바이너리는 못 싣는다.
EventSource는 커스텀 헤더를 못 붙인다. Authorization: Bearer ...가 불가능하다. 인증은 쿠키에 의존해야 한다. 사소해 보이지만 실전에서는 이게 아키텍처를 흔든다 — 저희 프로젝트에서도 실제로 문제가 됐고, 2편과 3편에서 자세히 다룬다.
HTTP/1.1에서는 브라우저가 오리진당 동시 연결을 6개로 제한한다. SSE 연결 하나가 그 슬롯을 영구 점유한다. 같은 사이트 탭을 여러 개 띄우면 슬롯이 바로 고갈되고, 그 뒤의 일반 요청까지 줄을 선다. HTTP/2 이상에서는 하나의 연결에 스트림을 멀티플렉싱하므로 이 제약이 사라진다. SSE를 쓰려면 HTTP/2가 사실상 전제 조건이다.
프록시와 로드밸런서의 idle timeout에 끊긴다. 60초 동안 바이트가 안 흐르면 중간 장비가 연결을 죽이는 경우가 흔하다. 그래서 위 포맷의 주석 줄을 주기적으로 흘리는 heartbeat가 필수다.
WebSocket — 별도 프로토콜
WebSocket은 접근이 다르다. HTTP Upgrade: websocket 핸드셰이크를 딱 한 번 거치고, 그 뒤로는 같은 TCP 연결 위에서 자체 프레임을 주고받는 별도 프로토콜로 전환된다. RFC 6455. URL 스킴도 ws:///wss://로 다르다.
양방향이다. 클라이언트도 서버로 메시지를 보낸다. 이게 SSE와의 첫 번째 근본 차이다.
인증 얘기를 정확히 해둘 필요가 있다. 흔한 오해가 "WebSocket은 헤더를 붙일 수 있다"인데, 아니다. 브라우저의 WebSocket 생성자도 커스텀 헤더를 못 붙인다. 핸드셰이크는 HTTP 요청이므로 쿠키는 자동으로 붙는다 — 이 점은 SSE와 같다.
결정적 차이는 그 다음이다. WebSocket은 양방향이라 연결이 열린 직후 첫 메시지로 토큰을 보낼 수 있다. SSE에서는 클라이언트가 연결 후에 뭔가를 보낼 방법 자체가 없다.
대신 SSE가 공짜로 주던 것들이 사라진다. 재연결과 유실 복구가 프로토콜 레벨에 없다. ping/pong 프레임이 스펙에 있긴 하지만 브라우저 API로는 노출되지 않는다. 끊김 감지, 백오프 재연결, 놓친 메시지 복구를 전부 직접 만들거나, socket.io 같은 라이브러리에 기대야 한다.
그 밖의 특성은 이렇다. 텍스트와 바이너리를 둘 다 보낸다. 프레임 오버헤드가 작다(작은 메시지 기준 헤더 2~14바이트). 인프라 쪽에서는 프록시·로드밸런서가 Upgrade 요청을 통과시키도록 설정해야 한다.
정면 비교
| 축 | SSE | WebSocket |
|---|---|---|
| 방향성 | 서버 → 클라 단방향 | 양방향 |
| 프로토콜 | HTTP 그 자체 | HTTP 핸드셰이크 후 별도 프로토콜 (RFC 6455) |
| 브라우저 API | EventSource | WebSocket |
| 재연결 | 내장 (retry: 지시) | 없음 — 직접 구현 |
| 유실 복구 | Last-Event-ID로 재개 지점 전달 | 없음 — 직접 구현 |
| 인증 방식 | 쿠키 (커스텀 헤더 불가) | 쿠키 + 연결 후 첫 메시지로 토큰 가능 |
| 페이로드 | 텍스트만 | 텍스트 + 바이너리 |
| 연결 수 제 약 | HTTP/1.1에서 오리진당 6개 슬롯 점유 (HTTP/2로 해소) | 별도 연결이라 6개 제한과 무관 |
| 서버 구현 부담 | 낮음 — 응답을 안 닫는 핸들러 | 높음 — 연결 상태·룸·재연결 관리 |
| 인프라 요구사항 | idle timeout 대비 heartbeat, HTTP/2 권장 | 프록시의 Upgrade 통과 설정 |
그래서 언제 무엇을 쓰나
"단방향이면 SSE, 양방향이면 WebSocket"으로 환원하고 싶은 유혹이 있는데, 실제로 갈리는 축은 세 개다.
1. 클라이언트가 서버에 무언가를 말해야 하는가.
구독 대상을 바꾸거나, 룸에 입장하거나, 수신 확인(ack)을 보내야 하는가. 하나라도 해당하면 WebSocket이다. SSE로 이걸 흉내 내려면 별도 HTTP 요청을 섞어야 하는데, 그 순간 "연결 하나로 실시간"이라는 단순함이 깨진다. 채팅, 협업 편집, 커서 공유가 여기에 속한다.
2. 끊긴 사이의 유실을 어떻게 메우는가.
지하철에서 10초 끊겼다 붙었을 때, 그 사이 이벤트를 놓쳐도 되는가. 놓치면 안 된다면 — SSE는 Last-Event-ID라는 프로토콜 차원의 답이 있다. WebSocket은 이벤트 ID 발급, 서버 측 버퍼링, 재연결 시 재전송을 전부 직접 만 들어야 한다. 역설적이지만 이 축에서는 SSE가 유리하다. "WebSocket이 상위호환"이라는 통념과 반대다.
3. 이벤트가 "신호"인가 "데이터"인가.
"뭔가 바뀌었어"라는 신호만 보내고 클라이언트가 REST로 다시 조회하는 구조라면 SSE로 충분하다. 알림 배지, 진행률 스트림, 대시보드 지표 푸시가 그렇다. LLM 토큰 스트리밍도 전형적인 SSE 케이스다 — 서버가 일방적으로 흘리기만 하면 된다.
반면 변경된 데이터 자체를 실어 보내 클라이언트 캐시를 직접 갱신할 거라면 양쪽 다 가능하다. 다만 "어떤 데이터를 구독할지"를 클라이언트가 서버와 협상해야 하는 순간 1번 축에 걸리면서 WebSocket 쪽으로 기운다.
흔한 오해 세 가지
"SSE는 느리다" — 아니다. 둘 다 하나의 TCP 연결을 계속 유지하고, 이벤트는 그 위로 즉시 흐른다. 지연 차이의 본질은 프로토콜이 아니라 프레임 오버헤드와 구현 품질이다.
"WebSocket이 SSE의 상위호환이다" — 아니다. 재연결과 유실 복구를 공짜로 주는 쪽은 SSE다. WebSocket을 고르는 순간 그 두 가지를 직접 책임져야 한다.
"SSE는 인증을 못 한다" — 쿠키로 한다. 못 하는 건 커스텀 헤더다. 그리고 앞서 봤듯 브라우저 WebSocket도 커스텀 헤더는 못 붙인다.
마무리
세 줄로 요약한다.
- SSE는 HTTP 응답을 안 닫는 것, WebSocket은 HTTP에서 출발해 별도 프로토콜로 전환하는 것이다.
- 재연결·유실 복구는 SSE가 내장, WebSocket은 직접 구현이다. "상위호환" 관계가 아니다.
- 선택 기준은 방향성 하나가 아니라 — 클라가 말해야 하는가 / 유실을 어떻게 메우는가 / 신호인가 데이터인가 — 세 축이다.
저희는 SSE로 시작해서 WebSocket으로 갈아탔어요. 어느 쪽이 우월해서가 아니라, 요구사항이 1번 축을 넘어갔기 때문이다. 처음부터 그걸 알았느냐 하면, 솔직히 아니었다. 2편에서 SSE로 실시간을 어떻게 구현했는지, 3편에서 왜 걷어냈는지 그대로 씁니다.