원문: SSE vs WebSockets (and polling): picking a real-time transport on the front end
서버 전송 이벤트(Server-Sent Events), WebSocket, 일반 폴링이 클라이언트 관점에서 어떻게 다른지, 각각 어떤 인증과 재연결 문제를 안고 있는지, 프로젝트별로 무엇을 선택해야 하는지 살펴봅니다.
사용자가 새로고침 버튼을 누르지 않아도 UI가 업데이트되어야 합니다. 선택지는 “타이머에 맞춰 다시 요청하기”부터 “세션이 유지되는 동안 소켓을 열어두기”까지 다양합니다. 지나치게 무거운 방식을 선택하면 필요하지도 않은 소켓 인프라를 운영하게 됩니다. 반대로 너무 가벼운 방식을 선택하면 UI 반응이 늦어지거나 API에 과도한 요청을 보내게 됩니다. 이 글에서는 클라이언트 관점에서 세 가지 선택지와 각각 어떤 상황에 적합한지 살펴봅니다.
선택지#
- 폴링(Polling). 일정한 간격으로 데이터를 가져옵니다. 새로운 인프라가 전혀 필요하지 않고 어디에서나 동작합니다.
- 롱 폴링(Long polling). 서버는 데이터가 생기거나 타임아웃될 때까지 요청을 열린 상태로 유지합니다. SSE가 등장한 뒤로는 대부분 레거시 방식이 되었지만, 일부 폴백에서 여전히 사용합니다.
- 서버 전송 이벤트(Server-Sent Events, SSE). 오랫동안 유지되는 하나의 HTTP 응답에 서버가 계속 데이터를 써 내려갑니다. 서버에서 클라이언트로 향하는 단방향 방식입니다. 브라우저의
EventSourceAPI를 사용하며 텍스트만 전송할 수 있습니다. 자동 재연결과 이어받기 기능이 내장되어 있습니다. - WebSocket. HTTP 업그레이드 후 별도 프로토콜에서 동작하는 전이중(full-duplex) 연결입니다. 텍스트와 바이너리를 전송할 수 있습니다. 메시지 프로토콜, 재연결, 인증 흐름은 직접 구현해야 합니다.
관리형 실시간 서비스(Pusher, Ably, Supabase Realtime, Firebase)와 GraphQL 구독은 거의 항상 내부에서 WebSocket을 사용합니다. HTTP/3 기반 WebTransport도 존재하고 알아둘 가치는 있지만, 아직 이를 필요로 하는 프런트엔드는 많지 않습니다.
SSE와 WebSocket 비교#
| SSE | WebSocket | |
|---|---|---|
| 방향 | 서버에서 클라이언트로만 | 양방향 |
| 프로토콜 | 일반 HTTP(1.1 또는 2) | 별도 프로토콜인 ws/wss로 업그레이드 |
| 브라우저 API | 작지만 많은 기능을 알아서 처리하는 EventSource |
작고, 그 위의 모든 기능을 직접 구현하는 WebSocket |
| 재연결 | 자동 | 직접 구현 |
| 연결이 끊긴 뒤 이어받기 | Last-Event-ID 헤더를 사용하며 서버가 놓친 이벤트를 재전송할 수 있음 |
직접 설계 |
| 페이로드 | data: / event: / id: 형식으로 구성된 UTF-8 텍스트 |
텍스트 또는 바이너리 프레임 |
| 브라우저의 사용자 정의 인증 헤더 | 네이티브 EventSource에서는 사용할 수 없음 |
핸드셰이크에서도 사용할 수 없음 |
| 인프라 | 일반 HTTP지만 버퍼링 프록시가 스트리밍을 방해할 수 있음 | WebSocket을 인식하는 로드 밸런서가 필요하고 때로는 고정 세션도 필요함 |
| HTTP/1.1 연결 제한 | 브라우저에서 도메인마다 약 6개. HTTP/2 멀티플렉싱을 사용하면 이 제한이 사라짐 | 연결 하나를 사용하며 6개 제한을 받지 않음 |
| 확장 방식 | 클라이언트마다 HTTP 연결 하나를 유지 | 클라이언트마다 소켓 하나를 유지하며, 보통 게시/구독(pub/sub) 팬아웃 계층도 필요함 |
폴링도 여전히 쓸모가 있습니다#
데이터가 천천히 또는 불규칙하게 바뀌고 몇 초 정도 오래된 상태여도 괜찮다면, 폴링이 가장 적합하고 비용도 가장 적게 드는 선택입니다.
- 대시보드, “새 항목” 배지, CI 상태, 1분마다 업데이트되는 IoT 패널에 적합합니다.
- TanStack Query에서는
refetchInterval,refetchOnWindowFocus를 사용하며, 엣지의stale-while-revalidate와도 잘 맞습니다(캐싱 관련 글). - 상황에 맞게 조절하세요. 탭이 숨겨지면 간격을 늘리거나 중단하고, 사용자 동작 직후에는 더 자주 폴링합니다.
- 수천 개의 클라이언트가 같은 시각에 한꺼번에 폴링하지 않도록 간격에 지터(jitter)를 추가합니다.
그 대신 최소 지연 시간이 폴링 간격만큼 생기며, 아무 결과도 받지 못하는 요청을 자주 보내게 됩니다.
SSE가 적합할 때#
- 알림, 라이브 피드, 장시간 작업 진행률, 읽기만 하는 가격 표시기, “다른 사용자가 이 내용을 편집했습니다”와 같은 단방향 업데이트가 필요할 때 적합합니다.
- LLM의 토큰을 스트리밍할 때 적합합니다. 어시스턴트 형태의 UI가 SSE를 사용하는 이유입니다. 서버는 텍스트를 스트리밍하고 클라이언트는 이를 이어 붙이며, 클라이언트의 다음 프롬프트는 일반적인 POST 요청으로 보냅니다.
- 빌드 및 배포 로그를 패널에 스트리밍할 때 적합합니다.
- 직접 구현하지 않고 자동 재연결과 이벤트 재전송을 사용하고 싶을 때 적합합니다.
- 최소한의 인프라만 필요할 때 적합합니다. 그저 HTTP 응답 하나일 뿐입니다.
const es = new EventSource('/api/notifications', { withCredentials: true });
es.addEventListener('notification', e => {
addToInbox(JSON.parse(e.data));
});
es.onerror = () => {
// EventSource는 스스로 재연결합니다. 상태만 반영합니다.
setConnectionState('reconnecting');
};
es.onopen = () => setConnectionState('live');
// EventSource는 404 응답에도 계속 재시도합니다. 작업이 끝나면 직접 닫아야 합니다.
// 컴포넌트 언마운트 또는 라우트 변경 시: es.close();WebSocket이 적합할 때#
- 채팅, 협업 편집(커서 위치, 접속 상태, CRDT 또는 OT 연산), 멀티플레이어 게임, 라이브 드로잉, 주문도 실행하는 트레이딩 터미널처럼 실제로 양방향 통신과 짧은 지연 시간이 필요할 때 적합합니다.
- 오디오나 비디오 프레임, protobuf나 msgpack, 압축한 게임 상태처럼 바이너리 페이로드를 전송할 때 적합합니다.
- 클라이언트가 자주 데이터를 보내 메시지마다 POST 요청을 사용하기에는 낭비가 클 때 적합합니다.
class RealtimeSocket {
private ws?: WebSocket;
private attempt = 0;
private heartbeat?: number;
connect() {
this.ws = new WebSocket(
'wss://api.example.com/realtime?token=' + getToken()
);
this.ws.onopen = () => {
this.attempt = 0;
this.send({ type: 'subscribe', channels: currentChannels() });
this.heartbeat = window.setInterval(
() => this.send({ type: 'ping' }),
25000
);
};
this.ws.onclose = () => {
clearInterval(this.heartbeat);
const delay =
Math.min(1000 * 2 ** this.attempt++, 30000) + Math.random() * 1000;
setTimeout(() => this.connect(), delay); // 백오프 + 지터
};
this.ws.onmessage = e => handleMessage(JSON.parse(e.data));
}
send(msg: unknown) {
if (this.ws?.readyState === WebSocket.OPEN)
this.ws.send(JSON.stringify(msg));
}
}인증의 함정#
브라우저에서는 EventSource와 WebSocket 핸드셰이크 모두에 임의의 헤더를 설정할 수 없습니다. 따라서 둘 다 Authorization: Bearer를 사용할 수 없습니다. 일반적으로 다음과 같은 방법으로 해결합니다.
- 쿠키. 연결이 동일 사이트에서 이루어지면 둘 다 쿠키를 전송합니다(SSE에서는
withCredentials: true가 필요합니다). 사용할 수 있다면 httpOnly 세션 쿠키가 가장 깔끔한 선택입니다. - 쿼리 문자열의 수명이 짧은 토큰. 두 방식 모두에서 동작합니다. 토큰이 서버 액세스 로그와 프록시 로그에 남으므로, 몇 분 안에 만료되고 해당 연결로 범위를 제한한 토큰을 발급합니다.
- fetch 기반 SSE 클라이언트.
@microsoft/fetch-event-source같은 라이브러리는 네이티브EventSource대신fetch를 사용하므로 헤더와 POST 본문을 보낼 수 있습니다. 그 대신 재연결을 직접 구현해야 합니다. - 연결 직후 인증 메시지 전송. WebSocket에서 사용하는 패턴입니다. 연결 직후 첫 번째 메시지로 토큰을 전송하고, 1~2초 안에 토큰이 도착하지 않거나 유효하지 않으면 서버가 소켓을 닫습니다.
클라이언트 관점의 재연결과 회복력#
SSE#
EventSource는 자동으로 재시도하며, 서버는retry:필드로 지연 시간을 제안할 수 있습니다.- 그래도 이벤트 재전송 후 중복을 제거하고, 애플리케이션 상태를 다시 구독하며, “재연결 중” 표시를 보여주고, 리소스가 사라지면 재시도를 중단하는 처리는 필요합니다.
EventSource는 404 응답에도 계속 요청을 보내기 때문입니다.
WebSocket#
- 지수 백오프와 지터를 사용하고 재시도 횟수에 상한을 둔 재연결 로직을 구현합니다.
- 20~30초마다 하트비트 핑을 보냅니다. 연결이 아무 신호 없이 끊어지면 브라우저가
onclose를 즉시 실행하지 않을 때가 있으므로, 하트비트로 연결 종료를 알아냅니다. - 재연결할 때마다 채널을 다시 구독합니다. 서버는 이전 연결의 구독 상태를 기억하지 않습니다.
- 연결이 끊긴 동안 서버로 보낼 메시지를 어떻게 처리할지 정합니다. 큐에 넣거나, 메시지를 버리고 재구독 과정에서 상태를 다시 동기화할 수 있습니다.
두 방식 모두#
document.hidden상태에서는 연결을 일시 중지하거나 닫고, 포커스를 되찾으면 재개합니다.- 컴포넌트가 언마운트되거나 페이지를 이동할 때 연결을 닫습니다.
- 뒤로 가기/앞으로 가기 캐시가 페이지를 멈추고 재개하는 상황을 주의합니다.
점진적인 전략#
클라이언트에서 데이터를 보낼 필요가 없다면 폴링을 폴백으로 둔 SSE를 바로 선택하세요. 거의 모든 상황에 대응하면서도 단순함을 유지할 수 있습니다. 실제로 양방향 통신이 필요하고 까다로운 프록시까지 지원해야 할 때만 “WebSocket, 실패하면 SSE, 다시 실패하면 롱 폴링”으로 이어지는 전체 단계(socket.io가 사용하는 방식)를 선택하세요.
프로젝트별 선택#
| 프로젝트 | 선택 | 이유 |
|---|---|---|
| 채팅/메시징 | WebSocket | 클라이언트가 자주 데이터를 보내고, 지연 시간이 짧아야 하며, 접속 상태를 다룸 |
| 협업 문서 편집기 | WebSocket | 양방향 연산, 커서, 접속 상태 |
| 멀티플레이어 게임 | WebSocket(추후 WebTransport 추가) | 빈도가 높고 바이너리를 자주 사용하며 클라이언트 입력이 있음 |
| LLM 채팅 스트리밍 | SSE | 서버가 토큰을 스트리밍하고 프롬프트는 일반 POST 요청으로 보냄 |
| 알림 벨 | SSE | 서버에서 클라이언트로만 전송하며 자동 재연결이 필요함 |
| 라이브 피드/타임라인 업데이트 | SSE | 단방향이며 재연결 후 이벤트를 다시 받으면 유용함 |
| 장시간 내보내기/가져오기 진행률 표시줄 | SSE | 작업 하나에 스트림 하나를 열고 완료되면 종료함 |
| CI/CD 파이프라인 상태 페이지 | SSE 또는 폴링 | 빈도가 낮고 데이터가 조금 오래되어도 괜찮음 |
| 읽기 전용 가격/점수 표시기 | SSE | 서버 푸시만 필요함. 클라이언트에서도 동작을 보내면 WebSocket 사용 |
| “새 게시물 있음” 배지 | 폴링 | 몇 초 정도 오래된 상태여도 괜찮고 새 인프라가 필요 없음 |
| 분당 약 1회 업데이트하는 IoT 대시보드 | 폴링 또는 SSE | 클라이언트 수와 필요한 지연 시간 수준에 따라 달라짐 |
| 공유 커서/접속 상태 | WebSocket | 작고 일정한 양방향 메시지를 계속 주고받음 |
요약
몇 초 정도 오래된 상태를 허용할 수 있고 새 인프라를 원하지 않는다면 폴링이 기본 선택입니다. SSE는 알림, 피드, 작업 진행률, LLM 토큰처럼 서버에서 클라이언트로 향하는 스트림에 적합합니다. 일반 HTTP를 사용하고 자동 재연결과 재전송을 지원하지만 텍스트만 전송할 수 있습니다. WebSocket은 채팅, 협업, 게임, 접속 상태처럼 실제로 양방향 통신과 짧은 지연 시간이 필요하거나 바이너리 트래픽을 다룰 때 사용합니다. 재연결과 하트비트, 인증 흐름은 직접 구현해야 합니다. SSE와 WebSocket 핸드셰이크 모두 브라우저에서 사용자 정의 인증 헤더를 실을 수 없으므로 쿠키나 수명이 짧은 쿼리 토큰을 준비해야 합니다. 클라이언트에서 데이터를 보낼 필요가 없다면 폴링을 폴백으로 둔 SSE를 선택하고 거기서 멈추세요.
🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!
![[Ykss]](/static/e1b6058af380e8a00f152aefa04db768/cbeb2/profile.jpg)