WebSocket vs SSE: 실시간 기능에 양방향이 정말 필요한가
단방향이면 충분한 실시간 기능에 SSE가 더 단순한 이유, WebSocket이 필요한 경우, 그리고 프록시·서버리스에서 각각 걸리는 지점을 정리했습니다.
실시간 기능에 양방향이 정말 필요한가
알림, 진행률 표시, 실시간 피드 같은 기능을 만들려면 서버가 클라이언트에게 먼저 말을 걸어야 합니다. 이때 대부분 WebSocket을 떠올리지만, 필요한 것이 한 방향 전달이라면 SSE(Server-Sent Events)가 더 단순한 답입니다.
핵심 질문은 하나입니다. 클라이언트가 서버로 자주 보낼 메시지가 있는가? 없다면 양방향 연결을 유지할 이유도 없습니다.
SSE: HTTP 응답을 끊지 않고 흘려보낸다
SSE는 새 프로토콜이 아닙니다. 그냥 HTTP 응답을 닫지 않고 텍스트를 계속 내려보내는 방식입니다.
// 서버 (Web 표준 Response 기준)
const stream = new ReadableStream({
start(controller) {
const send = (data) => controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
send({ progress: 0 });
// ...작업 진행에 따라 계속 send()
},
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
});
클라이언트는 브라우저 내장 API 한 줄입니다.
const es = new EventSource("/api/progress");
es.onmessage = (e) => setProgress(JSON.parse(e.data).progress);
SSE의 실질적 장점은 재연결이 표준에 들어 있다는 점입니다. 연결이 끊기면 브라우저가 알아서 다시 붙고, 서버가 id: 필드를 보냈다면 마지막으로 받은 값을 Last-Event-ID 헤더로 함께 보냅니다. 서버는 그 지점부터 이어 보낼 수 있습니다. WebSocket에서는 이 로직을 직접 만들어야 합니다.
제약도 분명합니다. 서버에서 클라이언트로만 갑니다. 텍스트만 보낼 수 있어서 바이너리는 인코딩이 필요합니다. 그리고 EventSource로는 커스텀 헤더를 붙일 수 없어, 인증은 쿠키에 의존하거나 별도 처리를 해야 합니다.
WebSocket: 연결을 열어두고 양쪽이 말한다
WebSocket은 HTTP로 핸드셰이크한 뒤 프로토콜을 전환해 전용 양방향 채널을 만듭니다. 이후에는 양쪽이 언제든 프레임을 보낼 수 있고, 텍스트와 바이너리를 모두 다룹니다.
메시지 지연이 중요하고 클라이언트 입력이 잦은 경우 이 구조가 맞습니다. 채팅, 멀티플레이 게임, 커서와 편집이 동시에 오가는 협업 도구가 대표적입니다.
대가는 직접 만들어야 할 것이 많다는 점입니다. 재연결과 백오프, 연결 상태 관리, 끊긴 동안 놓친 메시지 복구, 살아 있는지 확인하는 핑퐁이 전부 애플리케이션의 몫입니다. 메시지 형식도 직접 정해야 합니다.
인프라에서 갈리는 부분
두 방식 모두 "연결을 오래 유지한다"는 공통점이 있어서, 중간 장비 설정에서 문제가 생깁니다. 여기서 겪는 증상이 다릅니다.
SSE는 응답 버퍼링에 막힙니다. 프록시가 응답을 모아서 한 번에 보내려 하면 이벤트가 실시간으로 도착하지 않습니다. nginx라면 해당 위치에서 proxy_buffering off를 켜거나, 응답에 X-Accel-Buffering: no 헤더를 실어 버퍼링을 끄면 해결됩니다. 로컬에서는 잘 되다가 배포하면 이벤트가 뭉쳐서 오는 현상이 대개 이것입니다.
WebSocket은 업그레이드 통과가 관문입니다. 프록시나 로드밸런서가 Upgrade, Connection 헤더를 전달하도록 설정되어 있어야 하고, 유휴 연결 타임아웃도 넉넉히 잡아야 합니다.
그리고 둘 다 서버리스 함수와 잘 맞지 않습니다. 함수는 짧게 실행되고 끝나는 것을 전제로 하기 때문에, 연결을 몇 분씩 붙들고 있는 용도로는 실행 시간 제한과 비용 구조가 모두 불리합니다. 상시 실행되는 서버를 따로 두거나 관리형 실시간 서비스를 쓰는 편이 현실적입니다.
그리고 폴링이라는 선택지
갱신 주기가 몇 초 이상이어도 괜찮은 기능이라면 주기적으로 물어보는 방식이 여전히 유효합니다. 새 댓글 확인, 상태 갱신 정도라면 일반 HTTP 요청으로 충분합니다.
연결을 유지하지 않으니 인프라 제약이 없고, 서버리스에서도 문제없이 돌고, 실패해도 다음 주기에 복구됩니다. "실시간"이라는 말에 끌려 필요 이상으로 복잡한 구조를 택하는 경우가 흔한데, 요구되는 지연 시간을 먼저 정해보면 판단이 쉬워집니다.
선택 기준
SSE가 맞는 경우
- 서버 → 클라이언트 단방향 (알림, 진행률, 로그 스트리밍, 생성 결과 스트리밍)
- 끊겼을 때 자동 재연결과 이어받기가 필요하다
- 인프라를 단순하게 유지하고 싶다
WebSocket이 맞는 경우
- 양쪽이 자주 주고받는다 (채팅, 게임, 실시간 협업)
- 바이너리 데이터를 보낸다
- 지연 시간이 기능의 품질을 좌우한다
폴링이 맞는 경우
- 수 초~수십 초 지연이 허용된다
- 서버리스에 배포하며 상시 서버를 두고 싶지 않다
한 화면에서 둘을 같이 써도 되나요?
가능하고 실제로 흔합니다. 예를 들어 채팅은 WebSocket으로 처리하면서, 서버가 일방적으로 알리는 시스템 공지는 SSE로 받는 구성이 있습니다. 다만 연결을 두 개 유지하는 비용이 생기므로, 이미 WebSocket을 열어둔 상태라면 그 채널로 함께 보내는 편이 단순합니다.
SSE는 동시 연결 수에 제한이 있다고 들었는데요?
HTTP/1.1에서는 브라우저가 같은 도메인에 유지하는 연결 수 한도가 있어서, 여러 탭을 열면 SSE 연결이 그 한도를 잡아먹는 문제가 있었습니다. HTTP/2 이상에서는 하나의 연결에 여러 스트림이 다중화되므로 이 제약이 사실상 사라집니다. 다만 서버가 HTTP/2로 서비스되는지 확인해야 하고, 그렇다 해도 서버 쪽 동시 연결 수는 여전히 용량 계획의 대상입니다.
연결이 끊긴 동안의 메시지는 어떻게 하나요?
SSE는 id:와 Last-Event-ID로 재개 지점을 알려주는 규약이 있지만, 그 지점부터 보낼 데이터를 서버가 보관하고 있어야 실제로 이어집니다. WebSocket은 이 전부를 직접 설계해야 합니다. 어느 쪽이든 놓친 구간이 중요하다면, 재연결 직후 최신 상태를 한 번 통째로 받아오는 경로를 함께 두는 편이 안전합니다.
만들어보면 요구사항이 정해진다
어느 정도의 지연이 허용되는지는 실제로 써봐야 감이 옵니다. 폴링으로 먼저 만들어 쓰다가 답답한 지점이 분명해지면 그때 옮기는 순서가 낭비가 적습니다.
실시간 기능이 들어간 프로젝트를 만들었다면 Vibeollio에 프로젝트 등록해서 공개해보세요. 실시간 동작은 네트워크 환경에 따라 체감이 크게 달라지는데, 여러 환경의 방문자가 직접 써보고 남기는 반응이 로컬 테스트로는 알 수 없는 것을 알려줍니다.
