WebSocket 실전 구현 가이드
HTTP vs WebSocket, Socket.IO, ws 라이브러리, 재연결 전략, 보안 설정 실전 가이드.
한 줄 요약: WebSocket은 HTTP 핸드셰이크로 연결을 시작한 뒤 양방향 지속 연결을 유지한다. 채팅, 실시간 대시보드, 멀티플레이어 게임처럼 서버에서 클라이언트로 데이터를 "밀어야" 하는 경우에 HTTP 폴링보다 훨씬 효율적이다.
실시간 기능을 구현하려 할 때 가장 먼저 드는 질문은 "WebSocket을 직접 쓸까, Socket.IO를 쓸까"다. 이 글은 HTTP와 WebSocket의 차이, Node.js에서 WebSocket 서버와 클라이언트를 구현하는 방법, Socket.IO의 기능과 트레이드오프, 연결 끊김 대응 재연결 전략, 그리고 보안 설정까지 실전에 필요한 내용을 다룬다.
HTTP vs WebSocket — 언제 무엇을 쓰나
HTTP는 요청-응답 모델이다. 클라이언트가 요청해야 서버가 응답한다. 서버가 먼저 데이터를 보낼 수 없다.
실시간 데이터를 HTTP로 구현하는 방법들이 있지만 각각 한계가 있다.
- 폴링(Polling): 클라이언트가 주기적으로
GET /updates를 요청한다. 새 데이터가 없어도 요청한다. 서버 부하와 불필요한 네트워크 트래픽이 발생한다. - 롱 폴링(Long Polling): 서버가 새 데이터가 생길 때까지 응답을 지연한다. 개선이지만 연결이 반복적으로 끊기고 재연결된다.
- SSE(Server-Sent Events): 서버가 클라이언트로 단방향 스트림을 보낼 수 있다. 단방향이라 클라이언트 → 서버 통신은 별도 HTTP 요청이 필요하다.
WebSocket은 HTTP Upgrade 헤더를 사용해 초기 핸드셰이크 후 TCP 연결을 유지한다. 이후 서버와 클라이언트 모두 언제든 메시지를 보낼 수 있다. 양방향, 저지연, 낮은 오버헤드가 특징이다.
| 방식 | 방향 | 지연 | 서버 부하 | 사용 사례 |
|---|---|---|---|---|
| HTTP Polling | 단방향 | 높음 | 높음 | 단순 상태 확인 |
| SSE | 서버→클라이언트 | 낮음 | 중간 | 알림, 피드 |
| WebSocket | 양방향 | 매우 낮음 | 낮음 | 채팅, 게임, 협업 |
ws 라이브러리로 WebSocket 서버 구현
ws는 Node.js의 표준 WebSocket 라이브러리다. Socket.IO보다 가볍고, WebSocket 프로토콜을 직접 다룬다.
ws 라이브러리 — 서버 구현 (Node.js)// npm install ws const { WebSocketServer } = require('ws'); const wss = new WebSocketServer({ port: 8080 }); // 연결된 클라이언트를 관리하는 Set const clients = new Set(); wss.on('connection', (ws, req) => { // 클라이언트 IP (프록시 뒤에서는 X-Forwarded-For 사용) const ip = req.socket.remoteAddress; console.log(`Client connected: ${ip}`); clients.add(ws); // 클라이언트로부터 메시지 수신 ws.on('message', (data) => { let message; try { message = JSON.parse(data.toString()); } catch { ws.send(JSON.stringify({ error: 'Invalid JSON' })); return; } console.log('Received:', message); // 브로드캐스트: 연결된 모든 클라이언트에 전송 const payload = JSON.stringify({ type: 'broadcast', data: message }); for (const client of clients) { if (client.readyState === WebSocket.OPEN) { client.send(payload); } } }); // 연결 종료 ws.on('close', () => { clients.delete(ws); console.log(`Client disconnected: ${ip}`); }); // 에러 처리 ws.on('error', (err) => { console.error('WebSocket error:', err); clients.delete(ws); }); // 연결 확인 메시지 전송 ws.send(JSON.stringify({ type: 'connected', message: 'Welcome' })); }); console.log('WebSocket server running on ws://localhost:8080');
ws 라이브러리 — 클라이언트 구현 (브라우저)// 브라우저 내장 WebSocket API 사용 (별도 설치 불필요) const ws = new WebSocket('ws://localhost:8080'); ws.addEventListener('open', () => { console.log('Connected to server'); ws.send(JSON.stringify({ type: 'hello', text: '안녕하세요' })); }); ws.addEventListener('message', (event) => { const data = JSON.parse(event.data); console.log('Received:', data); }); ws.addEventListener('close', (event) => { console.log(`Connection closed: code=${event.code}, reason=${event.reason}`); // 재연결 로직 실행 }); ws.addEventListener('error', (error) => { console.error('WebSocket error:', error); }); // 메시지 전송 function sendMessage(text) { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'message', text })); } }
Socket.IO — WebSocket 위의 추상 레이어
Socket.IO는 WebSocket 위에 추가 기능을 얹은 라이브러리다. WebSocket을 직접 쓰는 것보다 편리하지만, 클라이언트와 서버 모두 Socket.IO를 써야 한다는 의존성이 생긴다.
Socket.IO가 ws 대비 추가 제공하는 기능
- 자동 재연결: 연결이 끊기면 지수 백오프로 자동 재시도한다.
- 룸(Room): 클라이언트를 그룹(룸)으로 묶어 그룹 내에서만 브로드캐스트 가능. 채팅 채널 구현에 편리하다.
- 네임스페이스: 하나의 서버에서 논리적으로 분리된 연결 공간을 만들 수 있다.
- 폴백: WebSocket이 불가한 환경(일부 기업 방화벽)에서 HTTP 롱 폴링으로 자동 폴백한다.
- 이벤트 기반 API:
socket.emit('message', data)/socket.on('message', handler)로 이벤트 이름을 지정할 수 있어 메시지 타입 관리가 편하다.
Socket.IO를 선택하지 않을 이유
- Socket.IO 클라이언트 라이브러리 자체가 번들 크기를 늘린다.
- 표준 WebSocket 클라이언트(브라우저, 모바일 앱)와 직접 연동되지 않는다.
- Socket.IO 서버를 여러 인스턴스로 수평 확장하려면 Redis Adapter가 필요하다.
재연결 전략 — 연결 끊김 대응
WebSocket 연결은 네트워크 불안정, 서버 재시작, 클라이언트 절전 등으로 예고 없이 끊긴다. 재연결 로직은 필수다.
지수 백오프 재연결 구현 (브라우저)class ReconnectingWebSocket { constructor(url) { this.url = url; this.reconnectDelay = 1000; // 첫 재연결 대기: 1초 this.maxDelay = 30000; // 최대 대기: 30초 this.reconnectAttempts = 0; this.connect(); } connect() { this.ws = new WebSocket(this.url); this.ws.addEventListener('open', () => { console.log('Connected'); this.reconnectDelay = 1000; // 성공 시 딜레이 초기화 this.reconnectAttempts = 0; }); this.ws.addEventListener('message', (event) => { this.onMessage(JSON.parse(event.data)); }); this.ws.addEventListener('close', (event) => { // 1000: 정상 종료, 1001: 서버 종료 — 재연결 안 함 if (event.code === 1000 || event.code === 1001) return; this.scheduleReconnect(); }); this.ws.addEventListener('error', () => { this.ws.close(); }); } scheduleReconnect() { this.reconnectAttempts++; // 지수 백오프 + 지터(jitter)로 동시 재연결 분산 const jitter = Math.random() * 500; const delay = Math.min(this.reconnectDelay * 2 ** (this.reconnectAttempts - 1), this.maxDelay) + jitter; console.log(`Reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts})`); setTimeout(() => this.connect(), delay); } send(data) { if (this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } onMessage(data) { // 서브클래스에서 오버라이드 console.log('Message:', data); } }
WebSocket 보안 설정
WebSocket 서버는 HTTP보다 보안 설정을 빠뜨리기 쉽다. 필수 설정 목록이다.
WSS (WebSocket Secure)
프로덕션에서는 반드시 wss:// (TLS over WebSocket)를 사용해야 한다. Nginx나 Cloudflare 앞에 두어 TLS 종료를 처리하면 애플리케이션 코드는 변경 없이 ws://를 유지할 수 있다.
Origin 검증
WebSocket 핸드셰이크 시 Origin 헤더를 확인해야 한다. 브라우저는 Origin 헤더를 조작할 수 없으므로 CSRF 방어가 가능하다.
인증
WebSocket은 쿠키를 핸드셰이크 시 자동으로 보내지만, 프로그래매틱 클라이언트(서버, 모바일 앱)에서는 쿠키가 없다. 연결 후 첫 메시지로 JWT 토큰을 보내거나, 핸드셰이크 URL에 토큰을 쿼리 파라미터로 포함하는 방법을 쓴다. URL에 토큰을 넣으면 서버 로그에 노출될 수 있으니 짧은 만료 시간의 1회용 토큰을 사용하는 것이 권장된다.
Rate Limiting과 메시지 크기 제한
연결 수, 메시지 전송 속도, 메시지 크기 모두 제한해야 한다. 제한이 없으면 단일 클라이언트가 서버를 압도할 수 있다.