실시간 통신 패턴: WebSocket, SSE, Polling
TL;DR
- 용어 풀네임 어원/유래 Polling - 13세기 중세 영어 “poll(머리)” → “머리 세기” → “여론조사” → 컴퓨터 상태 조회.
- Long Polling - 2006년 Alex Russell의 “Comet” 명명과 함께 정착. 기존 polling이 “짧은(short)” 방식이라면, 서버가 연결을 “길게(long)” 붙잡는 방식이라는 직관적 명칭
- 원문 전체는 아래 상세 내용에 그대로 포함했다.
1. 개념
용어 풀네임 어원/유래 Polling - 13세기 중세 영어 “poll(머리)” → “머리 세기” → “여론조사” → 컴퓨터 상태 조회.
2. 배경
Polling - 13세기 중세 영어 “poll(머리)” → “머리 세기” → “여론조사” → 컴퓨터 상태 조회. 1957년 영국 면직 공장 수리공 순회 모델이 수학적 연구의 시초. 1968년 “polling system” 용어 공식 문헌 등장
3. 이유
Long Polling - 2006년 Alex Russell의 “Comet” 명명과 함께 정착. 기존 polling이 “짧은(short)” 방식이라면, 서버가 연결을 “길게(long)” 붙잡는 방식이라는 직관적 명칭
4. 특징
SSE Server-Sent Events “서버가 보내는 이벤트”. 2004년 Ian Hickson이 WHATWG 제안에 포함. 2006년 Opera 브라우저가 최초 구현. 클라이언트 API명 EventSource는 “이벤트의 출처”라는 의미
5. 상세 내용
실시간 통신 패턴: WebSocket, SSE, Polling
1. 실시간 통신이란?
┌─────────────────────────────────────────────────────────────────┐
│ 실시간 통신 = 서버가 먼저 말할 수 있는가? │
│ │
│ 정의: │
│ 서버에서 발생한 이벤트를 클라이언트에게 즉시(또는 거의 즉시) │
│ 전달하는 통신 방식. HTTP의 Request-Response 모델을 넘어서 │
│ 서버 주도(server-initiated) 데이터 전송을 가능하게 하는 것. │
│ │
│ 핵심 질문: │
│ ├── 클라이언트가 묻지 않아도 서버가 보낼 수 있는가? │
│ ├── 데이터 발생 즉시 전달되는가? (지연 최소화) │
│ └── 연결을 계속 유지할 수 있는가? (영속 연결) │
│ │
│ 활용 분야: │
│ ├── 실시간 채팅 (Slack, Discord) │
│ ├── 주식 시세 / 대시보드 (Bloomberg, Grafana) │
│ ├── 실시간 협업 (Google Docs, Figma) │
│ ├── 온라인 게임 (위치 동기화, 상태 업데이트) │
│ ├── 라이드 셰어링 (Uber 드라이버 위치 추적) │
│ └── AI 스트리밍 응답 (ChatGPT, Claude) │
│ │
└─────────────────────────────────────────────────────────────────┘
1.1 HTTP의 근본적 제약: Request-Response 모델
┌─────────────────────────────────────────────────────────────────┐
│ HTTP의 Request-Response 모델 │
│ │
│ [전통적 HTTP 통신] │
│ │
│ Client Server │
│ │ │ │
│ │──── GET /data ──────────────────► │ │
│ │ │ (처리) │
│ │ ◄──── 200 OK + data ─────────── │ │
│ │ │ │
│ │ (서버에 새 데이터 발생!) │ │
│ │ │ ← 서버가 보낼 방법 │
│ │ │ 없음! │
│ │ │ │
│ │ 클라이언트가 다시 물어야 함 │ │
│ │──── GET /data ──────────────────► │ │
│ │ ◄──── 200 OK + new data ──────── │ │
│ │ │ │
│ │
│ 근본 제약: │
│ ├── 클라이언트만 요청 가능 (서버는 먼저 보낼 수 없음) │
│ ├── 요청마다 헤더 오버헤드 (500~2,000 bytes) │
│ ├── HTTP/1.0: 요청마다 TCP 연결 새로 수립 │
│ └── 서버는 클라이언트의 존재를 "기억"하지 않음 (Stateless) │
│ │
└─────────────────────────────────────────────────────────────────┘
1.2 왜 실시간 통신이 어려운지
┌─────────────────────────────────────────────────────────────────┐
│ 실시간 통신의 3가지 근본 과제 │
│ │
│ 과제 1: 서버 → 클라이언트 푸시 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ HTTP는 "전화를 받는 것"만 가능하고 │ │
│ │ "전화를 거는 것"은 불가능한 구조. │ │
│ │ │ │
│ │ 서버: "새 메시지가 왔는데... 클라이언트에게 │ │
│ │ 어떻게 알리지? 전화번호(연결)가 없다!" │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 과제 2: 연결 유지 비용 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ TCP 연결 1개 = OS 자원(파일 디스크립터, 메모리) 소모 │ │
│ │ 10만 사용자 × 1 연결 = 10만 개의 열린 연결 유지 │ │
│ │ │ │
│ │ Thread-per-Connection: │ │
│ │ 10,000 연결 × 2MB/스레드 = 20GB 메모리 → 고갈! │ │
│ │ 이것이 바로 C10K Problem (1999년, Dan Kegel) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 과제 3: 중간 인프라 호환성 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 프록시, 방화벽, CDN, 로드밸런서가 중간에 개입 │ │
│ │ ├── 기업 방화벽: WebSocket Upgrade 차단 가능 │ │
│ │ ├── 프록시: 응답 버퍼링으로 SSE 이벤트 지연 │ │
│ │ └── CDN: 장기 연결(Long-lived connection) 비호환 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
2. 용어 사전 (Terminology Dictionary)
┌─────────────────────────────────────────────────────────────────┐
│ 실시간 통신 용어 사전 │
│ │
│ 모든 기술 용어의 풀네임, 어원, 유래를 정리한다. │
│ 용어의 뿌리를 아는 것이 기술의 본질을 이해하는 첫걸음이다. │
│ │
└─────────────────────────────────────────────────────────────────┘
| 용어 | 풀네임 | 어원/유래 |
|---|---|---|
| Polling | - | 13세기 중세 영어 “poll(머리)” → “머리 세기” → “여론조사” → 컴퓨터 상태 조회. 1957년 영국 면직 공장 수리공 순회 모델이 수학적 연구의 시초. 1968년 “polling system” 용어 공식 문헌 등장 |
| Long Polling | - | 2006년 Alex Russell의 “Comet” 명명과 함께 정착. 기존 polling이 “짧은(short)” 방식이라면, 서버가 연결을 “길게(long)” 붙잡는 방식이라는 직관적 명칭 |
| SSE | Server-Sent Events | “서버가 보내는 이벤트”. 2004년 Ian Hickson이 WHATWG 제안에 포함. 2006년 Opera 브라우저가 최초 구현. 클라이언트 API명 EventSource는 “이벤트의 출처”라는 의미 |
| WebSocket | WebSocket Protocol | “Web” + “Socket”. Socket은 Middle English “soket(창끝)”에서 유래. 1971년 RFC 147에서 ARPANET 문서에 “socket” 첫 등장. 1983년 Berkeley Sockets API 확립. 2008년 WHATWG에서 Ian Hickson이 “WebSocket” 명칭 제안. Michael Carter가 “SocketConnection” 제안한 것에 “Web” 접두사 추가 |
| Comet | - | 2006년 3월 Alex Russell 명명. Ajax(세제 브랜드)에 대한 말장난으로 다른 세제 브랜드 “Comet” 선택. 두문자어가 아닌 순수 언어 유희(wordplay) |
| STOMP | Simple (or Streaming) Text Oriented Messaging Protocol | S가 “Simple”이기도 “Streaming”이기도 한 의도적 이중성. 원래 이름 TTMP. 2000년대 초 Ruby/Python에서 엔터프라이즈 메시지 브로커 접속용으로 탄생 |
| Socket.IO | - | “Socket” + “.IO”. I/O(Input/Output)의 약어이자 기술 스타트업의 .io 도메인 트렌드(2010년 전후). 2010년 Guillermo Rauch가 LearnBoost에서 개발. “Sockets for the rest of us” |
| Engine.IO | - | Socket.IO의 하위 레이어. 연결 수립, transport 선택, heartbeat 담당 |
| Bayeux | Bayeux Protocol | 2008년 Dojo Foundation 산하 CometD 프로젝트. Named Channel 기반 Pub/Sub 메시징 |
| QUIC | Quick UDP Internet Connections | Google이 2012년 설계한 UDP 기반 전송 프로토콜. HTTP/3의 기반 |
| WebTransport | - | QUIC(HTTP/3) 위에서 동작하는 브라우저 API. 2019년 W3C WICG 제안. WebSocket의 후계자로 불림 |
3. 기술 진화 연대표 (Evolution Timeline)
┌─────────────────────────────────────────────────────────────────┐
│ │
│ 1991 HTTP/0.9 Tim Berners-Lee, GET만 지원 │
│ │ │
│ │ 1996 HTTP/1.0 연결-요청-응답-종료 반복 (RFC 1945) │
│ │ │ │
│ │ │ 1997 HTTP/1.1 Persistent Connection 기본값! │
│ │ │ │ Chunked Transfer Encoding 도입 │
│ │ │ │ → Long Polling, Streaming의 토대 │
│ │ │ │ │
│ │ │ │ 1999 XMLHttpRequest IE5에서 최초 등장 │
│ │ │ │ │ (Microsoft, ActiveX) │
│ │ │ │ │ │
│ │ │ │ │ 2000s초 Polling 패턴 XHR + setInterval │
│ │ │ │ │ │ │
│ │ │ │ │ │ 2004 SSE 초안 Ian Hickson/WHATWG │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ 2005 AJAX 명명 Jesse J.Garrett│
│ │ │ │ │ │ │ │ STOMP 초기 개발 (Codehaus) │
│ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ 2006 Comet 명명 Alex Russell │
│ │ │ │ │ │ │ │ │ Long Polling 정착 │
│ │ │ │ │ │ │ │ │ Opera: SSE 최초 구현 │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ 2008 WebSocket 제안 │
│ │ │ │ │ │ │ │ │ │ (Hickson + Carter) │
│ │ │ │ │ │ │ │ │ │ Bayeux Protocol │
│ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ 2009 Chrome 4: WS │
│ │ │ │ │ │ │ │ │ │ │ 최초 기본 지원 │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ 2010 Socket.IO │
│ │ │ │ │ │ │ │ │ │ │ │ (Rauch) │
│ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ 2011 RFC 6455 │
│ │ │ │ │ │ │ │ │ │ │ │ │ RFC 6202 │
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ 2012 │
│ │ │ │ │ │ │ │ │ │ │ │ │ STOMP 1.2 │
│ │ │ │ │ │ │ │ │ │ │ │ │ SSE W3C │
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ 2015 │
│ │ │ │ │ │ │ │ │ │ │ │ │ HTTP/2 │
│ │ │ │ │ │ │ │ │ │ │ │ │ (RFC 7540) │
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ 2018 │
│ │ │ │ │ │ │ │ │ │ │ │ │ RFC 8441 │
│ │ │ │ │ │ │ │ │ │ │ │ │ (WS/HTTP2) │
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ 2019 │
│ │ │ │ │ │ │ │ │ │ │ │ │ WebTransp. │
│ │ │ │ │ │ │ │ │ │ │ │ │ 제안 │
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ 2022 │
│ │ │ │ │ │ │ │ │ │ │ │ │ RFC 9220 │
│ │ │ │ │ │ │ │ │ │ │ │ │ Chrome 97: │
│ │ │ │ │ │ │ │ │ │ │ │ │ WebTransp. │
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ 2026 │
│ 현재 │
│ │
└─────────────────────────────────────────────────────────────────┘
주요 시점 상세
| 연도 | 사건 | 의미 |
|---|---|---|
| 1991 | HTTP/0.9 (Tim Berners-Lee, CERN) | GET만 지원, 헤더/상태코드 없음. “실시간” 개념 자체 부재 |
| 1996 | HTTP/1.0 (RFC 1945) | 요청마다 새 TCP 연결. 20개 리소스 = 20번 3-way handshake |
| 1997 | HTTP/1.1 (RFC 2068, 이후 2616) | Persistent Connection 기본값, Chunked Transfer Encoding 도입 |
| 1999 | XMLHttpRequest (Microsoft IE5) | 페이지 새로고침 없는 비동기 통신의 시작 |
| 2004 | SSE 초안 (Ian Hickson, WHATWG) | 서버→클라이언트 단방향 스트리밍 표준화 시도 |
| 2005 | Ajax 명명 (Jesse James Garrett) | 비동기 웹 통신의 폭발적 주목 |
| 2006 | Comet 명명 (Alex Russell), Opera SSE 구현 | 서버 푸시 기법 체계화, SSE 첫 구현 |
| 2008 | WebSocket 제안 (Carter + Hickson) | HTTP를 벗어난 양방향 통신 프로토콜 |
| 2009 | Chrome 4: WebSocket 최초 기본 지원 | 실시간 웹의 새 시대 개막 |
| 2010 | Socket.IO 출시 (Guillermo Rauch) | WebSocket + fallback으로 실시간 개발 단순화 |
| 2011 | RFC 6455 (WebSocket 표준), RFC 6202 (Long Polling 정리) | WebSocket 국제 표준화, Comet 시대 공식 마무리 |
| 2015 | HTTP/2 (RFC 7540) | 멀티플렉싱으로 연결 수 문제 완화, Server Push 도입(후에 실패) |
| 2018 | RFC 8441: WebSocket over HTTP/2 | HTTP/2에서 WebSocket 연결 공유 가능 |
| 2022 | RFC 9220: WebSocket over HTTP/3, Chrome 97 WebTransport | QUIC 기반 미래 실시간 통신의 서막 |
| 2026 | 현재 | WebSocket=양방향 표준, SSE=AI 스트리밍 부활, WebTransport=차세대 후보 |
4. 핵심 기술 상세
4.1 Short Polling
┌─────────────────────────────────────────────────────────────────┐
│ Short Polling (단순 폴링) │
│ │
│ Client Server │
│ │ │ │
│ │──── GET /updates ───────────────► │ │
│ │ ◄──── 200 OK (빈 응답) ───────── │ │
│ │ (1초 대기) │ │
│ │──── GET /updates ───────────────► │ │
│ │ ◄──── 200 OK (빈 응답) ───────── │ │
│ │ (1초 대기) │ │
│ │──── GET /updates ───────────────► │ │
│ │ ◄──── 200 OK (데이터!) ────────── │ ← 드디어 데이터! │
│ │ (1초 대기) │ │
│ │──── GET /updates ───────────────► │ │
│ │ ◄──── 200 OK (빈 응답) ───────── │ ← 다시 빈 응답... │
│ │ │ │
│ │
│ 특성: │
│ ├── 구현: 극히 단순 (setInterval + fetch) │
│ ├── 문제: 대부분 빈 응답 = 대역폭 낭비 │
│ ├── 지연: 최대 1 polling interval │
│ └── 서버 부하: 클라이언트 수 × 요청 빈도 │
│ │
│ 1,000명 × 1초 간격 = 초당 1,000 요청 │
│ 1,000명 × 100ms 간격 = 초당 10,000 요청 → 서버 과부하! │
│ │
└─────────────────────────────────────────────────────────────────┘
4.2 Long Polling
┌─────────────────────────────────────────────────────────────────┐
│ Long Polling (롱 폴링) │
│ │
│ Client Server │
│ │ │ │
│ │──── GET /poll ──────────────────► │ │
│ │ │ (hold... 대기중...) │
│ │ │ (최대 30초) │
│ │ │ (이벤트 발생!) │
│ │ ◄──── 200 OK (데이터) ────────── │ │
│ │ │ │
│ │──── GET /poll (즉시 재요청) ────► │ ← 즉시 새 연결 │
│ │ │ (hold... 대기중...) │
│ │ │ (30초 timeout) │
│ │ ◄──── 204 No Content ─────────── │ ← 타임아웃 │
│ │ │ │
│ │──── GET /poll (즉시 재요청) ────► │ │
│ │ │ │
│ │
│ Short Polling 대비 개선점: │
│ ├── 빈 응답 거의 없음 (데이터 있을 때만 응답) │
│ ├── 데이터 도착 즉시 전달 (지연 최소화) │
│ └── 불필요한 요청 감소 │
│ │
│ 남아있는 한계: │
│ ├── 매 응답마다 새 HTTP 연결 (헤더 오버헤드 반복) │
│ ├── 서버: 열린 연결 대량 유지 (스레드/메모리) │
│ └── Timeout 30초: 프록시/방화벽 idle 연결 강제 종료 방지용 │
│ │
│ RFC 6202: Long Polling 평균 지연 = 1 network transit │
│ 최대 지연 = 3 network transit │
│ │
└─────────────────────────────────────────────────────────────────┘
4.3 SSE (Server-Sent Events)
┌─────────────────────────────────────────────────────────────────┐
│ SSE = 표준화된 서버 → 클라이언트 스트리밍 │
│ │
│ Client Server │
│ │ │ │
│ │──── GET /events ───────────────► │ │
│ │ Accept: text/event-stream │ │
│ │ │ │
│ │ ◄──── 200 OK ────────────────── │ │
│ │ Content-Type: │ │
│ │ text/event-stream │ │
│ │ │ │
│ │ ◄──── event: update ──────────── │ ← 이벤트 1 │
│ │ data: {"price": 150} │ │
│ │ id: 1001 │ │
│ │ │ │
│ │ ◄──── event: update ──────────── │ ← 이벤트 2 │
│ │ data: {"price": 151} │ │
│ │ id: 1002 │ │
│ │ │ │
│ │ (연결 끊김!) │ │
│ │ │ │
│ │──── GET /events ───────────────► │ ← 자동 재연결! │
│ │ Last-Event-ID: 1002 │ ← 마지막 ID 전송 │
│ │ │ │
│ │ ◄──── id: 1003 ─────────────── │ ← 1002 이후부터 │
│ │ data: {"price": 152} │ 재전송 │
│ │ │ │
│ │
└─────────────────────────────────────────────────────────────────┘
text/event-stream 프로토콜 형식
┌─────────────────────────────────────────────────────────────────┐
│ SSE 이벤트 형식 (각 이벤트는 빈 줄 \n\n 으로 구분) │
│ │
│ event: userJoined\n │
│ data: {"userId": "abc123", "name": "Alice"}\n │
│ id: 42\n │
│ retry: 5000\n │
│ \n │
│ │
│ 필드 상세: │
│ ┌─────────┬────────────────────────────────────────────────┐ │
│ │ event: │ 이벤트 타입. 생략 시 "message" 이벤트 │ │
│ ├─────────┼────────────────────────────────────────────────┤ │
│ │ data: │ 실제 데이터. 여러 줄이면 각 줄마다 data: 반복 │ │
│ ├─────────┼────────────────────────────────────────────────┤ │
│ │ id: │ 이벤트 ID. 재연결 시 Last-Event-ID 헤더로 전송 │ │
│ ├─────────┼────────────────────────────────────────────────┤ │
│ │ retry: │ 재연결 대기 시간 (밀리초). 브라우저 자동 적용 │ │
│ ├─────────┼────────────────────────────────────────────────┤ │
│ │ : (주석)│ 콜론으로 시작. 클라이언트 무시. heartbeat 활용 │ │
│ └─────────┴────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
EventSource API
// 클라이언트 JavaScript
const eventSource = new EventSource('/events');
eventSource.onopen = () => console.log('연결됨');
eventSource.onmessage = (event) => {
console.log('기본 메시지:', event.data);
};
eventSource.addEventListener('userJoined', (event) => {
const user = JSON.parse(event.data);
console.log('사용자 입장:', user.name);
});
eventSource.onerror = (error) => {
// 브라우저가 자동 재연결 시도 (retry 간격 적용)
console.log('연결 오류, 자동 재연결 대기중...');
};
4.4 WebSocket
HTTP Upgrade 핸드셰이크 상세
┌─────────────────────────────────────────────────────────────────┐
│ WebSocket 핸드셰이크 과정 (RFC 6455) │
│ │
│ 1. Client → Server (HTTP/1.1 GET): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ GET /chat HTTP/1.1 │ │
│ │ Host: server.example.com │ │
│ │ Upgrade: websocket │ │
│ │ Connection: Upgrade │ │
│ │ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== │ │
│ │ Sec-WebSocket-Version: 13 │ │
│ │ │ │
│ │ ↑ Sec-WebSocket-Key: 무작위 16바이트 Base64 인코딩 │ │
│ │ ↑ Version 13: RFC 6455의 최종 버전 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 2. Server → Client (101 Switching Protocols): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ HTTP/1.1 101 Switching Protocols │ │
│ │ Upgrade: websocket │ │
│ │ Connection: Upgrade │ │
│ │ Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= │ │
│ │ │ │
│ │ ↑ Accept 계산: │ │
│ │ Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" │ │
│ │ → SHA-1 해시 → Base64 인코딩 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 3. 이후: HTTP 종료, WebSocket 바이너리 프레임 프로토콜로 전환 │
│ → 양방향(full-duplex) 통신 시작 │
│ │
└─────────────────────────────────────────────────────────────────┘
WebSocket 프레임 구조
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - -+
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - -+-------------------------------+
| Masking-key, if MASK set (32 bits) |
+-------------------------------+-------------------------------+
| Payload Data (masked if MASK set) |
+-----------+---------------------------------------------------+
| 필드 | 크기 | 설명 |
|---|---|---|
| FIN | 1bit | 마지막 fragment이면 1. fragmented 메시지의 중간 프레임은 0 |
| RSV1-3 | 3bits | 예약 비트. 확장용 (예: permessage-deflate 압축 시 RSV1 사용) |
| opcode | 4bits | 0x0: continuation, 0x1: text, 0x2: binary, 0x8: close, 0x9: ping, 0xA: pong |
| MASK | 1bit | 클라이언트 → 서버 방향은 항상 1 (RFC 6455 강제) |
| Payload len | 7bits | 0-125: 그 자체가 길이. 126: 다음 2바이트. 127: 다음 8바이트 |
| Masking-key | 32bits | MASK=1일 때만 존재. 클라이언트가 생성하는 랜덤 키 |
| Payload | 가변 | 실제 데이터. 클라이언트 발신 시 XOR masking 적용 |
Masking의 이유: 중간 프록시의 cache poisoning 공격 방지. 클라이언트가 보내는 모든 프레임은 반드시 masking해야 한다.
ping/pong: opcode 0x9(ping)를 받으면 반드시 동일 payload로 0xA(pong) 응답. keepalive 용도.
close 핸드셰이크: opcode 0x8(close) 프레임을 보내면, 상대방도 close 프레임으로 응답한 후 TCP 연결 종료.
4.5 HTTP/2 Server Push (왜 실시간용이 아닌지)
┌─────────────────────────────────────────────────────────────────┐
│ HTTP/2 Server Push ≠ 실시간 통신 │
│ │
│ Client Server │
│ │ │ │
│ │──── GET /page.html ─────────────► │ │
│ │ ◄──── PUSH_PROMISE (css) ─────── │ ← CSS 미리 예고 │
│ │ ◄──── PUSH_PROMISE (js) ──────── │ ← JS 미리 예고 │
│ │ ◄──── 200 OK (page.html) ────── │ │
│ │ ◄──── Pushed CSS response ───── │ │
│ │ ◄──── Pushed JS response ────── │ │
│ │ │ │
│ │
│ 실시간 통신용으로 부적합한 이유: │
│ ├── 목적: "예측적 정적 리소스 전달" (실시간 이벤트 X) │
│ ├── 클라이언트가 어차피 요청했을 리소스를 미리 보내는 것 │
│ ├── 동적 실시간 이벤트 스트리밍에 사용 불가 │
│ ├── 서버가 클라이언트 캐시 상태를 모름 → 대역폭 낭비 │
│ └── Chrome은 2022년에 Server Push 지원 제거! │
│ │
│ 결론: HTTP/2 Server Push는 실시간 통신 기술이 아니다. │
│ │
└─────────────────────────────────────────────────────────────────┘
4.6 gRPC Streaming (4가지 모드)
┌─────────────────────────────────────────────────────────────────┐
│ gRPC Streaming = HTTP/2 + Protocol Buffers │
│ │
│ 4가지 모드: │
│ │
│ 1. Unary RPC (일반 요청-응답) │
│ Client ─── 1 request ──► Server │
│ Client ◄── 1 response ── Server │
│ │
│ 2. Server Streaming RPC │
│ Client ─── 1 request ──────────► Server │
│ Client ◄── stream(response 1) ── Server │
│ Client ◄── stream(response 2) ── Server │
│ Client ◄── stream(response N) ── Server │
│ │
│ 3. Client Streaming RPC │
│ Client ─── stream(request 1) ──► Server │
│ Client ─── stream(request 2) ──► Server │
│ Client ─── stream(request N) ──► Server │
│ Client ◄── 1 response ────────── Server │
│ │
│ 4. Bidirectional Streaming RPC │
│ Client ─── stream(msg1) ──────► Server │
│ Client ◄── stream(reply1) ───── Server │
│ Client ─── stream(msg2) ──────► Server │
│ Client ◄── stream(reply2) ───── Server │
│ (양방향 독립 스트림, 순서 무관) │
│ │
│ 적합: 마이크로서비스 간 통신, 타입 안전 스트리밍 │
│ 한계: 브라우저 직접 사용 제한 (grpc-web 필요) │
│ │
└─────────────────────────────────────────────────────────────────┘
// Server Streaming 예시
rpc ListEvents (EventRequest) returns (stream EventResponse);
// Bidirectional Streaming 예시
rpc Chat (stream ChatMessage) returns (stream ChatMessage);
4.7 WebTransport (QUIC 기반, WebSocket 후계자)
┌─────────────────────────────────────────────────────────────────┐
│ WebTransport = WebSocket의 미래 │
│ │
│ WebSocket vs WebTransport 비교: │
│ │
│ ┌──────────────────┬──────────────┬──────────────────┐ │
│ │ 비교 항목 │ WebSocket │ WebTransport │ │
│ ├──────────────────┼──────────────┼──────────────────┤ │
│ │ 기반 프로토콜 │ TCP │ QUIC (UDP 기반) │ │
│ │ 스트림 │ 단일 스트림 │ 다중 독립 스트림 │ │
│ │ HOL blocking │ 있음 │ 없음 │ │
│ │ 데이터 전달 │ reliable만 │ reliable + │ │
│ │ │ │ unreliable │ │
│ │ 네트워크 전환 │ 연결 끊김 │ 연결 유지 │ │
│ │ 연결 수립 속도 │ TCP 3-way │ QUIC 0-RTT │ │
│ │ 브라우저 지원 │ 100% │ ~75% (2026) │ │
│ └──────────────────┴──────────────┴──────────────────┘ │
│ │
│ Unreliable datagram의 의미: │
│ 게임 등에서 최신 위치만 중요하고 지연된 패킷은 무의미. │
│ → 같은 연결에서 reliable + unreliable 동시 사용 가능 │
│ │
│ 현재 상태 (2026): │
│ ├── Chrome/Edge: 지원 │
│ ├── Firefox: 구현 중 │
│ ├── Safari: 미지원 │
│ └── 서버 인프라 생태계: 미성숙 (프로덕션 2-3년 후 전망) │
│ │
└─────────────────────────────────────────────────────────────────┘
4.8 GraphQL Subscriptions
┌─────────────────────────────────────────────────────────────────┐
│ GraphQL Subscriptions = 선언적 실시간 데이터 구독 │
│ │
│ Client Server │
│ │──WS Upgrade + graphql-ws──────► │ │
│ │──{"type":"connection_init"}────► │ │
│ │ ◄──{"type":"connection_ack"}──── │ │
│ │──{"type":"subscribe", │ │
│ │ "query":"subscription{…}"}───► │ │
│ │ ◄──{"type":"next","data":{…}}── │ ← 이벤트 발생 시마다 │
│ │ ◄──{"type":"next","data":{…}}── │ │
│ │ │ │
│ │
│ 전송 계층: WebSocket이 일반적이나 SSE도 가능 │
│ (GraphQL Subscriptions는 대부분 단방향이므로 SSE로 충분) │
│ │
└─────────────────────────────────────────────────────────────────┘
4.9 Socket.IO (Engine.IO + Fallback + Room/Namespace)
┌─────────────────────────────────────────────────────────────────┐
│ Socket.IO 아키텍처 │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Socket.IO (상위 레이어) │ │
│ │ 이벤트 emit/handle, 자동 재연결, 패킷 버퍼링, │ │
│ │ 멀티플렉싱, Namespace, Room │ │
│ ├───────────────────────────────────────────────────┤ │
│ │ Engine.IO (하위 레이어) │ │
│ │ 연결 수립, transport 선택/upgrade, heartbeat, │ │
│ │ 연결 상태 관리 │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ Fallback 메커니즘: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 1단계: HTTP Long-Polling으로 즉시 연결 수립 │ │
│ │ (WebSocket 시도 전 안정적 연결 보장) │ │
│ │ │ │
│ │ 2단계: Background에서 WebSocket 업그레이드 시도 │ │
│ │ ├── 성공: Long-Polling 종료, WebSocket으로 전환 │ │
│ │ └── 실패: Long-Polling 유지 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Namespace: 단일 연결 위에 논리적 채널 분리 │
│ /admin, /chat, /monitoring → 각각 별도 미들웨어 적용 가능 │
│ │
│ Room: 순수 서버 측 그룹화 메커니즘 │
│ io.to('game-room-42').emit('gameUpdate', data); │
│ 클라이언트는 자신이 어느 room에 속하는지 모름 │
│ │
└─────────────────────────────────────────────────────────────────┘
4.10 STOMP 프로토콜
┌─────────────────────────────────────────────────────────────────┐
│ STOMP 프레임 기본 구조 │
│ │
│ COMMAND\n │
│ header1:value1\n │
│ header2:value2\n │
│ \n ← 빈 줄이 헤더와 바디 구분 │
│ Body content here^@ ← ^@ = NULL 바이트(0x00)로 종료 │
│ │
│ 주요 클라이언트 명령어: 주요 서버 명령어: │
│ ├── CONNECT / STOMP ├── CONNECTED │
│ ├── SUBSCRIBE ├── MESSAGE │
│ ├── SEND ├── RECEIPT │
│ ├── UNSUBSCRIBE └── ERROR │
│ ├── ACK / NACK │
│ ├── BEGIN / COMMIT / ABORT │
│ └── DISCONNECT │
│ │
│ Spring Framework에서의 STOMP over WebSocket: │
│ │
│ 클라이언트 → WebSocket → STOMP 프레임 → Spring Message Broker │
│ │ │
│ /app/** : @MessageMapping │
│ /topic/** : SimpleBroker │
│ /user/** : 개인 전송 │
│ │
└─────────────────────────────────────────────────────────────────┘
5. 학술적 기반 (Academic Foundation)
5.1 RFC 및 공식 스펙
| RFC/스펙 | 제목 | 발행일 | 상태 | 핵심 내용 |
|---|---|---|---|---|
| RFC 6455 | The WebSocket Protocol | 2011.12 | Standards Track | TCP 위 양방향 통신, HTTP Upgrade 핸드셰이크, 프레임 마스킹 의무화, ws:/wss:// URI 스킴 |
| RFC 6202 | Known Issues and Best Practices for Long Polling and Streaming | 2011.04 | Informational | Long Polling/Streaming 알려진 문제, 프록시 간섭, 파이프라이닝 충돌, 연결 수 제한 |
| RFC 8441 | Bootstrapping WebSockets with HTTP/2 | 2018.09 | Standards Track | HTTP/2 단일 스트림 위 WebSocket, Extended CONNECT :protocol 헤더 |
| RFC 9220 | Bootstrapping WebSockets with HTTP/3 | 2022.06 | Standards Track | HTTP/3(QUIC) 위 WebSocket. 2026년 기준 브라우저 실구현 전무 |
| WHATWG SSE | Server-Sent Events (HTML Living Standard) | 2004~ | Living Standard | EventSource API, text/event-stream MIME, 자동 재연결, Last-Event-ID |
| STOMP 1.2 | Streaming Text Oriented Messaging Protocol | 2012.10 | - | 텍스트 기반 프레임, HTTP 영감 설계, WebSocket 위 메시징 브로커 의미론 |
5.2 관련 논문/기술 문서
- Bayeux Protocol (CometD, 2008): Dojo Foundation. Comet 클라이언트-서버 표준 통신 프로토콜. JSON 기반 Pub/Sub, Named Channel, Transport 독립적
- “Is the Web ready for HTTP/2 Server Push?” (arXiv, 2018): HTTP/2 Server Push 실제 성능 측정. 캐시 문제로 실용성 낮음을 실증
- RxDB 기술 비교: WebSocket 연결 후 2바이트 오버헤드로 가장 낮음. SSE 약 5바이트. Long Polling이 가장 높음
- RFC 2616 (1999) → RFC 7230 (2014): HTTP/1.1 Persistent Connection이 Long Polling/Streaming의 기술적 토대. 도메인당 2개 연결 권고 → 2008년 Firefox 3이 6개로 상향
6. 대안 비교표 (Comparison Table)
6.1 핵심 특성 비교
| 특성 | Short Polling | Long Polling | SSE | WebSocket | WebTransport | gRPC Streaming |
|---|---|---|---|---|---|---|
| 방향성 | Client Pull | Client Pull | Server→Client | 양방향 | 양방향+다중 스트림 | 양방향 |
| 프로토콜 | HTTP | HTTP | HTTP | WS/WSS (TCP) | QUIC (UDP) | HTTP/2 |
| 연결 방식 | 반복 요청 | 지연 응답 | 지속 연결 | 지속 연결 | 지속 연결 | 지속 연결 |
| 지연시간 | 매우 높음 | 높음 | 낮음 (~3-6ms) | 가장 낮음 (~1-3ms) | 가장 낮음 | 낮음 |
| 헤더 오버헤드 | 500-2000B/요청 | 500-2000B/요청 | 최초 1회 | 프레임당 2-6B | 프레임당 최소 | Protobuf (바이너리) |
| 자동 재연결 | 수동 | 수동 | 브라우저 내장 | 수동 | 수동 | 수동 |
| 바이너리 지원 | HTTP body | HTTP body | 불가 (텍스트만) | 지원 | 지원 | Protobuf |
| HTTP/2 다중화 | 가능 | 가능 | 가능 | 불가 (RFC 8441로 가능) | N/A (HTTP/3) | 기본 |
| 방화벽 호환 | 매우 좋음 | 매우 좋음 | 좋음 | 주의 필요 | 주의 필요 | 주의 필요 |
| Sticky Session | 불필요 | 불필요 | 불필요 | 필요 | 필요 | 가능 |
| 구현 복잡도 | 매우 낮음 | 낮음 | 낮음 | 중간 | 높음 | 중간 |
| 브라우저 지원 | 100% | 100% | 모든 현대 | 모든 현대 | ~75% | grpc-web 필요 |
| 프로덕션 성숙도 | 높음 | 높음 | 높음 | 높음 | 낮음 | 높음 |
6.2 성능 벤치마크 수치
지연시간 (Latency)
| 방식 | 일반적 지연시간 | 비고 |
|---|---|---|
| WebSocket | ~1-3ms | 연결 수립 후, BTC 시세 기준 ~0.5ms |
| SSE | ~3-6ms | WebSocket 대비 소폭 높음 |
| Long Polling | 100-200ms | 새 연결 수립 비용 누적 |
| Short Polling | 폴링 주기 + 처리시간 | 최소 수백 ms |
SSE와 WebSocket의 순수 지연시간 차이는 약 3ms로, 100,000 events/sec 규모에서도 실질적 차이 미미.
네트워크 오버헤드
- Socket.IO 측정: HTTP 요청/응답 1쌍 = 282 bytes, WebSocket 동일 데이터 = 54 bytes
- 50회 반복 시 WebSocket이 HTTP 대비 약 50% 빠름
- 단일 요청만일 경우 HTTP가 약 50% 빠름 (WebSocket 연결 수립 비용)
동시 연결 규모
┌─────────────────────────────────────────────────────────────────┐
│ 동시 연결 규모별 서버 요구사항 │
│ │
│ C10K (1만 동시 연결) │
│ ├── WebSocket: 연결당 ~3.2 KB (커널 소켓), CPU 효율적 │
│ ├── Long Polling: 스레드 모델에서 고메모리 │
│ └── 해결: Nginx, Node.js 등 이벤트 드리븐 서버 │
│ │
│ C100K (10만 동시 연결) │
│ ├── Nginx/Node.js + 커널 튜닝으로 단일 노드 240,000 연결 │
│ ├── 지연시간 50ms 이하 유지 가능 (Ably 벤치마크) │
│ └── 해결: OS 커널 튜닝 + 비동기 I/O │
│ │
│ C10M (1000만 동시 연결) │
│ ├── MigratoryData 실증 (Dell R610, 12코어, 96GB RAM): │
│ │ ├── 10,000,108 동시 연결 │
│ │ ├── 커널 소켓당 ~3.2 KB → 전체 ~32 GB │
│ │ ├── JVM 힙: 54 GB │
│ │ ├── CPU: 50% 미만 │
│ │ ├── 처리량: 168,000 msg/sec (~10M msg/min) │
│ │ ├── 중앙값 지연: 18ms │
│ │ └── 99th percentile: 585ms │
│ └── WhatsApp: 24코어 Erlang/FreeBSD에서 200만 동시 연결 │
│ │
└─────────────────────────────────────────────────────────────────┘
7. 상황별 최적 선택 가이드
┌─────────────────────────────────────────────────────────────────┐
│ 의사결정 플로우차트 │
│ │
│ 실시간 통신이 필요한가? │
│ │ │
│ ├── YES: 서버→클라이언트 단방향인가? │
│ │ │ │
│ │ ├── YES → SSE 우선 고려 │
│ │ │ ├── 알림, 대시보드, 피드 → SSE │
│ │ │ ├── AI 스트리밍 응답 → SSE │
│ │ │ ├── 기업 방화벽 환경 → SSE (HTTP 기반) │
│ │ │ └── HTTP/2 + 확장성 → SSE │
│ │ │ │
│ │ └── NO (양방향 필요): │
│ │ │ │
│ │ ├── 웹 브라우저 클라이언트? │
│ │ │ ├── 채팅, 게임, 실시간 협업 → WebSocket │
│ │ │ └── 멀티스트림 + 저지연 (미래) → WebTransport │
│ │ │ │
│ │ └── 서버 간 통신? │
│ │ ├── 타입 안전성 + 고성능 → gRPC Streaming │
│ │ └── IoT 디바이스 간 → MQTT │
│ │ │
│ └── NO (업데이트 드문 경우): │
│ └── Short Polling 또는 일반 HTTP │
│ │
└─────────────────────────────────────────────────────────────────┘
시나리오별 권장 기술
| 시나리오 | 권장 기술 | 이유 |
|---|---|---|
| 채팅 | WebSocket | 양방향 필수, 동일 연결에서 송수신, 지연 최소화 |
| 대시보드/알림 | SSE | 단방향 충분, 자동 재연결 내장, HTTP 호환, 구현 단순 |
| 온라인 게임 | WebSocket (현재) / WebTransport (미래) | 양방향 + 저지연. WebTransport는 HOL blocking 없음 |
| IoT | MQTT | 최소 헤더(2B), QoS 레벨 선택, 불안정 네트워크에 강함 |
| 마이크로서비스 간 | gRPC Streaming | Protobuf 바이너리, 타입 안전, HTTP/2 멀티플렉싱 |
| AI 스트리밍 응답 | SSE | ChatGPT/Claude 등에서 광범위 사용, 단방향 충분 |
| 주식 시세 | SSE 또는 WebSocket | 단방향이면 SSE, 사용자 인터랙션 있으면 WebSocket |
| 실시간 협업 (Figma, Docs) | WebSocket | 양방향 동시 편집, OT/CRDT 알고리즘 동기화 |
8. 실제 기업 사용 사례
┌─────────────────────────────────────────────────────────────────┐
│ 실제 기업 사용 사례 │
│ │
│ Slack │
│ ├── 피크 시간대 500만+ 동시 WebSocket 세션 │
│ ├── Gateway Server(Java): stateful, 인메모리 사용자 정보 │
│ └── Channel Server: 메시지 브로드캐스트 및 순서 조정 │
│ │
│ Discord │
│ ├── Gateway WebSocket 연결 필수 (유일한 실시간 경로) │
│ ├── ETF(Erlang 바이너리 포맷) 인코딩 옵션 │
│ └── heartbeat로 연결 유지 │
│ │
│ Twitter/X │
│ ├── Streaming API에 SSE 사용 (외부 개발자 대상) │
│ └── 타임라인 업데이트 server push (단방향 → SSE 적합) │
│ │
│ Facebook/Meta │
│ ├── 초기: Long Polling 기반 채팅 │
│ ├── 전환: Firefox Messenger에서 WebSocket 대규모 적용 │
│ └── 현재: 웹=WebSocket, 모바일=MQTT (대역폭 최소화) │
│ │
│ Google Docs │
│ ├── WebSocket 기반 실시간 협업 │
│ └── OT(Operational Transformation) 알고리즘으로 충돌 해결 │
│ │
│ Figma │
│ ├── WebSocket multiplayer 아키텍처 │
│ ├── 객체 속성별 최신값 추적 (Last Write Wins) │
│ └── 커서 위치 등 ephemeral 데이터도 WebSocket 브로드캐스트 │
│ │
│ Uber │
│ ├── Node.js WebSocket 서버로 드라이버 GPS 수신 │
│ ├── → Kafka 위치 큐 → Redis 지리공간 인덱스 │
│ └── 라이드 오퍼도 WebSocket으로 드라이버에게 push │
│ │
│ Netflix │
│ ├── Hystrix 모니터링 대시보드에 SSE 활용 │
│ └── 마이크로서비스 성능 데이터 실시간 push (단방향 → SSE) │
│ │
└─────────────────────────────────────────────────────────────────┘
9. 인프라 관점
9.1 로드밸런서 (L4/L7, Sticky Session)
| 방식 | L4 LB (NLB) | L7 LB (ALB) | Sticky Session |
|---|---|---|---|
| Polling | 불필요 | 가능 | 불필요 |
| SSE | 가능 | 주의 (응답 버퍼링) | 불필요 |
| WebSocket | 가능 (권장) | 가능 (Upgrade 헤더 처리) | 필요 (연결 지속) |
WebSocket 핵심: 연결이 stateful이므로 한번 연결된 클라이언트는 동일 서버 인스턴스에 유지되어야 한다.
9.2 Nginx / HAProxy 설정
Nginx WebSocket 설정
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s; # 긴 타임아웃 필수
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
Nginx SSE 설정 (가장 흔한 함정!)
location /events {
proxy_pass http://backend:8080;
# 핵심! 누락 시 이벤트가 버퍼에 묶임
proxy_buffering off;
proxy_cache off;
# HTTP/1.1 유지 (chunked transfer 지원)
proxy_http_version 1.1;
proxy_set_header Connection '';
chunked_transfer_encoding off;
# 타임아웃: 기본 60초는 SSE에 너무 짧음
proxy_read_timeout 86400s; # 24시간
proxy_send_timeout 86400s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
| 설정 | 기본값 | SSE 필요값 | 이유 |
|---|---|---|---|
proxy_buffering |
on | off | 이벤트 즉시 전달 |
proxy_read_timeout |
60s | 86400s | 장기 연결 유지 |
proxy_http_version |
1.0 | 1.1 | Chunked transfer |
gzip |
on | off | 스트리밍 압축 충돌 |
HAProxy WebSocket 설정
frontend ws_frontend
acl is_websocket hdr(Upgrade) -i WebSocket
use_backend ws_backend if is_websocket
backend ws_backend
balance leastconn
timeout tunnel 1h # 핵심: tunnel 타임아웃
cookie SERVERID insert indirect nocache
timeout tunnel이 가장 중요. 기본 HTTP 타임아웃이 적용되면 WebSocket 연결이 예기치 않게 끊긴다.
9.3 클라우드 환경
| 서비스 | WebSocket | SSE | 비고 |
|---|---|---|---|
| AWS ALB | 네이티브 지원 | 지원 | Idle timeout 기본 60초 (조정 필요) |
| AWS NLB | 지원 (L4) | 지원 | TCP 레벨 처리 |
| AWS API Gateway | WebSocket API 별도 | HTTP API 통해 지원 | $connect/$disconnect 라우트 |
| Cloudflare | 2014년부터 지원 | Workers에서 지원 | Durable Objects로 상태 관리 |
9.4 Kubernetes Ingress
# AWS ALB Ingress Controller 기준
annotations:
kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/healthcheck-path: /health
# 주의: WebSocket(ws://) 경로를 헬스체크하면 실패!
# 반드시 HTTP 헬스체크 경로를 별도 지정
10. 서버 구현 아키텍처
10.1 Thread-per-Connection vs Event-Driven
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Thread-per-Connection 모델: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 연결마다 하나의 OS 스레드 할당 │ │
│ │ ├── 각 스레드: 1-2MB 메모리 │ │
│ │ ├── 10,000 연결 = 10,000 스레드 │ │
│ │ ├── → Context Switching 오버헤드 │ │
│ │ ├── → 메모리 고갈 (20GB!) │ │
│ │ └── = C10K Problem의 근본 원인 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Event-Driven 모델 (epoll/kqueue): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ epoll (Linux): │ │
│ │ ├── epoll_create(): 인스턴스 생성 │ │
│ │ ├── epoll_ctl(): 관심 fd 등록/수정/삭제 │ │
│ │ ├── epoll_wait(): 이벤트 발생 fd만 반환 │ │
│ │ └── 비용이 활성 이벤트 수에 비례 (총 연결 수 X) │ │
│ │ │ │
│ │ kqueue (BSD/macOS): │ │
│ │ ├── kevent() 단일 함수로 등록+대기 │ │
│ │ └── 파일, 소켓, 프로세스 등 통합 지원 │ │
│ │ │ │
│ │ 핵심: "준비된 것만 알려준다(notify only when ready)" │ │
│ │ 수만 연결 중 실제 이벤트 발생한 소수만 처리 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
10.2 Spring MVC vs WebFlux
| 항목 | Spring MVC | Spring WebFlux |
|---|---|---|
| I/O 모델 | Blocking (Servlet API) | Non-blocking (Reactive Streams) |
| 스레드 모델 | Thread-per-request | 소수의 event loop 스레드 (Netty) |
| WebSocket | 지원 (blocking write/read) | 완전한 non-blocking |
| 반환 타입 | 동기 객체 | Mono<T>, Flux<T> |
| 적합 | JPA/JDBC, 기존 코드베이스 | 고동시성 I/O 바운드, 스트리밍 |
Spring WebFlux는 Reactor Netty 위에서 동작. Netty는 내부적으로 epoll(Linux) 또는 kqueue(macOS)를 사용해 수만 개의 WebSocket 연결을 소수의 스레드로 처리한다.
10.3 Node.js 이벤트 루프
┌─────────────────────────────────────────────────────────────────┐
│ [V8 JavaScript Engine] │
│ ↓ │
│ [Node.js Event Loop (libuv)] │
│ ├── timers (setTimeout, setInterval) │
│ ├── I/O callbacks │
│ ├── idle, prepare │
│ ├── poll ← epoll/kqueue로 WebSocket 이벤트 감지 │
│ ├── check (setImmediate) │
│ └── close callbacks │
│ │
│ WebSocket 연결은 대부분 idle 상태 (간헐적 메시지) │
│ → 단일 스레드가 수천 개 연결을 이벤트 기반으로 처리 │
│ → CPU 집약적 작업 없는 실시간 메시징 서버에 최적 │
│ │
└─────────────────────────────────────────────────────────────────┘
10.4 Kotlin 코루틴 + Flow
// 연결 상태를 StateFlow로 관리
val connectionState = MutableStateFlow<ConnectionState>(Disconnected)
// 수신 메시지를 Flow로 노출
fun observeMessages(): Flow<Message> = flow {
webSocketSession.incoming.collect { frame ->
emit(frame.toMessage())
}
}
// 코루틴으로 WebSocket 연결 관리
fun connect() = scope.launch {
try {
client.webSocket(url) {
connectionState.value = Connected
for (frame in incoming) {
processFrame(frame) // suspend 함수로 non-blocking
}
}
} finally {
connectionState.value = Disconnected
}
}
Channel vs Flow 선택 기준:
Channel: 단일 소비자, 이벤트 순서 보장, 버퍼링 필요SharedFlow: 다중 구독자, hot stream (WebSocket 메시지 브로드캐스트)StateFlow: 최신 상태만 필요, UI 상태 관리 (연결 상태 표시)
11. 베스트 프랙티스
11.1 WebSocket 베스트 프랙티스
Heartbeat (ping/pong)
┌─────────────────────────────────────────────────────────────────┐
│ 권장 설정: │
│ ├── Ping 전송 간격: 30초 │
│ ├── Pong 응답 대기 타임아웃: 5초 │
│ └── 응답 없을 시: 연결 종료 후 재연결 │
│ │
│ Ghost Connection 문제: │
│ 모바일 네트워크 전환, 슬립 모드 복귀 시 │
│ 연결이 "살아있는 것처럼 보이지만 실제로는 죽은" 상태 │
│ → heartbeat 없이는 감지 불가 │
│ │
└─────────────────────────────────────────────────────────────────┘
class ResilientWebSocket {
startHeartbeat() {
this.pingInterval = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.ping();
this.pongTimeout = setTimeout(() => {
this.ws.terminate(); // 5초 내 pong 미수신 시 강제 종료
}, 5000);
}
}, 30000);
}
}
Exponential Backoff with Jitter
function getReconnectDelay(attempt) {
const base = 1000; // 1초 기본
const max = 30000; // 최대 30초
const exponential = Math.min(base * 2 ** attempt, max);
const jitter = Math.random() * 1000; // 0~1초 무작위
return exponential + jitter;
}
// 시도별 지연: ~1s, ~2s, ~4s, ~8s, ~16s, ~30s (최대)
재연결 성공 후 반드시 서버로부터 최신 상태 스냅샷을 fetch해서 놓친 이벤트 보정.
인증: JWT 토큰 전달
┌─────────────────────────────────────────────────────────────────┐
│ 브라우저 WebSocket API는 HTTP Authorization 헤더 미지원! │
│ │
│ 방법 A: Query Parameter (편리하지만 보안 주의) │
│ wss://api.example.com/ws?token=eyJhbGc... │
│ └── 단점: 토큰이 서버 로그, 프록시 로그에 노출 │
│ └── 완화: 일회용 단기 토큰(수 분 유효) 발급 │
│ │
│ 방법 B: Sec-WebSocket-Protocol 헤더 (권장) │
│ new WebSocket(url, ['v1', `token.${jwt}`]); │
│ │
│ 방법 C: 연결 직후 인증 메시지 (가장 안전) │
│ ws.onopen = () => ws.send({type:'auth', token: jwt}); │
│ └── 서버: 10초 내 auth 미수신 시 연결 강제 종료 (1008) │
│ │
└─────────────────────────────────────────────────────────────────┘
Redis Pub/Sub 스케일링
┌─────────────────────────────────────────────────────────────────┐
│ 다중 서버 환경에서 WebSocket 메시지 동기화 │
│ │
│ [Client A] ──► [WS Server 1] ──► Redis PUBLISH "room:42" │
│ │ │
│ [Client B] ──► [WS Server 2] ──SUBSCRIBE─┘ → 로컬 broadcast │
│ │
│ 원리: │
│ 1. 각 WS 서버는 관련 Redis 채널을 SUBSCRIBE │
│ 2. 메시지 수신 시 Redis에 PUBLISH │
│ 3. 모든 서버가 수신 후 로컬 클라이언트에게 브로드캐스트 │
│ │
│ 고급 대안: 초대형 시스템에서는 NATS JetStream 권장 │
│ │
│ Sticky Session vs Pub/Sub: │
│ ├── Sticky Session: 구현 단순, 로드밸런서가 단일 장애 지점 │
│ └── Pub/Sub (권장): 탄력적 확장, 장애 격리 │
│ │
└─────────────────────────────────────────────────────────────────┘
보안
wss://(TLS) 사용 필수.ws://는 프로덕션 사용 금지- Origin 헤더 검증: Cross-Site WebSocket Hijacking (CSWSH) 방지
- Rate Limiting: 연결당 10 msg/s, IP당 신규 연결 10개/분
- 메시지 크기 제한: 기본 1MB 이하
- JSON Schema 검증: 입력 메시지 타입 화이트리스트
- 압축 주의: CRIME/BREACH 공격 취약점 (민감 데이터 + 압축)
11.2 SSE 베스트 프랙티스
Last-Event-ID 복구 전략
# 서버 이벤트 형식
id: 1001
event: order-update
data: {"orderId": "abc", "status": "shipped"}
retry: 3000
id: 1002
event: order-update
data: {"orderId": "xyz", "status": "delivered"}
서버 측에서 반드시 이벤트 저장소(DB 또는 캐시)를 유지해야 Last-Event-ID 기반 복구가 가능하다.
HTTP/2 최적화
HTTP/1.1: 도메인당 최대 6개 연결 (SSE 연결이 점유)
HTTP/2: 단일 TCP 연결에서 멀티플렉싱 → 연결 제한 사실상 없음
HTTP/2 전환만으로 SSE의 가장 큰 제약인 연결 수 문제가 해결된다.
11.3 Polling 베스트 프랙티스
Adaptive Polling (적응형 폴링)
class AdaptivePoller {
interval = 1000;
minInterval = 1000;
maxInterval = 30000;
async poll() {
const response = await fetch('/api/updates');
if (response.status === 304) {
this.interval = Math.min(this.interval * 1.5, this.maxInterval);
} else {
this.interval = this.minInterval;
}
setTimeout(() => this.poll(), this.interval);
}
}
ETag / If-None-Match
# 첫 요청
GET /api/data HTTP/1.1
# 서버 응답
HTTP/1.1 200 OK
ETag: "abc123"
# 이후 요청
GET /api/data HTTP/1.1
If-None-Match: "abc123"
# 변경 없을 때 (body 없음 → 대역폭 절약)
HTTP/1.1 304 Not Modified
11.4 Spring Boot 구현 패턴
SseEmitter (MVC)
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamEvents() {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
executor.execute(() -> {
try {
for (int i = 0; i < 100; i++) {
emitter.send(SseEmitter.event()
.id(String.valueOf(i))
.name("update")
.data("Event " + i)
.reconnectTime(3000L));
Thread.sleep(1000);
}
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
WebFlux Flux (권장, Non-blocking)
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamEvents() {
return Flux.interval(Duration.ofSeconds(1))
.map(seq -> ServerSentEvent.<String>builder()
.id(String.valueOf(seq))
.event("tick")
.data("Event " + seq)
.retry(Duration.ofSeconds(3))
.build());
}
Kotlin Flow (최신 권장)
@GetMapping("/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun streamEvents(): Flow<ServerSentEvent<String>> = flow {
var seq = 0L
while (true) {
emit(ServerSentEvent.builder<String>()
.id(seq.toString())
.event("update")
.data("Event $seq")
.build())
seq++
delay(1000)
}
}.catch { e ->
emit(ServerSentEvent.builder<String>()
.comment("error: ${e.message}").build())
}
DeferredResult Long Polling (Spring Boot)
@GetMapping("/long-poll")
public DeferredResult<ResponseEntity<List<Event>>> longPoll(
@RequestParam String lastEventId) {
DeferredResult<ResponseEntity<List<Event>>> result =
new DeferredResult<>(30000L); // 30초 타임아웃
result.onTimeout(() ->
result.setResult(ResponseEntity.noContent().build()));
eventQueue.register(lastEventId, result);
return result;
}
12. 주요 함정과 안티패턴
12.1 WebSocket 함정
┌─────────────────────────────────────────────────────────────────┐
│ 함정 1: 프록시/방화벽이 WebSocket Upgrade를 차단 │
│ ├── 해결: wss:// (포트 443) 사용 │
│ ├── 해결: 폴백 전략 (WS 실패 시 SSE 전환) │
│ └── 해결: Socket.IO 같은 폴링 폴백 라이브러리 │
│ │
│ 함정 2: 메모리 누수 (연결 해제 시 리소스 정리 누락) │
│ ├── sessions.remove(session) │
│ ├── subscriptions.remove(session.getId()) │
│ └── heartbeatTimers.get(session.getId()).cancel() │
│ │
│ 함정 3: 브로드캐스트 O(N) 문제 │
│ ├── 10,000 연결 → 10,000번의 send() 동기 호출 │
│ ├── 해결: 100ms 내 메시지 배치 전송 │
│ ├── 해결: "좋아요 x 1000" 형태로 집계 │
│ └── 해결: Redis Pub/Sub로 서버 간 분산 │
│ │
│ 함정 4: HTTP 미들웨어 재구현 필요 │
│ └── WebSocket은 HTTP가 아니므로 인증/CORS/로깅 별도 구현 │
│ │
│ 함정 5: 서버 재시작 시 모든 연결 끊김 │
│ ├── 해결: Redis에 연결 상태 저장 │
│ ├── 해결: Graceful shutdown + 재연결 유도 │
│ └── 해결: 블루/그린 또는 롤링 업데이트 │
│ │
└─────────────────────────────────────────────────────────────────┘
12.2 SSE 함정
┌─────────────────────────────────────────────────────────────────┐
│ 함정 1: HTTP/1.1 연결 제한 (가장 실질적 문제) │
│ ├── 브라우저당 도메인별 6개 TCP 연결 제한 │
│ ├── 탭 6개 이상 → 7번째 탭 SSE 연결 불가! │
│ ├── 해결: HTTP/2 전환 (멀티플렉싱) │
│ ├── 해결: SharedWorker로 탭 간 연결 공유 │
│ └── 해결: 서브도메인 분리 │
│ │
│ 함정 2: 바이너리 데이터 전송 불가 │
│ ├── SSE는 텍스트 기반 │
│ ├── Base64 인코딩 필요 → ~33% 크기 증가 │
│ └── 바이너리 많으면 WebSocket 사용 │
│ │
│ 함정 3: 클라이언트→서버 통신은 별도 HTTP 요청 필요 │
│ └── SSE 수신 + fetch() POST 전송 조합 (실제로는 명확한 패턴) │
│ │
└─────────────────────────────────────────────────────────────────┘
12.3 Polling 안티패턴
┌─────────────────────────────────────────────────────────────────┐
│ 안티패턴 1: 너무 짧은 고정 간격 │
│ ├── setInterval(fetchUpdates, 100); // 0.1초마다! │
│ └── 1,000명 × 10 req/s = 10,000 req/s → 서버 과부하 │
│ │
│ 안티패턴 2: 변경 없어도 Full Response 반환 │
│ └── ETag/304 Not Modified 활용하지 않음 → 대역폭 낭비 │
│ │
│ 안티패턴 3: Thundering Herd │
│ ├── 1,000명이 정각에 동시 폴링 시작 │
│ ├── 매 30초마다 1,000개 요청 동시 쇄도 │
│ └── 해결: 초기 폴링 시작 시 jitter 추가 │
│ const initialDelay = Math.random() * intervalMs; │
│ │
└─────────────────────────────────────────────────────────────────┘
13. 마이그레이션 가이드
13.1 Polling → SSE 전환
┌─────────────────────────────────────────────────────────────────┐
│ 전환 체크리스트: │
│ │
│ 1. 인프라 확인 │
│ ├── Nginx: proxy_buffering off, 타임아웃 연장 │
│ ├── 로드밸런서: SSE 지원 여부 │
│ └── HTTP/2 지원 여부 (연결 수 제한 해소) │
│ │
│ 2. 서버 측 변경 │
│ ├── 기존 폴링 엔드포인트 유지 (하위 호환) │
│ ├── SSE 엔드포인트 추가 │
│ ├── 이벤트 ID 시스템 구축 (Last-Event-ID 복구) │
│ └── 이벤트 히스토리 저장소 구성 │
│ │
│ 3. 클라이언트 측 변경 │
│ ├── setInterval 제거 │
│ ├── EventSource 연결로 교체 │
│ └── onmessage / onerror / onopen 핸들러 구현 │
│ │
│ 4. 점진적 전환 │
│ ├── Feature flag로 일부 사용자만 SSE 활성화 │
│ ├── 에러율, 지연시간 모니터링 │
│ └── 문제 없으면 점진적 확대 │
│ │
│ 실제 사례: Long Polling → SSE 전환 시 │
│ 서버 8대 → 3대, 월 $3,800 절감 (연 $45,000) │
│ │
└─────────────────────────────────────────────────────────────────┘
13.2 SSE → WebSocket 전환
전환이 필요한 신호:
- 클라이언트에서 서버로 고빈도 메시지 전송 필요
- 지연시간 100ms 이하 요구사항
- 양방향 실시간 협업 기능 추가 시
추가 고려사항:
- WebSocket 인증 체계 별도 구현
- CORS, 보안 미들웨어 재구성
- 스케일링 아키텍처 변경 (Redis Pub/Sub 등)
- 프록시/방화벽 WebSocket 지원 확인
- 폴백 전략 (WebSocket 실패 시 SSE 유지)
13.3 Socket.IO → Native WebSocket 전환
전환 전 확인:
- 폴링 폴백이 실제 사용되고 있는가? (Socket.IO 로그에서 transport: polling 비율 확인)
- 자동 재연결 로직을 직접 구현할 준비가 되었는가?
- Room/Namespace 기능을 직접 구현할 수 있는가?
13.4 단계적 마이그레이션 (Strangler Fig Pattern)
┌─────────────────────────────────────────────────────────────────┐
│ Phase 1: 병렬 운영 │
│ ├── 기존 Polling 엔드포인트 유지 │
│ ├── 새 SSE/WebSocket 엔드포인트 추가 │
│ └── Feature Flag로 제어 │
│ │
│ Phase 2: 트래픽 이전 │
│ ├── 내부 사용자 → 베타 사용자 → 전체 사용자 │
│ └── 각 단계에서 에러율, 레이턴시 메트릭 모니터링 │
│ │
│ Phase 3: 구형 엔드포인트 제거 │
│ ├── 구형 클라이언트 접속 비율 1% 미만 시 │
│ └── Deprecation 헤더로 사전 경고 후 제거 │
│ │
└─────────────────────────────────────────────────────────────────┘
14. References
RFC 및 공식 스펙
- RFC 6455 - The WebSocket Protocol (2011)
- RFC 6202 - Known Issues and Best Practices for Long Polling and Streaming (2011)
- RFC 8441 - Bootstrapping WebSockets with HTTP/2 (2018)
- RFC 9220 - Bootstrapping WebSockets with HTTP/3 (2022)
- RFC 7540 - HTTP/2 (2015)
- RFC 2616 - HTTP/1.1 (1999, 폐기)
- RFC 7230 - HTTP/1.1 Message Syntax (2014)
- WHATWG SSE Living Standard
- WHATWG WebSockets Standard
- W3C SSE Publication History
- W3C WebSocket API Publication History
- W3C WebTransport Working Draft
- STOMP Protocol Specification 1.2
기술 문서 및 가이드
- MDN - Using server-sent events
- MDN - Writing WebSocket servers
- MDN - Evolution of HTTP
- gRPC Core Concepts
- Socket.IO Documentation v4
- Spring Framework STOMP Overview
- Spring WebFlux WebSockets
기술 비교 및 벤치마크
- WebSockets vs SSE vs Long-Polling vs WebRTC vs WebTransport - RxDB
- WebSocket vs SSE: Performance Battle 2025 - metatech.dev
- How to Scale WebSockets - Ably
- MigratoryData C10M Problem Solved
- Can WebTransport replace WebSockets? - Ably
- WebSockets vs WebTransport - websocket.org
- gRPC vs WebSocket - Ably
- MQTT vs WebSocket - HiveMQ
기업 사례
- Real-time Messaging - Slack Engineering
- How Figma’s multiplayer technology works - Figma Blog
- Under the hood: Facebook Messenger for Firefox - Meta Engineering
베스트 프랙티스
- WebSocket Architecture Best Practices - Ably
- WebSocket Security Hardening Guide - websocket.org
- WebSocket Best Practices for Production - LatteStream
- Configure SSE Through Nginx - OneUptime
- Scaling Pub/Sub with WebSockets and Redis - Ably
- Server-Sent Events in Spring - Baeldung
- WebSockets with Spring - Baeldung
- Spring Boot 3 WebSocket JWT Authentication - Medium