- Published on
웹소켓, SSE, 폴링 — 실시간 통신 방식 고르기와 대부분 웹소켓이 필요 없는 이유
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — 실시간이라는 요구사항을 받았을 때
"알림을 실시간으로 보여 주세요"라는 요구가 오면 대개 웹소켓 이야기부터 시작됩니다. 라이브러리를 고르고, 재연결 로직을 짜고, 로드밸런서 설정을 바꾸고, 수평 확장을 위해 레디스 어댑터를 붙입니다. 그런데 정작 주고받는 데이터는 서버에서 클라이언트로 가는 알림 한 종류뿐인 경우가 대단히 많습니다.
먼저 물어야 할 질문은 세 개입니다. 데이터가 양방향으로 흐르는가, 클라이언트에서 서버로 가는 메시지의 빈도가 얼마인가, 허용 가능한 지연이 몇 초인가. 이 답에 따라 필요한 기술이 갈립니다. 그리고 상당수의 답은 웹소켓이 아닙니다.
네 가지 방식의 동작과 비용
| 방식 | 지연 | 서버 커넥션 | 메시지당 오버헤드 | 방향 | 재연결 | HTTP 인프라 호환 |
|---|---|---|---|---|---|---|
| 짧은 폴링 | 0에서 주기까지 | 요청 시에만 | 요청과 응답 헤더 전체 | 단방향 | 불필요 | 완전 |
| 롱 폴링 | 거의 즉시 | 대기 중 계속 점유 | 메시지마다 헤더 전체와 재연결 | 단방향 | 매번 새 요청 | 완전 |
| SSE | 거의 즉시 | 계속 점유 | 이벤트 프레임 수 바이트 | 단방향 | 자동 | 완전 |
| 웹소켓 | 거의 즉시 | 계속 점유 | 프레임 헤더 2에서 14바이트 | 양방향 | 직접 구현 | 업그레이드 이후 없음 |
표에서 눈여겨볼 칸은 마지막 두 개입니다. SSE는 재연결이 규격에 들어 있고 HTTP 인프라를 그대로 씁니다. 웹소켓은 둘 다 직접 만들어야 합니다.
짧은 폴링의 비용을 구체적으로 보면 이렇습니다. 동시 사용자 1만 명이 5초마다 확인하면 초당 2000요청입니다. 대부분은 "바뀐 것 없음"을 돌려주는 빈 응답입니다. 대신 서버는 아무 상태도 들고 있지 않고, 캐시와 로드밸런서가 평소대로 동작하며, 장애 시 그냥 다음 주기에 복구됩니다. 30초에 한 번 갱신되면 충분한 대시보드라면 이것이 정답입니다. 실시간처럼 보이지 않아서 배제될 뿐, 기술적으로 가장 견고합니다.
롱 폴링은 요청을 붙잡아 두었다가 데이터가 생기면 응답합니다. 지연은 거의 사라지지만, 메시지마다 응답을 닫고 새 요청을 여는 왕복이 필요하고 헤더가 통째로 다시 오갑니다. 메시지 빈도가 높으면 SSE보다 확연히 비쌉니다. 지금은 SSE가 안 되는 환경을 위한 폴백 정도의 자리입니다.
단방향이라면 SSE가 이긴다
SSE는 평범한 GET 요청 하나입니다. 응답의 Content-Type이 text/event-stream이고, 서버가 연결을 닫지 않은 채 텍스트를 계속 흘려보냅니다.
GET /api/notifications/stream HTTP/1.1
Accept: text/event-stream
Cookie: sid=8f3c...
HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-store
X-Accel-Buffering: no
retry: 3000
id: 1042
event: order.updated
data: {"orderId":"ord_8812","status":"SHIPPED"}
: keep-alive
id: 1043
event: order.updated
data: {"orderId":"ord_8813","status":"PAID"}
클라이언트 코드도 짧습니다.
const es = new EventSource('/api/notifications/stream')
es.addEventListener('order.updated', (e) => {
const payload = JSON.parse(e.data)
applyOrderUpdate(payload)
})
// 연결이 끊기면 브라우저가 알아서 retry 간격 뒤에 다시 붙습니다
es.onerror = () => console.warn('stream interrupted, browser will retry')
여기서 얻는 것들이 웹소켓 대비 실질적인 차이입니다.
재연결이 공짜입니다. 네트워크가 끊기거나 서버가 재시작되면 브라우저가 retry 값에 따라 자동으로 다시 붙습니다. 웹소켓에서는 이 로직을 직접 짜야 하고, 대부분의 첫 구현은 백오프도 지터도 없습니다.
재개가 규격에 있습니다. 서버가 id 필드를 보내면 브라우저가 그 값을 기억했다가 재연결 시 Last-Event-ID 헤더로 보냅니다. 서버는 그 지점 이후의 이벤트만 다시 보내면 됩니다. 끊긴 동안의 알림이 사라지는 문제를 표준 방식으로 해결할 수 있습니다.
인증과 미들웨어가 그대로 동작합니다. 쿠키가 실리고, 기존 인증 미들웨어가 통과 여부를 판단하고, 접근 로그가 남고, 레이트 리밋이 걸립니다. 웹소켓은 업그레이드 이후 HTTP가 아니므로 이 모든 것을 프로토콜 위에서 다시 만들어야 합니다.
LLM 응답 스트리밍이 대부분 SSE로 구현되어 있는 것도 우연이 아닙니다. 서버가 토큰을 밀어 주기만 하면 되는 전형적인 단방향 문제입니다.
SSE의 실질적인 약점도 정확히 알아 두는 편이 좋습니다. EventSource API는 커스텀 헤더를 지원하지 않습니다. Authorization 헤더를 붙일 수 없다는 뜻입니다. 쿠키 인증이면 문제가 없지만, 베어러 토큰을 쓰는 구조라면 토큰을 쿼리 문자열에 넣는 유혹에 빠집니다. 쿼리 문자열은 액세스 로그와 프록시 로그에 그대로 남으므로 피해야 합니다. 대안은 fetch로 스트림을 직접 읽는 것입니다.
const res = await fetch('/api/notifications/stream', {
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
})
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
// 이 방식은 재연결과 Last-Event-ID 처리를 직접 구현해야 합니다
헤더는 자유로워지지만 규격이 주던 자동 재연결과 재개는 잃습니다. 그 둘을 직접 구현할 각오라면 선택할 만합니다.
또 하나, SSE는 텍스트만 보냅니다. 바이너리를 보내려면 base64로 부풀려야 하므로 33퍼센트의 낭비가 생깁니다. 대용량 바이너리가 오간다면 SSE는 맞지 않습니다.
웹소켓이 정말 필요한 경우
경계는 클라이언트에서 서버로 가는 메시지의 빈도와 지연 요구입니다.
채팅에서 타이핑 표시를 보내는 것처럼 초당 여러 번 클라이언트가 말해야 하는 경우, 협업 편집기에서 커서 위치와 편집 연산을 흘려보내는 경우, 멀티플레이어 게임의 입력 동기화, 바이너리 프레임을 주고받는 경우가 여기 해당합니다. 이런 자리에서는 요청마다 붙는 HTTP 오버헤드가 실제로 문제가 되고, 양방향 프레이밍이 주는 이득이 명확합니다.
반면 클라이언트에서 서버로 가는 이벤트가 초당 한 번 미만이라면, 그냥 POST로 보내면 됩니다. 서버에서 클라이언트로는 SSE, 클라이언트에서 서버로는 일반 POST를 쓰는 조합은 생각보다 훨씬 자주 정답입니다. HTTP/2나 HTTP/3에서는 요청들이 하나의 커넥션에 다중화되므로 커넥션 수립 비용도 반복되지 않습니다.
착각하기 쉬운 부분도 있습니다. "채팅이니까 웹소켓"이라는 판단은 절반만 맞습니다. 메시지를 보내는 행위는 초당 몇 번 있는 일이 아니므로 POST로 충분하고, 받는 쪽만 스트림이면 됩니다. 타이핑 표시나 실시간 커서처럼 고빈도 신호를 넣기로 결정한 순간 웹소켓이 필요해집니다. 즉 웹소켓을 부르는 것은 채팅이라는 도메인이 아니라 고빈도 상향 트래픽이라는 성질입니다.
인프라의 현실 — 타임아웃, 버퍼링, 커넥션 한도, 스티키 세션
장수 연결은 중간 장비들과 부딪힙니다. 개발 환경에서는 안 보이다가 프로덕션에서 나타나는 문제가 대부분 여기 있습니다.
유휴 타임아웃이 가장 흔합니다. AWS의 애플리케이션 로드밸런서는 기본 유휴 타임아웃이 60초이고, nginx의 프록시 읽기 타임아웃도 기본 60초입니다. 60초 동안 아무 바이트도 흐르지 않으면 연결이 끊깁니다. 알림이 뜸한 시간대에 연결이 계속 끊기는 증상이 여기서 나옵니다.
해법은 하트비트입니다. SSE에서는 콜론으로 시작하는 주석 줄을 주기적으로 보냅니다. 클라이언트는 이 줄을 무시하지만 중간 장비는 트래픽이 흐른다고 판단합니다.
// SSE 하트비트 — 가장 짧은 타임아웃의 절반 이하 주기로 보냅니다
const heartbeat = setInterval(() => res.write(': keep-alive\n\n'), 25_000)
req.on('close', () => clearInterval(heartbeat))
웹소켓에는 ping과 pong 제어 프레임이 있습니다. 서버가 ping을 보내고 정해진 시간 안에 pong이 오지 않으면 죽은 연결로 보고 정리합니다. 이 정리를 안 하면 실제로는 끊긴 연결의 상태가 서버 메모리에 계속 남습니다. TCP 연결이 조용히 사라지는 경우가 드물지 않기 때문에, 하트비트는 타임아웃 회피보다 죽은 연결 탐지 목적이 더 큽니다.
버퍼링도 자주 걸립니다. nginx는 기본적으로 프록시 응답을 버퍼링하므로 SSE 이벤트가 즉시 나가지 않고 뭉쳐서 나갑니다. 응답에 X-Accel-Buffering: no를 붙이거나 해당 로케이션에서 프록시 버퍼링을 끄면 됩니다. 압축 미들웨어도 같은 문제를 만듭니다. 이벤트마다 플러시하지 않는 gzip 미들웨어를 거치면 스트림이 정지한 것처럼 보입니다. text/event-stream은 압축 대상에서 빼는 편이 안전합니다.
브라우저의 커넥션 한도는 SSE에 대해 가장 많이 인용되는 약점입니다. HTTP/1.1에서 브라우저는 오리진당 6개 정도의 커넥션만 엽니다. 탭마다 SSE 스트림을 하나씩 열면 여섯 개 탭에서 한도가 차고, 일곱 번째 탭은 물론 그 오리진으로 나가는 다른 요청까지 전부 대기합니다. 원인을 모르면 대단히 이상한 증상으로 보입니다.
이 문제는 HTTP/2에서 사라집니다. 하나의 TCP 커넥션 위에 스트림이 다중화되고 동시 스트림 수는 기본값이 100 이상입니다. TLS를 쓰는 현대적인 CDN이나 로드밸런서 뒤라면 이미 HTTP/2로 서빙되고 있을 가능성이 높습니다. SSE를 검토할 때는 실제 프로토콜 버전부터 확인하면 됩니다. 참고로 웹소켓은 이 6개 한도의 적용을 받지 않고 별도의 훨씬 큰 한도를 씁니다.
스티키 세션은 선택이 아니라 성질입니다. 연결이 한 번 맺어지면 그 수명 동안 특정 노드에 묶입니다. 여기서 파생되는 문제가 배포입니다. 롤링 배포로 노드를 하나 내리면 그 노드의 모든 연결이 동시에 끊기고, 클라이언트들이 동시에 재연결을 시도합니다. 노드를 순차적으로 내리면 이 파도가 배포 시간 내내 반복됩니다. 완화책은 연결 드레이닝 시간을 충분히 주고, 클라이언트 재연결에 지터를 넣고, 서버가 종료 전에 재연결 지연을 늘린 값을 내려보내는 것입니다.
수평 확장에는 펍섭이 필요하다
노드가 둘 이상이면 즉시 부딪히는 문제가 있습니다. 사용자 A는 노드 1에, 사용자 B는 노드 2에 연결되어 있는데, A가 만든 이벤트를 B에게 보내야 합니다. 노드 1은 B의 소켓을 갖고 있지 않습니다.
그래서 노드 간 전파 계층이 필요합니다. 레디스 펍섭, NATS, 카프카가 흔한 선택입니다. 각 노드가 관련 채널을 구독하고, 받은 이벤트를 자기 노드에 붙어 있는 연결로 내려보냅니다.
선택할 때 봐야 할 것은 전달 보장입니다. 레디스 펍섭은 발행 시점에 구독 중이 아닌 노드에는 전달되지 않습니다. 재시작 중이던 노드는 그 사이의 메시지를 영구히 놓칩니다. 알림 유실이 허용되지 않는다면 레디스 스트림이나 카프카처럼 저장되는 로그를 쓰고, 클라이언트 재연결 시 Last-Event-ID 기준으로 빠진 구간을 채워 줘야 합니다.
팬아웃 비용도 계산해야 합니다. 모든 노드가 모든 이벤트를 받는 구조라면, 노드 수가 늘어날수록 노드당 처리량이 줄지 않고 그대로입니다. 채널을 잘게 나눠서 관심 있는 노드만 구독하게 만드는 것이 확장의 관건입니다.
재연결, 하트비트, 백프레셔
재연결 폭풍은 실시간 서비스의 대표적인 자해입니다. 배포나 네트워크 장애로 10만 개의 연결이 동시에 끊기면, 그 10만 개가 동시에 다시 붙으려 합니다. 서버는 연결 수립과 인증과 초기 상태 전송을 한꺼번에 처리해야 하고, 대개 그 부하로 다시 무너집니다.
대책은 클라이언트 쪽의 지터 있는 지수 백오프입니다.
let attempt = 0
function scheduleReconnect() {
const cap = 30_000
const delay = Math.random() * Math.min(cap, 500 * 2 ** attempt)
attempt += 1
setTimeout(connect, delay)
}
function onOpen() {
attempt = 0 // 성공하면 반드시 초기화합니다
}
SSE를 쓰면 retry 필드로 서버가 이 값을 어느 정도 통제할 수 있습니다. 과부하 상황에서 큰 값을 내려보내 재접속을 늦추는 식입니다.
백프레셔는 더 조용히 문제가 됩니다. 느린 클라이언트에게 초당 수십 개의 이벤트를 밀어 넣으면, 전송되지 못한 데이터가 서버 메모리에 쌓입니다. Node에서 res.write()는 내부 버퍼가 가득 차면 false를 반환하는데, 이 반환값을 무시하고 계속 쓰면 메모리가 계속 자랍니다. 웹소켓 라이브러리에서는 소켓의 버퍼 대기량을 확인할 수 있습니다.
// 밀린 데이터가 임계치를 넘으면 정책을 적용합니다
if (socket.bufferedAmount > 1_000_000) {
// 1) 오래된 델타는 버리고 최신 스냅샷 하나로 합치거나
// 2) 이 클라이언트를 끊고 재연결 시 전체 상태를 다시 받게 합니다
socket.close(1013, 'client too slow')
}
선택지는 셋 중 하나입니다. 버리기, 합치기, 끊기. 무한정 버퍼링만은 답이 아닙니다. 특히 위치나 시세처럼 최신 값만 의미 있는 데이터는 밀린 것들을 합쳐서 마지막 상태 하나만 보내는 것이 정확도와 자원을 동시에 지킵니다.
웹소켓을 쓴다면 상향 메시지에 대한 레이트 리밋도 직접 넣어야 합니다. HTTP 미들웨어의 보호가 업그레이드 이후에는 적용되지 않기 때문입니다. 메시지 크기 상한과 초당 메시지 수 상한을 두지 않으면 클라이언트 하나가 노드를 점유할 수 있습니다.
서버 자원 계산 — 동시 연결 수와 메모리
용량 산정은 단순한 곱셈이지만 항목을 빠뜨리기 쉽습니다.
연결 하나당 커널의 소켓 송수신 버퍼가 붙습니다. 기본 설정에서 수 KB에서 수십 KB 사이이고 튜닝 가능합니다. TLS를 쓰면 세션별 버퍼가 추가로 붙는데, 기본 설정에서는 연결당 수십 KB에 이릅니다. 여기에 애플리케이션이 연결마다 들고 있는 사용자 정보, 구독 목록, 전송 큐가 더해집니다.
현실적인 값은 연결당 10KB에서 50KB입니다. 동시 연결 10만 개면 1GB에서 5GB이고, 100만 개를 한 노드에 담으려는 시도는 대부분 이 계산에서 막힙니다. 노드를 늘리는 편이 거의 항상 낫습니다.
메모리 외에 걸리는 한계가 몇 개 더 있습니다. 파일 디스크립터 상한은 프로세스와 시스템 양쪽에서 올려야 합니다. 프록시가 백엔드로 나갈 때 쓰는 임시 포트는 목적지 조합당 대략 6만 개대에서 고갈되므로, 프록시와 백엔드 사이의 커넥션 재사용이나 백엔드 주소 다변화가 필요합니다. NAT 게이트웨이의 연결 추적 테이블도 같은 이유로 상한이 됩니다.
하트비트의 패킷 비용도 무시하면 안 됩니다. 연결 10만 개에 25초 주기 하트비트를 걸면 초당 4000개의 작은 패킷이 왕복합니다. 대역폭은 미미하지만 인터럽트와 컨텍스트 스위치는 실제 비용이고, 이벤트가 거의 없는 서비스에서는 하트비트가 트래픽의 대부분을 차지하게 됩니다. 주기는 필요한 만큼만 짧게 잡습니다.
마치며 — 필요한 만큼만 실시간으로
방식을 고르는 순서는 이렇습니다. 갱신 주기가 수십 초여도 되는가. 그렇다면 폴링으로 끝냅니다. 데이터가 서버에서 클라이언트로만 흐르는가. 그렇다면 SSE입니다. 자동 재연결과 재개가 규격에 있고, 인증과 로깅과 레이트 리밋이 평소 쓰던 것 그대로입니다. 클라이언트에서 서버로 초당 여러 번 말해야 하거나 바이너리를 주고받아야 하는가. 그때 웹소켓입니다.
웹소켓을 도입하는 순간 HTTP가 주던 것들을 전부 직접 만들어야 하고, 그 비용은 라이브러리를 붙이는 시점이 아니라 운영 6개월 차에 청구됩니다. 재연결 폭풍, 죽은 연결 누수, 노드 간 전파, 상향 메시지 남용, 배포 시 대량 절단이 순서대로 찾아옵니다. 그 비용을 치를 이유가 분명할 때만 치르는 것이 좋습니다.