WebSocket 완전정복 — 핸드셰이크부터 재연결, 스케일링까지
HTTP의 한계에서 출발해 WebSocket의 핸드셰이크, 프레임 구조, 하트비트, 재연결 전략, 수평 확장까지 실시간 통신의 모든 것을 정리합니다.
왜 WebSocket이 필요한가?
HTTP는 본질적으로 요청-응답(request-response) 모델이다. 클라이언트가 물어봐야만 서버가 대답하고, 서버가 먼저 말을 걸 수 없다.
[클라이언트] ──요청──▶ [서버]
◀──응답──
(연결 종료)
하지만 채팅, 주식 시세, 협업 툴, 게임처럼 서버가 먼저 알려줘야 하는 기능이 있다. 새 메시지가 도착했을 때, 주가가 바뀌었을 때, 다른 사용자가 커서를 움직였을 때 — 클라이언트는 그 사실을 모른다.
HTTP로 흉내 내본 실시간 통신
1. Polling (주기적 폴링)
클라이언트가 "새 소식 있어?"를 계속 물어본다.
[클라이언트] ──"있어?"──▶ [서버] (1초마다)
◀──"없음"──
[클라이언트] ──"있어?"──▶ [서버]
◀──"없음"──
단순하지만 대부분의 요청이 낭비이고, 실시간성도 떨어진다.
2. Long Polling
서버가 새 데이터가 생길 때까지 응답을 미룬다.
[클라이언트] ──요청──▶ [서버] (데이터 없으면 대기)
...
◀──응답──── (데이터 생기면 응답)
[클라이언트] ──재요청──▶ [서버] (즉시 다시 요청)
응답이 올 때마다 매번 새로운 연결을 맺어야 하고, 헤더 오버헤드가 크다.
그래서 나온 WebSocket
WebSocket은 한 번 연결을 맺으면 그 연결을 계속 유지하면서, 클라이언트와 서버가 양방향으로 자유롭게 메시지를 주고받는 프로토콜이다.
[클라이언트] ◀═══════ 양방향 통신 ═══════▶ [서버]
(연결 유지)
RFC 6455로 표준화되었고, 브라우저에서는 WebSocket 객체로 사용한다.
HTTP vs WebSocket 한눈에 비교
| 항목 | HTTP | WebSocket |
|---|---|---|
| 통신 방향 | 단방향 (요청→응답) | 양방향 (full-duplex) |
| 연결 유지 | 요청마다 열고 닫음 (keep-alive 제외) | 한 번 열면 유지 |
| 오버헤드 | 매 요청마다 헤더 전송 | 첫 핸드셰이크 이후 프레임만 |
| URL 스킴 | http://, https:// |
ws://, wss:// |
| 포트 | 80 / 443 | 80 / 443 (동일) |
| 주 용도 | 문서/API 조회 | 채팅, 알림, 게임, 스트리밍 |
WebSocket도 기본 포트는 80/443이라 방화벽 통과가 쉽다.
암호화된 버전이 wss://이며, 운영에서는 무조건 wss://를 써야 한다.
WebSocket 핸드셰이크 과정
WebSocket 연결은 HTTP 요청으로 시작한 뒤, 프로토콜을 업그레이드해서 만들어진다. 이 "HTTP → WebSocket"으로 갈아타는 과정을 핸드셰이크라고 한다.
1. 클라이언트 요청 (Upgrade)
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
Upgrade: websocket— "이 연결을 WebSocket으로 바꾸고 싶어요"Sec-WebSocket-Key— 클라이언트가 보낸 랜덤 Base64 값Sec-WebSocket-Version: 13— 표준 버전
2. 서버 응답 (101 Switching Protocols)
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
101— "프로토콜 바꿨어요"Sec-WebSocket-Accept— 클라이언트 Key + 고정 GUID를 SHA-1 해시한 값
이 값은 서버가 WebSocket 프로토콜을 진짜로 이해하고 있다는 증명이다. 일반 HTTP 서버가 실수로 101을 돌려보내는 걸 막기 위한 장치다.
3. 이후 통신
HTTP 연결을 그대로 TCP 소켓으로 재활용해서, 양쪽 모두 프레임 단위로 데이터를 주고받는다.
[브라우저] ──HTTP 요청──▶ [서버]
◀──101 응답──
═══ 이후부터는 WebSocket 프레임 ═══
──"안녕"──▶
◀──"반가워"──
WebSocket 프레임 구조
WebSocket 메시지는 프레임(frame) 단위로 쪼개서 전송된다.
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| op |M| Payload len | Extended payload length |
|I|S|S|S| code |A| (7 bits) | (16/64 bits) |
|N|V|V|V|(4bit) |S| | |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Masking-key (32 bits, 클라이언트→서버일 때만) |
+---------------------------------------------------------------+
| Payload Data |
+---------------------------------------------------------------+
주요 필드
| 필드 | 의미 |
|---|---|
| FIN | 이 프레임이 메시지의 마지막인가? (큰 메시지는 여러 프레임으로 쪼갬) |
| opcode | 프레임 타입 (텍스트/바이너리/종료/핑/퐁 등) |
| MASK | 클라이언트→서버 방향은 반드시 마스킹 (보안용) |
| Payload length | 실제 데이터 길이 |
| Payload | 보낼 데이터 |
opcode 종류
| opcode | 이름 | 설명 |
|---|---|---|
0x1 |
Text | UTF-8 텍스트 |
0x2 |
Binary | 바이너리 데이터 (이미지, 파일 등) |
0x8 |
Close | 연결 종료 알림 |
0x9 |
Ping | 살아 있는지 확인 |
0xA |
Pong | Ping에 대한 응답 |
왜 마스킹이 필요한가?
클라이언트→서버 방향 프레임은 반드시 랜덤 키로 XOR 마스킹해서 보내야 한다. 이유는 웹 환경에서 프록시 캐시 포이즈닝 공격을 막기 위해서다. 마스킹이 없으면 공격자가 WebSocket 페이로드를 HTTP 요청처럼 위장해 중간 프록시를 속일 수 있다.
브라우저 클라이언트 예제
브라우저에는 WebSocket 객체가 내장돼 있어 라이브러리 없이 바로 쓸 수 있다.
const socket = new WebSocket('wss://example.com/chat')
// 연결 열림
socket.addEventListener('open', () => {
console.log('연결됨')
socket.send(JSON.stringify({ type: 'join', room: 'general' }))
})
// 메시지 수신
socket.addEventListener('message', (event) => {
const data = JSON.parse(event.data)
console.log('받음:', data)
})
// 에러
socket.addEventListener('error', (err) => {
console.error('에러:', err)
})
// 연결 종료
socket.addEventListener('close', (event) => {
console.log(`종료: code=${event.code}, reason=${event.reason}`)
})
readyState
소켓의 현재 상태를 나타내는 숫자값.
| 값 | 상수 | 의미 |
|---|---|---|
| 0 | CONNECTING |
연결 중 |
| 1 | OPEN |
연결됨 (전송 가능) |
| 2 | CLOSING |
닫는 중 |
| 3 | CLOSED |
닫힘 |
if (socket.readyState === WebSocket.OPEN) {
socket.send('hello')
}
주의: CONNECTING 상태에서 send()를 호출하면 예외가 발생한다.
반드시 open 이벤트 이후에 전송하거나, readyState를 확인해야 한다.
Node.js 서버 예제 (ws 라이브러리)
Node.js에서 WebSocket 서버를 만들 때 가장 널리 쓰는 라이브러리는 ws다.
npm install ws
import { WebSocketServer } from 'ws'
const wss = new WebSocketServer({ port: 8080 })
wss.on('connection', (socket, request) => {
const ip = request.socket.remoteAddress
console.log(`클라이언트 접속: ${ip}`)
// 메시지 수신
socket.on('message', (data) => {
const msg = data.toString()
console.log('받음:', msg)
// 모든 클라이언트에게 브로드캐스트
wss.clients.forEach((client) => {
if (client.readyState === 1) {
client.send(msg)
}
})
})
socket.on('close', () => {
console.log('클라이언트 종료')
})
// 접속하자마자 환영 메시지
socket.send(JSON.stringify({ type: 'welcome' }))
})
이 코드만으로 간단한 채팅 서버가 완성된다. 누군가 메시지를 보내면 접속된 모든 클라이언트에게 그대로 전달된다.
방(room) 개념 구현
실전에서는 전체 브로드캐스트보다 특정 방에만 전송하는 경우가 많다.
const rooms = new Map<string, Set<WebSocket>>()
wss.on('connection', (socket) => {
let currentRoom: string | null = null
socket.on('message', (data) => {
const msg = JSON.parse(data.toString())
if (msg.type === 'join') {
currentRoom = msg.room
if (!rooms.has(currentRoom)) rooms.set(currentRoom, new Set())
rooms.get(currentRoom)!.add(socket)
}
if (msg.type === 'chat' && currentRoom) {
const members = rooms.get(currentRoom) ?? new Set()
members.forEach((client) => {
if (client !== socket && client.readyState === 1) {
client.send(JSON.stringify({ type: 'chat', text: msg.text }))
}
})
}
})
socket.on('close', () => {
if (currentRoom) rooms.get(currentRoom)?.delete(socket)
})
})
하트비트 (Ping / Pong)
TCP 연결은 중간에 끊겨도 양쪽이 바로 알아채지 못한다. 특히 모바일 네트워크 전환, NAT 타임아웃, 프록시 정리 등으로 **"좀비 연결"**이 남을 수 있다.
이를 감지하기 위해 WebSocket은 Ping/Pong 프레임을 제공한다.
[서버] ──Ping──▶ [클라이언트]
◀──Pong──
(응답 없으면 죽은 연결로 간주하고 종료)
Node.js에서 구현
wss.on('connection', (socket) => {
// @ts-expect-error 커스텀 프로퍼티
socket.isAlive = true
socket.on('pong', () => {
// @ts-expect-error
socket.isAlive = true
})
})
// 30초마다 체크
const interval = setInterval(() => {
wss.clients.forEach((socket) => {
// @ts-expect-error
if (!socket.isAlive) return socket.terminate()
// @ts-expect-error
socket.isAlive = false
socket.ping()
})
}, 30_000)
핑을 보냈는데 다음 체크 때까지 퐁이 안 오면 terminate()로 강제 종료한다.
재연결 전략
WebSocket은 네트워크 불안정, 서버 재시작 등으로 언제든 끊어질 수 있다. 클라이언트는 끊기면 자동으로 다시 연결해야 사용자 경험이 유지된다.
단순 재연결의 문제
socket.addEventListener('close', () => {
new WebSocket(url) // 끊기자마자 즉시 재연결
})
서버가 다운됐을 때 모든 클라이언트가 동시에 재접속을 시도하면 서버가 살아나자마자 다시 터진다. 이걸 Thundering Herd 문제라 한다.
Exponential Backoff + Jitter
재시도 간격을 점점 늘리고, 약간의 무작위성을 더한다.
class ReconnectingSocket {
private attempts = 0
private socket: WebSocket | null = null
constructor(private url: string) {
this.connect()
}
private connect() {
this.socket = new WebSocket(this.url)
this.socket.addEventListener('open', () => {
this.attempts = 0 // 성공하면 초기화
})
this.socket.addEventListener('close', () => {
const delay = Math.min(1000 * 2 ** this.attempts, 30_000)
const jitter = Math.random() * 1000
setTimeout(() => this.connect(), delay + jitter)
this.attempts++
})
}
send(data: string) {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(data)
}
}
}
재시도 간격: 1초 → 2초 → 4초 → 8초 → ... → 최대 30초. 여기에 랜덤 지터를 더해 몰려드는 재접속을 분산시킨다.
WebSocket vs SSE vs Long Polling
실시간을 구현하는 방법이 WebSocket만 있는 건 아니다.
| 항목 | WebSocket | SSE (Server-Sent Events) | Long Polling |
|---|---|---|---|
| 방향 | 양방향 | 서버→클라 단방향 | 요청마다 한 번 |
| 프로토콜 | 별도 (ws/wss) | HTTP | HTTP |
| 자동 재연결 | 직접 구현 | 브라우저가 내장 | 직접 구현 |
| 바이너리 | 지원 | 텍스트만 | 텍스트 (base64 가능) |
| 구현 난이도 | 중간 | 낮음 | 낮음 |
| 용도 | 채팅, 게임, 협업 | 실시간 피드, 알림, LLM 스트리밍 | 단순 알림 |
언제 WebSocket을 써야 하나?
- 클라이언트도 자주 메시지를 보내야 할 때 (채팅, 게임 컨트롤)
- 바이너리 데이터가 필요할 때 (음성, 이미지 스트림)
- 지연(latency)이 매우 중요할 때
반대로, 서버→클라 단방향 알림이라면 SSE가 구현이 훨씬 간단하다. ChatGPT 같은 LLM 토큰 스트리밍은 대부분 SSE로 구현된다.
스케일링 — 서버가 여러 대라면?
WebSocket은 연결이 유지되는 프로토콜이라 수평 확장이 까다롭다.
[클라이언트 A] ──연결──▶ [서버 1]
[클라이언트 B] ──연결──▶ [서버 2]
서버 1에 접속한 A가 보낸 메시지를
서버 2에 접속한 B에게 어떻게 전달할까?
1. Sticky Session
로드 밸런서가 같은 클라이언트는 항상 같은 서버로 보내도록 고정한다. 연결 유지에는 필수지만, 방 전체에 브로드캐스트하려면 여전히 서버 간 통신이 필요하다.
2. Redis Pub/Sub
서버들끼리 Redis 채널을 통해 메시지를 공유한다.
[서버 1]: 메시지 받음 → Redis PUBLISH "room:general"
│
▼
┌──────────────────────────┐
│ Redis Pub/Sub │
└──────────────────────────┘
│ │ │
▼ ▼ ▼
[서버 1] [서버 2] [서버 3]
각 서버가 자기 방 멤버에게 브로드캐스트
Socket.IO, NestJS WebSocket Gateway 등 주요 라이브러리는 Redis 어댑터를 기본 지원한다.
3. 전용 메시징 인프라
규모가 더 커지면 Kafka, NATS, AWS IoT Core 같은 전용 메시징 시스템을 쓰거나, Ably, Pusher, PubNub 같은 WebSocket 전용 SaaS를 쓴다.
보안 체크리스트
| 항목 | 설명 |
|---|---|
| wss:// 사용 | 평문(ws)은 도청 및 변조 가능. 운영은 무조건 wss |
| Origin 검증 | 핸드셰이크 시 Origin 헤더가 우리 도메인인지 확인 |
| 인증 | 쿼리 파라미터 토큰, 쿠키, 첫 메시지 인증 등으로 사용자 식별 |
| 메시지 크기 제한 | 거대한 프레임으로 메모리 고갈 방지 (maxPayload) |
| Rate Limiting | IP/사용자당 연결 수, 초당 메시지 수 제한 |
| 입력 검증 | WebSocket 메시지도 결국 사용자 입력. XSS/SQL Injection 주의 |
const wss = new WebSocketServer({
port: 8080,
maxPayload: 64 * 1024, // 64KB 제한
verifyClient: (info, cb) => {
const origin = info.origin
const allowed = ['https://mysite.com']
if (!allowed.includes(origin)) return cb(false, 403, 'Forbidden')
cb(true)
},
})
실전 사용 사례
| 사례 | 설명 |
|---|---|
| 채팅 / DM | 메시지 즉시 전달, 타이핑 표시, 읽음 처리 |
| 실시간 알림 | 주문 상태 변경, 새 댓글 알림 |
| 협업 툴 | 구글 Docs, Figma — 커서 위치, 편집 내용 실시간 동기화 |
| 주식 / 암호화폐 | 틱 단위 시세 스트리밍 |
| 멀티플레이 게임 | 플레이어 이동/액션 상태 공유 |
| IoT 제어 | 디바이스 제어 명령, 센서 값 수집 |
정리
| 개념 | 핵심 한 줄 |
|---|---|
| 등장 배경 | HTTP로는 서버가 먼저 말을 못 걸어서 |
| 핸드셰이크 | HTTP 요청으로 시작, 101 Switching Protocols로 업그레이드 |
| 프레임 | opcode로 타입 구분, 클라→서버는 반드시 마스킹 |
| readyState | CONNECTING / OPEN / CLOSING / CLOSED |
| Ping / Pong | 좀비 연결을 감지하는 하트비트 |
| 재연결 | Exponential Backoff + Jitter로 Thundering Herd 방지 |
| vs SSE | 양방향은 WebSocket, 서버→클라 단방향 스트리밍은 SSE |
| 스케일링 | Sticky Session + Redis Pub/Sub이 기본 조합 |
| 보안 | wss, Origin 검증, 인증, 메시지 크기 제한 필수 |
WebSocket은 실시간성이 필요한 모든 기능의 기반이다. 원리를 이해하고 나면, Socket.IO 같은 라이브러리를 써도 내부에서 무슨 일이 벌어지는지 감이 잡힌다.