폴링은 클라이언트가 반복해서 묻는 풀 모델이고, SSE와 웹소켓은 연결 하나를 열어 두어 서버가 실시간으로 푸시하게 하는 방식입니다. 셋 중 하나를 고르는 일은 사실 세 가지 질문에 답하는 일입니다. 데이터가 어느 방향으로 흐르는지, 얼마나 자주 바뀌는지, 그 사이에 어떤 인프라가 있는지입니다. 그리고 솔직한 답은 앱 단위가 아니라 기능 단위로 달라집니다.
핵심 요약
| 질문 | 답변 |
|---|---|
| 숏 폴링 | 타이머에 맞춰 질의 – 단순하고 캐시 친화적이지만 요청 대부분이 낭비 |
| 롱 폴링 | 데이터가 도착할 때까지 서버가 요청을 붙잡음 – 평범한 HTTP로 흉내 낸 푸시 |
| SSE | HTTP 스트림 하나, 서버 → 클라이언트 텍스트 이벤트, 자동 재연결 내장 |
| 웹소켓 | 소켓 하나, 전이중, 바이너리 지원 – 프로토콜은 직접 소유 |
| 경험칙 | 단방향 → SSE · 양방향 → 웹소켓 · 드문 변경 → 폴링으로 충분 |
코드로 나란히 보기
// 1 · 숏 폴링 — 타이머에 맞춰 질의
setInterval(async () => {
const res = await fetch('/api/messages?since=' + lastId);
render(await res.json()); // 대개 빈 응답 — 헤더 비용은 그대로 지불
}, 2000);
// 2 · Server-Sent Events — HTTP 스트림 하나, 서버가 텍스트 이벤트를 푸시
const events = new EventSource('/api/stream');
events.onmessage = (e) => render(JSON.parse(e.data)); // 자동 재연결
// 3 · WebSocket — 소켓 하나, 양방향
const ws = new WebSocket('wss://api.example.com/live');
ws.onmessage = (e) => render(JSON.parse(e.data));
ws.send(JSON.stringify({ type: 'typing' })); // 클라이언트도 푸시
대부분의 앱이 실제로 배포하는 것은 전송 계층을 대신 맡아 주는 구독 계층입니다:
// JavaScript / Node.js — Back4app JS SDK
// The polling replacement: subscribe once, receive pushes
const query = new Parse.Query('Message');
query.equalTo('room', 'general');
const subscription = await query.subscribe(); // WebSocket under the hood
subscription.on('create', (msg) => render(msg)); // pushed, not polled
// subscription.unsubscribe() when the screen closes // Flutter / Dart — Back4app Flutter SDK
// The polling replacement: subscribe once, receive pushes
final liveQuery = LiveQuery();
final query = QueryBuilder<ParseObject>(ParseObject('Message'))
..whereEqualTo('room', 'general');
final subscription = await liveQuery.client.subscribe(query); // WebSocket under the hood
subscription.on(LiveQueryEvent.create, (msg) => render(msg)); // pushed, not polled // iOS / Swift — Back4app Swift SDK
// The polling replacement: subscribe once, receive pushes
let query = Message.query("room" == "general")
let subscription = try await query.subscribe() // WebSocket under the hood
subscription.handleEvent { _, event in
if case .created(let msg) = event { render(msg) } // pushed, not polled
} // Android / Kotlin — Back4app Android SDK
// The polling replacement: subscribe once, receive pushes
val client = ParseLiveQueryClient.Factory.getClient()
val query = ParseQuery.getQuery<ParseObject>("Message")
query.whereEqualTo("room", "general")
val subscription = client.subscribe(query) // WebSocket under the hood
subscription.handleEvent(SubscriptionHandling.Event.CREATE) { _, msg ->
render(msg) // pushed, not polled
} 각 기법의 작동 방식
숏 폴링(short polling)은 fetch를 setInterval로 감싼 것입니다. n초마다 바뀐 것이 있는지 묻습니다. 매 주기마다 헤더, 인증, 라우팅을 포함한 전체 HTTP 요청/응답 비용을 치르는데, 대개 “아직 없음”을 듣기 위해서이며, 평균 전달 지연은 주기의 절반에 왕복 시간을 더한 값입니다.
롱 폴링(long polling)은 대기를 서버 측으로 옮깁니다. 클라이언트가 묻고, 서버는 데이터가 도착하거나 타임아웃이 발생할 때까지 요청을 열어 두며, 클라이언트는 즉시 다시 묻습니다. 평범한 HTTP로 푸시에 근접하는 Comet 시대의 우회책이며, 그 알려진 비용(재연결 반복, 요청마다 붙는 헤더, 순서 처리의 주의점)은 RFC 6202에 정리되어 있습니다.
서버 전송 이벤트(Server-Sent Events)는 붙잡아 둔 응답을 영구적으로 만듭니다. Content-Type: text/event-stream을 가진 끝나지 않는 HTTP 응답 하나로, 서버는 그 위로 UTF-8 이벤트를 씁니다(WHATWG 표준에 따른 data:, event:, id:, retry: 필드). 브라우저의 EventSource가 이를 소비하며, 자동으로 재연결하고 Last-Event-ID로 재개합니다.
웹소켓은 HTTP를 완전히 떠납니다. 핸드셰이크가 연결을 업그레이드하고(101 Switching Protocols, RFC 6455), 그 뒤로 양쪽은 누군가 소켓을 닫을 때까지 약 2–14바이트의 오버헤드로 텍스트나 바이너리 프레임을 전이중으로 주고받습니다. 가장 강력한 방식이지만, 프레임 위의 모든 것(메시지 형식, 확인 응답, 재연결, 재개)은 직접 설계해야 합니다.
SSE vs. 웹소켓 vs. 롱 폴링 vs. 숏 폴링
| 숏 폴링 | 롱 폴링 | SSE | 웹소켓 | |
|---|---|---|---|---|
| 방향 | 풀 | 흉내 낸 푸시 | 서버 → 클라이언트 | 전이중 |
| 프로토콜 | 평범한 HTTP | 평범한 HTTP | 평범한 HTTP 스트림 | 업그레이드 후 자체 프로토콜 |
| 전달 지연 | 주기/2 + RTT | ~RTT | ~RTT | ~RTT |
| 페이로드 | 무엇이든 | 무엇이든 | UTF-8 텍스트만 | 텍스트 + 바이너리 |
| 자동 재연결 | 저절로 됨(다음 폴링) | 재요청 루프 | 내장 + Last-Event-ID | 직접 작성 |
| 프록시/방화벽 마찰 | 없음 | 낮음 | 낮음(버퍼링 주의) | 업그레이드가 제거될 수 있음 |
| 서버 상태 | 없음 | 붙잡아 둔 요청 | 열린 스트림 | 고정된 소켓 |
| 복잡도 | 매우 낮음 | 중간 | 낮음 | 가장 높음 |
| 최적 지점 | 드문 변경 | 레거시 폴백 | 피드, 알림, AI 스트림 | 채팅, 게임, 협업 |
폴링의 비용은 얼마입니까? 오버헤드 계산
어떤 순위 페이지도 하지 않는 비교입니다. 2초 주기로 폴링하는 클라이언트 1,000개와, 같은 1,000개를 푸시로 연결한 경우입니다:
클라이언트 1,000개, 2 s 폴링 클라이언트 1,000개, 푸시
→ 초당 500건의 요청이 계속 발생 → 유휴 시 요청 0건
→ 주기마다 헤더 ~800 B → SSE 이벤트 프레이밍 ~5 B
→ 순수 헤더 비용만 ~0.4 MB/s → WebSocket 프레임 오버헤드 2–14 B
→ 거의 모든 응답이 빈 응답 → 데이터가 있을 때만 바이트가 흐름
→ 평균 전달 지연: 1 s + RTT → 전달 지연: ~RTT
다만 이 계산이 푸시에만 유리한 것은 아닙니다. 업데이트가 잦고 지연 시간이 중요하면 푸시가 압도적으로 이깁니다. 하지만 데이터가 한 시간에 한 번 바뀐다면 초당 500건의 요청은 애초에 생기지 않습니다. 느슨한 폴링(또는 포커스 시 재조회)은 캐시 친화적이고, curl로 디버깅할 수 있고, 서버리스와 호환되며, 연결 상태가 없습니다. 폴링을 통째로 배제하는 것은 엔지니어링 판단이 아니라 유행을 따르는 것입니다.
재연결: 조용한 차별화 요소
연결은 끊깁니다. 기지국 핸드오프, 노트북 덮개 닫기, 프록시 유휴 타임아웃(흔히 30–120 s이며, 그래서 SSE 스트림은 주석 형태의 keep-alive를 보내고 웹소켓은 ping/pong 프레임을 주고받습니다) 때문입니다. 그다음에 일어나는 일이 전송 방식을 가릅니다. SSE는 계약 자체가 이를 처리합니다. 브라우저가 서버가 지정한 retry 지연으로 재시도하고 Last-Event-ID를 제시하므로, 짧은 이벤트 버퍼를 유지하는 서버는 공백 없이 스트림을 재개합니다. 원시 웹소켓은 그냥 닫힙니다. 지수 백오프 재연결, 놓친 메시지 복구, 재개 프로토콜이 모두 애플리케이션 코드이며, “그냥 웹소켓을 쓰면 된다”에서 가장 과소평가되는 항목입니다. 어느 쪽이든 몇 분 동안 오프라인이었던 클라이언트에게는 재연결만이 아니라 따라잡기(기본 쿼리 재실행)가 필요합니다. 푸시 전송은 변경분을 전달하며, 변경분은 기준선을 전제로 합니다.
AI 채팅이 SSE로 스트리밍하는 이유
토큰 단위의 모델 출력은 SSE에 완벽하게 맞는 워크로드입니다. 엄격하게 단방향이고, 텍스트이며, 간헐적으로 몰리고, 모든 프록시와 CDN이 이해하는 평범한 HTTP 위에서 동작하며, 중단된 생성을 재개할 수 있습니다. 그래서 LLM API는 압도적으로 완성 결과를 text/event-stream으로 스트리밍하며, 이 패턴은 챗봇을 넘어 알아 둘 가치가 있습니다. 진행 상황 피드, 빌드 로그, 대시보드가 같은 형태를 공유합니다. 웹소켓은 사용자가 스트림 도중에 끼어드는 음성 계층에서 AI 제품에 들어옵니다. 클라이언트가 되받아 말해야 하는 순간부터 전이중 방식의 비용을 치를 가치가 생깁니다.
푸시의 확장: 상태 저장이 중요한 이유
로드 밸런서 입장에서 폴링과 SSE는 평범한 HTTP입니다. 어느 서버든 어느 요청에나 응답할 수 있습니다(SSE는 스트림을 유지하지만 HTTP 의미론을 지키므로 버퍼링하지 않는 프록시만 있으면 됩니다). 웹소켓은 상태입니다. 각 소켓이 클라이언트를 프로세스 하나에 고정하므로, 수평 확장에는 연결을 인식하는 로드 밸런싱, 배포 시 연결 드레이닝, 그리고 서버 A에서 발행된 메시지가 서버 B가 가진 소켓에 도달하도록 하는 pub/sub 백플레인(흔히 Redis)이 필요합니다. HTTP/1.1에서는 SSE에도 유명한 함정이 있습니다. 출처당 6개의 연결이 탭 사이에서 공유된다는 점입니다(MDN의 경고). 스트림이 연결 하나에 다중화되는 HTTP/2에서는 이 문제가 사라집니다. 어느 것도 특이하지 않지만, 바로 이 모든 것 때문에 관리형 실시간 계층이 존재합니다.
일반적인 사용 사례
- 알림 피드와 시세 표시기 – 단방향, 텍스트, 잦은 빈도. SSE가 가장 잘 맞는 영역입니다.
- AI 응답 스트리밍 – 현재 SSE의 가장 대표적인 사용 사례입니다. 단방향, 토큰 텍스트, 끊기면 재개.
- 채팅과 협업 – 메시지가 낮은 지연 시간으로 양방향으로 흐르므로 웹소켓이 적합하며, 대개 관리형 계층을 거칩니다.
- 라이브 대시보드 – 쿼리 결과를 서버가 푸시합니다. 실제로는 직접 구현한 전송이 아니라 라이브 쿼리 구독입니다.
- 천천히 바뀌는 데이터 – 한 시간마다 갱신되는 재고, 설정 화면. 솔직히 폴링이나 포커스 시 재조회로 충분합니다.
어떤 전송 방식을 선택해야 할까? 결정 매트릭스
| 상황 | 선택 |
|---|---|
| 서버 → 클라이언트 전용(피드, 스트림, 진행 상황) | SSE |
| 클라이언트와 서버가 모두 실시간으로 보냄 | 웹소켓 |
| 업데이트가 몇 분에 한 번보다 드묾 | 숏 폴링 / 포커스 시 재조회 |
| 적대적인 프록시, 레거시 인프라 | 폴백으로 롱 폴링 |
| 바이너리 또는 고빈도 페이로드 | 웹소켓 |
| 서버리스 플랫폼, 함수 타임아웃 | 폴링, 또는 스트리밍을 지원하는 호스트에서 SSE |
| ”화면에 라이브 데이터만 띄우면 됨” | 전송을 대신 맡는 라이브 쿼리 계층 |
한계와 트레이드오프
- SSE는 텍스트 전용이고 단방향입니다. 바이너리는 인코딩해야 하고, 클라이언트에서 서버로 가는 통신은 별도의 HTTP 요청으로 보내야 합니다. 확인 응답에는 괜찮지만 채팅에는 맞지 않습니다.
- 웹소켓을 쓰면 그 위의 프로토콜을 직접 만들어야 합니다. 프레이밍, 확인 응답, 재연결, 재개, 백프레셔. 전송은 쉽고, 그 위의 계약이 진짜 일입니다.
- 폴링의 단순함 뒤에는 지연 시간의 하한이 숨어 있습니다. 어떤 튜닝으로도 주기/2의 평균 지연을 벗어날 수 없으며, 주기를 줄이면 헤더 비용만 다시 늘어날 뿐입니다.
- 롱 폴링은 대규모에서 양쪽의 단점만 모읍니다. 붙잡아 둔 요청은 푸시처럼 서버 용량을 소비하면서도, 풀처럼 메시지마다 재연결 오버헤드를 치릅니다. 그래서 폴백 단으로만 살아남습니다.
- 모든 푸시 전송은 프록시의 협조가 필요합니다. 스트림 버퍼링은 SSE를 조용히 망가뜨리고, 제거된 업그레이드 헤더는 웹소켓을 망가뜨립니다. localhost가 아니라 실제 인프라를 거쳐 테스트해야 합니다.
Back4app에서의 실시간 전송
Back4app은 관리형 데이터베이스, 자동 생성되는 REST 및 GraphQL API, 인증, 파일 스토리지, Cloud Code 서버리스 함수를 결합한 오픈소스 Backend as a Service(BaaS) 플랫폼입니다. 전송 방식 결정은 대부분 Live Query가 흡수합니다. 위의 코드 탭은 쿼리를 구독하고, 플랫폼이 운영하는 웹소켓 인프라를 통해 푸시된 이벤트를 받습니다. 연결, 재연결, 권한 확인, 서버 간 팬아웃이 모두 포함되므로 “SSE냐 웹소켓이냐”는 직접 구축하는 인프라가 아니라 물려받는 구현 세부 사항이 됩니다. 더 느슨한 주기가 정말 맞는 곳에서는 같은 쿼리를 원하는 일정에 맞춰 평범한 fetch로 실행하면 됩니다. Parse 쿼리를 폴링하는 것과 구독하는 것은 코드 한 줄 차이이므로, 기능별로 알맞은 전송 방식을 고르는 일은 재작성이 아니라 리팩터링입니다.
자주 묻는 질문
SSE와 웹소켓 중 어느 쪽이 더 낫습니까?
어느 쪽도 보편적으로 낫지는 않습니다. 관건은 방향입니다. 데이터가 서버에서 클라이언트로 한 방향으로만 흐른다면 SSE가 더 단순한 도구입니다. 평범한 HTTP이고, 자동 재연결이 있으며, 일반적인 프록시를 그대로 통과합니다. 웹소켓은 채팅, 게임, 공동 편집처럼 클라이언트도 실시간으로 보내야 할 때 추가된 복잡성만큼의 값을 합니다.
SSE는 폴링보다 빠릅니까?
전달 지연 시간에서는 확실히 빠릅니다. 이벤트는 발생하는 순간 도착하지만, 폴링은 평균적으로 폴링 주기의 절반에 왕복 시간을 더한 만큼 늦습니다. 비용도 더 적습니다. 대부분 비어 있으면서 매번 전체 HTTP 헤더 오버헤드를 치르는 요청/응답 주기의 연속 대신, 유지되는 연결 하나면 됩니다.
롱 폴링은 언제 사용해야 합니까?
첫 번째 선택이 아니라 폴백으로 사용해야 합니다. 롱 폴링은 레거시 중개 장비나 웹소켓 업그레이드를 제거하거나 스트림을 버퍼링하는 프록시처럼 지속적인 연결이 실패하는 환경을 위해 존재합니다. 서버가 데이터가 도착할 때까지 각 요청을 열어 두므로 푸시에 근접하지만, 그 대가로 재연결이 반복되고 요청마다 헤더 오버헤드가 붙습니다.
브라우저는 SSE 연결을 몇 개까지 열 수 있습니까?
HTTP/1.1에서는 출처(origin)당 6개이며, 이 한도는 탭 사이에서 공유됩니다. 대시보드를 탭 7개에 열어 두면 조용히 멈춰 버리는, 문서화된 함정입니다. HTTP/2에서는 제약이 연결 하나에 다중화되는 동시 스트림 수(기본값 약 100개)로 옮겨 가므로 사실상 문제가 사라집니다.
SSE는 자동으로 재연결됩니까?
그렇습니다. 원시 웹소켓과 가장 크게 구별되는 기능입니다. EventSource는 끊긴 연결을 스스로 재시도하고, 서버가 지정한 재시도 간격을 따르며, 마지막으로 받은 이벤트 ID를 Last-Event-ID 헤더로 보내 서버가 공백 없이 스트림을 재개할 수 있게 합니다. 웹소켓의 재연결은 직접 작성해야 하는 코드입니다.
SSE로 바이너리 데이터를 보낼 수 있습니까?
보낼 수 없습니다. 스트림은 명세상 UTF-8 텍스트이므로 바이너리 페이로드는 인코딩해야 하며, 크기가 약 3분의 1 늘어납니다. 웹소켓은 바이너리 프레임을 기본으로 실어 나르며, 이는 오디오, 프로토콜 버퍼, 이미 압축된 모든 데이터에 중요합니다.
AI 채팅 앱은 응답 스트리밍에 무엇을 사용합니까?
거의 예외 없이 Server-Sent Events를 사용합니다. 토큰 단위 생성은 단방향 텍스트 스트리밍이며, 이는 정확히 SSE의 형태입니다. 평범한 HTTP로 내보내고, 업그레이드 협상이 없고, 자동으로 재개됩니다. 웹소켓은 음성 인터페이스처럼 클라이언트가 스트림 도중에 끼어들거나 말해야 할 때만 AI 제품에 등장합니다.
실시간 기능의 폴백 순서는 어떻게 됩니까?
기능을 감지하고 단계적으로 낮춥니다. 경로가 지원하면 웹소켓, 서버 푸시만 필요하거나 업그레이드가 실패하면 SSE, 최소 공통분모로는 롱 폴링입니다. 성숙한 실시간 라이브러리는 이 사다리를 자동으로 협상하며, 프로덕션에서 원시 소켓을 그대로 쓰는 일이 드문 이유 중 하나입니다.