WebSocket 실시간 통신: RFC 6455 업그레이드와 프레임, Socket.io 채팅, Redis 확장, 프로덕션 디버깅
이 글의 핵심
WebSocket 핸드셰이크와 프레임 구조, 브라우저 API와 Socket.io로 채팅 만들기, 서버를 늘릴 때 Redis와 스티키 세션이 필요한 이유, 프록시 타임아웃 때문에 프로덕션에서만 연결이 끊기던 사례를 정리합니다.
이 글의 핵심
WebSocket 사용법보다 먼저 강조하고 싶은 것은 WebSocket이 항상 답은 아니라는 점입니다. 알림처럼 서버에서 클라이언트로만 흐르면 되는 데이터라면 SSE(Server-Sent Events)나 짧은 폴링이 운영, 디버깅, 프록시 통과 측면에서 더 싸게 먹히는 경우가 많습니다. “실시간”이라는 요구가 나왔다고 곧장 소켓부터 열면, 연결 상태 관리와 스케일링이라는 비용을 나중에 치르게 됩니다.
그럼에도 양방향 통신, 낮은 지연, 연결 유지가 실제로 필요하다면 WebSocket은 여전히 가장 유력한 후보입니다. 아래에서는 그때 쓰는 기본 API와 Socket.io 채팅 예제, 여러 서버로 늘릴 때의 구조, 그리고 프로덕션에서만 연결이 끊기던 장애를 추적한 과정을 함께 다룹니다.
경험담: 1초 폴링으로 돌리던 채팅을 WebSocket으로 바꾸고 나서 서버 부하와 체감 지연은 확실히 줄었습니다. 그런데 곧이어 만난 문제는 “코드는 맞는데 프로덕션에서만 끊긴다”는 종류였습니다. 그 과정은 아래 디버깅 사례에 따로 정리했습니다.
들어가며: “실시간 업데이트가 필요해요”
실무에서 자주 만나는 상황
시나리오 1: 폴링이 비효율적 — 1초마다 API를 호출하면 대부분의 응답이 “변경 없음”인데도 매번 HTTP 헤더, 인증 검사, DB 조회가 반복됩니다. WebSocket은 연결 하나를 오래 유지하므로 변경이 있을 때만 데이터가 오갑니다. 다만 사용자가 수만 명이면 그만큼의 연결을 서버가 계속 들고 있어야 한다는 반대편 비용이 생깁니다.
시나리오 2: 메시지가 늦게 도착 — 폴링은 구조상 폴링 간격만큼 늦을 수 있습니다. 간격을 줄이면 부하가 늘고, 늘리면 지연이 커지는 딜레마를 WebSocket은 “변경 즉시 푸시”로 풀어 줍니다.
시나리오 3: 협업 도구와 게임 — 양쪽에서 입력이 자주 오가면 양방향 채널이 자연스럽습니다. 다만 동시 편집의 충돌 해결은 CRDT나 OT 같은 별도 알고리즘의 몫이고, WebSocket은 그 데이터를 실어 나르는 파이프일 뿐입니다.
WebSocket이란?
핵심만
WebSocket은 하나의 TCP 연결 위에서 서버와 클라이언트가 양방향으로 메시지를 주고받는 프로토콜입니다. 특징은 대략 다음과 같습니다.
- 양방향: 서버와 클라이언트 모두 먼저 메시지를 보낼 수 있음
- 연결 재사용: 한 번 연결하면 메시지마다 HTTP 요청/응답을 새로 만들지 않음
- 낮은 오버헤드: 연결 후에는 메시지당 몇 바이트의 프레임 헤더만 붙음 (실제 지연은 네트워크 환경에 따라 다르므로 직접 측정해야 함)
- 브라우저 기본 API 제공
HTTP와 비교하면, HTTP는 기본적으로 클라이언트가 묻고 서버가 답하는 구조이고, WebSocket은 HTTP로 한 번 업그레이드한 뒤 그 소켓을 계속 열어 둔 채 양쪽이 자유롭게 이야기하는 구조입니다.
선택 기준을 정리하면, 서버에서 클라이언트로만 흐르는 데이터는 SSE를 먼저 검토하고, 가끔만 갱신되는 데이터라면 캐시와 짧은 폴링도 충분히 좋은 선택입니다. SSE는 일반 HTTP 응답이라 기존 인증 미들웨어, 로깅, HTTP/2 멀티플렉싱을 그대로 쓰고, 브라우저가 재연결과 Last-Event-ID 전송까지 자동으로 처리합니다. WebSocket은 이 모든 것을 직접 만들어야 한다는 점을 비용에 포함해야 합니다.
프로토콜 내부: 업그레이드·프레임·하트비트 (RFC 6455)
ws, Socket.io, 브라우저 WebSocket이 프레임과 마스킹은 대신 처리해 줍니다. 하지만 101 응답이 오지 않음, 중간에 조용히 끊김, 갑자기 1006 코드 같은 증상은 결국 RFC 6455의 뼈대를 알아야 원인을 좁힐 수 있습니다. 처음에는 “라이브러리가 다 해 주겠지”라고 생각했지만, 앞에 프록시가 하나 끼는 순간 상황이 완전히 달라졌습니다.
HTTP에서 WebSocket으로: 업그레이드 핸드셰이크
- 클라이언트는
GET요청에Upgrade: websocket,Connection: Upgrade,Sec-WebSocket-Version: 13,Sec-WebSocket-Key등을 담아 보냅니다. - 서버는 키에 고정 GUID 문자열을 붙여 SHA-1 → Base64로 계산한 값을
Sec-WebSocket-Accept에 넣고 101 Switching Protocols로 응답합니다. - 그다음부터는 HTTP가 아니라 WebSocket 프레임이 오갑니다.
Sec-WebSocket-Key는 보안 인증 수단이 아니라, 서버가 WebSocket을 이해한다는 것과 캐시 프록시가 엉뚱한 응답을 재사용하지 않았다는 것을 확인하는 장치입니다. 인증은 별도로 해야 하는데, 브라우저의 WebSocket 생성자는 임의의 요청 헤더를 설정할 수 없습니다. 그래서 Authorization 헤더 대신 쿠키, 쿼리스트링 토큰(로그에 남지 않도록 짧은 수명으로), 또는 연결 직후 첫 메시지로 인증하는 방식을 씁니다. 쿠키 인증을 쓴다면 핸드셰이크의 Origin 헤더를 서버에서 반드시 검사해야 합니다. WebSocket에는 CORS가 적용되지 않으므로, 이를 빠뜨리면 다른 사이트가 사용자의 쿠키로 소켓을 여는 CSWSH(Cross-Site WebSocket Hijacking) 공격이 가능합니다.
역프록시(Nginx, 클라우드 로드밸런서)에서 Upgrade / Connection 헤더가 전달되지 않으면 101이 오지 않거나 연결 직후 끊깁니다. Nginx에서는 hop-by-hop 헤더인 이 둘을 기본으로 백엔드에 넘기지 않기 때문에 명시적으로 설정해야 합니다.
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1; # 1.0에는 Upgrade가 없음
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s; # 기본 60초 → 유휴 연결이 60초 만에 끊김
}
TLS를 쓰는 wss:// + 443 포트는 평문 ws://보다 회사망 방화벽이나 중간 장비를 훨씬 잘 통과합니다. 중간 장비가 암호화된 내용을 보지 못해 일반 HTTPS와 구분하지 못하기 때문입니다.
프레임 — FIN, opcode, 마스킹
- FIN: 이 조각이 메시지의 마지막인지 표시합니다.
- Opcode: 1 텍스트, 2 바이너리, 8 close, 9 Ping, 10 Pong.
- 클라이언트 → 서버 페이로드는 RFC상 마스킹이 필수이고, 서버 → 클라이언트는 마스킹하지 않습니다. 마스킹은 암호화가 아니라, 악의적인 페이로드가 중간 캐시 프록시를 오염시키는 공격을 막기 위한 장치입니다.
- 큰 메시지는 여러 프레임으로 쪼개질 수 있으므로 최대 메시지 크기 제한(
ws의maxPayload등)을 반드시 설정합니다. 제한이 없으면 클라이언트 하나가 거대한 메시지로 서버 메모리를 고갈시킬 수 있고, 제한을 넘으면 연결이 1009(Message Too Big)로 닫힙니다.
텍스트 vs 바이너리
텍스트(opcode 1)는 UTF-8 문자열이어야 합니다. 브라우저 onmessage에 문자열로 도착하며, JSON 채팅이나 알림에는 대개 이것을 씁니다. 유효한 UTF-8이 아니면 프로토콜 위반이라 상대가 연결을 1007 코드로 닫을 수 있습니다. 서버에서 바이트 버퍼를 잘라 문자열로 보내다가 멀티바이트 한글 중간이 잘리면 이 증상이 나오는데, 원인을 모르면 “특정 메시지만 보내면 연결이 죽는다”는 이상한 버그로 보입니다.
바이너리(opcode 2)는 임의의 바이트(Protobuf, 이미지 조각, 커스텀 프레이밍)이며 해석은 전부 애플리케이션의 몫입니다. 한 연결에 둘을 섞어 쓸 수 있지만, 받는 쪽에서 opcode나 앱 프로토콜로 구분해야 합니다. 브라우저에서 바이너리는 기본적으로 Blob으로 오므로, 바로 파싱하려면 ws.binaryType = 'arraybuffer'로 바꿔 두는 편이 편합니다.
하트비트: Ping/Pong vs 앱 레벨 keepalive
Ping(9) / Pong(10)은 두 가지 역할을 합니다. NAT와 로드밸런서가 유휴 TCP 연결을 잘라내기 전에 트래픽을 만들어 주고, 상대가 실제로 살아 있는지 확인합니다. TCP는 상대가 전원이 꺼지거나 네트워크가 끊겨도 한동안 연결이 살아 있다고 믿기 때문에, 서버가 주기적으로 Ping을 보내고 일정 시간 Pong이 없으면 직접 연결을 정리해야 좀비 연결이 쌓이지 않습니다. 브라우저 JavaScript에서는 Ping 프레임을 직접 보낼 수 없어서 {"type":"ping"} 같은 앱 레벨 메시지를 쓰는 팀도 많고, Socket.io는 자체 하트비트를 내장하고 있습니다.
중요한 점은 애플리케이션이 멀쩡해도 프록시의 proxy_read_timeout이나 클라우드 로드밸런서의 idle timeout이 짧으면 연결이 그냥 잘린다는 것입니다. 저는 Ping 주기(대략 20~30초)를 경로상의 가장 짧은 idle timeout보다 확실히 짧게 맞추는 것으로 “왜 정확히 60초마다 끊기지?” 같은 문제를 해결했습니다. 끊기는 간격이 일정하다면 거의 항상 어딘가의 타임아웃 값입니다.
Close 코드로 원인 좁히기
| 코드 | 의미 | 흔한 원인 |
|---|---|---|
| 1000 | 정상 종료 | 어느 한쪽이 close() 호출 |
| 1001 | Going Away | 페이지 이동, 서버 재시작 |
| 1006 | 비정상 종료 (Close 프레임 없음) | 프록시 타임아웃, 네트워크 단절, 프로세스 크래시 |
| 1008 | 정책 위반 | 인증 실패 등 서버가 거부 |
| 1009 | 메시지 너무 큼 | maxPayload 초과 |
| 1011 | 서버 내부 오류 | 서버 핸들러 예외 |
1006은 실제로 프레임으로 전송되는 코드가 아니라, Close 프레임 없이 TCP가 끊겼을 때 브라우저가 붙이는 값입니다. 그래서 1006 자체에는 정보가 거의 없고, 원인은 서버 로그와 프록시 로그에서 찾아야 합니다. 브라우저 보안 정책상 핸드셰이크 실패 이유도 JavaScript에 알려주지 않으므로(onerror의 이벤트에는 상세 정보가 없습니다), 개발자 도구의 Network 탭에서 WS 요청의 응답 코드를 보는 것이 가장 빠릅니다.
운영에서 제가 확인하는 항목
- WSS + 443 사용 여부 (대부분의 네트워크를 통과)
- 스티키 세션이 필요한지, Redis Pub/Sub으로 풀지 먼저 결정
- 재연결: 지수 백오프에 무작위 지연(jitter)을 더하고, 오프라인 큐와 마지막 이벤트 ID로 상태를 다시 맞추기
- 백프레셔: 느린 클라이언트에게
send를 무한정 쌓지 않기 - 관측: 연결 수, 핸드셰이크 실패, 비정상 close(1006 등) 비율
재연결에 무작위 지연을 넣는 이유는 서버 재배포 때문입니다. 서버가 재시작되면 수천 개의 클라이언트가 동시에 끊기고, 모두 똑같이 1초 뒤 재연결을 시도하면 새 서버가 뜨자마자 핸드셰이크 폭주로 다시 쓰러집니다. 백오프 간격에 무작위 값을 섞어 재연결 시점을 흩어 놓아야 합니다.
밤 11시, 스테이징은 되는데 프로덕션만 WebSocket이 죽던 날
상황은 이랬습니다. 로컬과 스테이징에서는 Socket.io가 잘 붙는데, 프로덕션에서만 몇십 분이 지나면 끊기거나 아예 처음부터 101이 오지 않는 것처럼 보였습니다. 앱 로그에는 가끔 ECONNRESET이 찍혔고, 브라우저는 1006(비정상 종료)만 남기고 끝났습니다.
제가 확인한 순서는 다음과 같습니다(매번 같지는 않지만, 이때는 이 순서였습니다).
- 코드 문제인지 먼저 의심했지만, 같은 빌드가 스테이징에서 동작한다는 사실이 답을 줬습니다. 배포 환경의 경계 쪽 문제라는 뜻입니다.
- 앞단 프록시(Nginx, 로드밸런서) 설정을 확인했습니다.
proxy_http_version 1.1,Upgrade와Connection헤더 전달, WebSocket용location이 실제 요청 경로에 매칭되는지를 봤습니다. 경로 접두사 하나가 달라서 WebSocket 요청이 일반location으로 빠지는 경우가 의외로 흔합니다. - idle / read timeout을 확인했습니다. 앱은 살아 있어도 중간 프록시가 조용한 연결을 잘라 내고 있었습니다. Ping 주기를 짧게 조정하고 프록시 timeout과 맞췄습니다.
- wss 사용 여부와 혼합 콘텐츠를 확인했습니다. HTTPS 페이지에서
ws://로 연결하면 브라우저가 차단하며, 콘솔 메시지가 꽤 정직하게 알려 줍니다. - 그다음에야 앱을 봤습니다. 메모리 누수,
send폭주, 특정 인스턴스에만 연결이 몰리는지(스티키) 등입니다.
결론은 역프록시의 WebSocket 프록싱 설정 누락과 timeout 불일치가 겹친 경우였습니다. 스테이징은 프록시를 거치지 않고 앱에 직접 붙어 있었기 때문에 문제가 재현되지 않았던 것입니다. 이 일을 겪은 뒤로 “연결이 끊기는 이유는 대부분 TCP가 아니라 그 위에 있는 누군가”라는 생각으로, 장애가 나면 Network 탭과 프록시 access log부터 엽니다. 웹 코드가 아니라 인프라 문제인 경우가 많습니다. 스테이징 환경도 프로덕션과 같은 프록시 계층을 거치도록 맞춰 두는 것이 이후의 가장 큰 교훈이었습니다.
WebSocket은 답이 아니라 도구입니다. 필요할 때는 이 정도의 운영 부담까지 감수할 준비가 되어 있어야 합니다.
Node.js 서버와 브라우저의 기본 WebSocket API
서버 (Node.js)
// server.ts
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => {
console.log('Client connected');
ws.on('message', (data) => {
console.log('Received:', data.toString());
// 에코
ws.send(`Echo: ${data}`);
});
ws.on('close', () => {
console.log('Client disconnected');
});
ws.send('Welcome!');
});
console.log('WebSocket server running on ws://localhost:8080');
ws 라이브러리의 message 이벤트는 문자열이 아니라 Buffer(또는 Buffer 배열)를 전달하므로, 위처럼 toString()으로 변환해야 합니다. 버전 8부터 텍스트 메시지도 Buffer로 오도록 바뀌었고, 두 번째 인자 isBinary로 텍스트와 바이너리를 구분합니다. 이 서버에는 하트비트, 메시지 크기 제한, Origin 검사가 없으므로 실제 서비스라면 maxPayload 옵션과 verifyClient(또는 HTTP 서버의 upgrade 이벤트에서 직접 인증)를 추가해야 합니다. ws.send()는 소켓 버퍼에 쓰기만 하고 반환하므로, 느린 클라이언트에게 계속 보내면 ws.bufferedAmount가 끝없이 커집니다. 브로드캐스트 전에 이 값이 임계치를 넘는 클라이언트는 건너뛰거나 연결을 끊는 것이 백프레셔 처리의 기본입니다.
클라이언트 (브라우저)
// client.ts
const ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => {
console.log('Connected');
ws.send('Hello Server!');
};
ws.onmessage = (event) => {
console.log('Received:', event.data);
};
ws.onerror = (error) => {
console.error('Error:', error);
};
ws.onclose = () => {
console.log('Disconnected');
};
브라우저 API는 이것이 전부이고, 재연결 기능이 없습니다. onclose가 호출되면 그 객체는 다시 쓸 수 없으므로 새 WebSocket을 만들어야 합니다. onopen 전에 send()를 호출하면 InvalidStateError가 나므로, 연결이 열리기 전에 보낼 메시지는 큐에 모아 두었다가 onopen에서 흘려보내는 식으로 처리합니다. 재연결, 큐잉, 하트비트, 폴백을 직접 만들기 부담스러울 때 Socket.io 같은 라이브러리를 쓰게 됩니다.
Socket.io 서버와 클라이언트
Socket.io는 WebSocket 위에 이벤트 이름, 방(room), 자동 재연결, 확인 응답(acknowledgement), long-polling 폴백을 얹은 별도 프로토콜입니다. 여기서 흔히 하는 오해가 “Socket.io = WebSocket”입니다. Socket.io 서버에는 일반 WebSocket 클라이언트나 다른 언어의 표준 WebSocket 라이브러리로 붙을 수 없고, 반드시 Socket.io 클라이언트(또는 그 프로토콜을 구현한 라이브러리)를 써야 합니다. 모바일 앱이나 외부 파트너가 붙어야 하는 API라면 이 제약이 선택을 뒤집을 수도 있습니다.
설치
# 서버
npm install socket.io
# 클라이언트
npm install socket.io-client
서버
// server.ts
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: 'http://localhost:3000',
methods: ['GET', 'POST'],
},
});
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
socket.on('message', (data) => {
console.log('Message:', data);
// 아래 세 줄은 전송 대상별 API 비교용 예시입니다.
// 실제로는 셋 중 하나만 사용합니다 (모두 쓰면 중복 전송).
// 모든 클라이언트에게 전송 (발신자 포함)
io.emit('message', data);
// 발신자 제외
socket.broadcast.emit('message', data);
// 특정 클라이언트에게 (targetSocketId는 앱에서 관리하는 대상 소켓 ID)
socket.to(targetSocketId).emit('message', data);
});
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
});
});
httpServer.listen(3000, () => {
console.log('Server running on :3000');
});
io.emit, socket.broadcast.emit, socket.to(id).emit은 전송 대상만 다릅니다. 모든 소켓은 자동으로 자기 socket.id와 같은 이름의 방에 들어가 있어서, 특정 소켓에게 보내는 것도 내부적으로는 방 브로드캐스트입니다. 다만 socket.id는 재연결할 때마다 바뀌므로, “특정 사용자에게 보내기”를 구현할 때 소켓 ID를 DB에 저장하면 곧 쓸모없어집니다. 연결 시 인증된 사용자 ID로 socket.join('user:' + userId)처럼 방에 넣고 그 방으로 보내면, 사용자가 여러 탭을 열었거나 재연결해도 자연스럽게 동작합니다.
클라이언트
// client.ts
import { io } from 'socket.io-client';
const socket = io('http://localhost:3000');
socket.on('connect', () => {
console.log('Connected:', socket.id);
});
socket.on('message', (data) => {
console.log('Received:', data);
});
socket.emit('message', { text: 'Hello!' });
연결 전에 emit해도 에러가 나지 않는 것은 Socket.io 클라이언트가 연결될 때까지 이벤트를 버퍼에 쌓아 두기 때문입니다. 편리하지만, 오프라인 상태가 길어지면 재연결 순간 쌓인 이벤트가 한꺼번에 전송된다는 뜻이기도 합니다. “타이핑 중” 같은 휘발성 이벤트는 socket.volatile.emit으로 보내면 연결되지 않은 상태에서는 버려집니다.
예제: 채팅 앱
서버
// server.ts
import { Server } from 'socket.io';
const io = new Server(3000, {
cors: { origin: '*' },
});
interface Message {
user: string;
text: string;
timestamp: string;
}
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
// 방 입장
socket.on('join', (room: string) => {
socket.join(room);
socket.to(room).emit('user-joined', socket.id);
console.log(`${socket.id} joined ${room}`);
});
// 메시지 전송
socket.on('message', (data: { room: string; message: Message }) => {
io.to(data.room).emit('message', data.message);
});
// 타이핑 중
socket.on('typing', (room: string) => {
socket.to(room).emit('typing', socket.id);
});
// 연결 해제
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
});
});
이 서버는 구조를 보여 주기 위한 최소 예제라 운영 환경에 그대로 쓰기에는 빈틈이 있습니다. cors: { origin: '*' }는 개발용이고, 클라이언트가 보낸 message.user를 그대로 방송하므로 누구나 다른 사람 이름으로 메시지를 보낼 수 있습니다. 발신자는 클라이언트 입력이 아니라 연결 시 인증된 정보(socket.data.user 등)로 서버가 채워야 합니다. 또 join에서 방 이름을 검증하지 않으면 권한 없는 방에도 들어갈 수 있습니다. TypeScript 타입 주석(room: string)은 런타임 검증이 아니므로, 네트워크로 들어온 값은 실제로 문자열인지, 길이가 적당한지 확인해야 합니다. 메시지를 저장하지 않으므로 새로 들어온 사용자는 과거 대화를 볼 수 없고, 잠깐 끊겼던 사용자는 그 사이 메시지를 놓칩니다. 이 부분은 DB에 저장하고 입장·재연결 시 마지막 메시지 이후를 조회해 채우는 방식으로 보완합니다.
클라이언트 (React)
// Chat.tsx
import { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import type { Message } from './types'; // 서버와 공유하는 Message 타입
let socket: Socket;
export function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const room = 'general';
useEffect(() => {
socket = io('http://localhost:3000');
socket.emit('join', room);
socket.on('message', (message: Message) => {
setMessages((prev) => [...prev, message]);
});
socket.on('typing', (userId: string) => {
console.log(`${userId} is typing...`);
});
return () => {
socket.disconnect();
};
}, []);
const sendMessage = () => {
if (input.trim()) {
const message: Message = {
user: 'Me',
text: input,
timestamp: new Date().toISOString(),
};
socket.emit('message', { room, message });
setInput('');
}
};
const handleTyping = () => {
socket.emit('typing', room);
};
return (
<div>
<div>
{messages.map((msg, i) => (
<div key={i}>
<strong>{msg.user}:</strong> {msg.text}
</div>
))}
</div>
<input
value={input}
onChange={(e) => {
setInput(e.target.value);
handleTyping();
}}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
/>
<button onClick={sendMessage}>Send</button>
</div>
);
}
React에서 소켓을 다룰 때 가장 자주 만나는 문제는 개발 모드에서 메시지가 두 번씩 보이는 현상입니다. React 18의 StrictMode는 개발 중 effect를 마운트→언마운트→마운트로 두 번 실행해 정리(cleanup) 코드가 올바른지 확인합니다. 위 코드처럼 cleanup에서 disconnect()를 제대로 호출하면 문제가 없지만, cleanup을 빠뜨리면 소켓이 두 개 열리고 리스너도 두 번 등록됩니다. 연결을 유지한 채 리스너만 바꾸는 구조라면 cleanup에서 socket.off('message', handler)로 등록한 핸들러를 정확히 해제해야 합니다.
handleTyping은 키 입력마다 이벤트를 보내므로, 빠르게 타이핑하면 초당 수십 개의 이벤트가 방 전체로 퍼집니다. 실제로는 1~2초 간격으로 스로틀링하고, 앞서 설명한 volatile.emit을 쓰는 편이 좋습니다. 또 목록의 key={i}는 메시지가 뒤에만 추가되는 이 예제에서는 괜찮지만, 이전 메시지를 앞에 끼워 넣는 기능을 붙이면 렌더링이 꼬이므로 서버가 부여한 메시지 ID를 key로 쓰는 편이 안전합니다.
Redis 어댑터로 여러 서버에 확장하기
서버가 한 대일 때는 io.to(room).emit()이 메모리 안의 방 목록만 보면 됩니다. 서버를 두 대로 늘리면, A 서버에 연결된 사용자가 보낸 메시지는 A 서버의 메모리에 있는 방 멤버에게만 전달되고 B 서버에 붙은 사람은 받지 못합니다. Redis 어댑터는 각 서버의 브로드캐스트를 Redis Pub/Sub으로 다른 서버에 전달해 이 간극을 메웁니다.
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const io = new Server(3000);
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
io.adapter(createAdapter(pubClient, subClient));
console.log('Redis adapter connected');
});
// 이제 여러 서버 인스턴스가 메시지를 공유합니다
subClient를 따로 만드는 이유는 Redis 연결이 SUBSCRIBE 상태에 들어가면 일반 명령을 보낼 수 없기 때문입니다. 발행용과 구독용 연결을 분리해야 합니다.
Redis 어댑터를 붙여도 해결되지 않는 것이 두 가지 있습니다. 첫째, 스티키 세션은 여전히 필요할 수 있습니다. Socket.io는 기본적으로 HTTP long-polling으로 연결을 시작한 뒤 WebSocket으로 업그레이드하는데, 그 사이의 폴링 요청들이 서로 다른 서버로 분산되면 Session ID unknown 에러(HTTP 400)가 납니다. 어댑터는 브로드캐스트를 동기화할 뿐 연결 세션을 공유하지 않기 때문입니다. 로드밸런서에서 스티키 세션을 켜거나, 폴백이 필요 없다면 클라이언트에서 transports: ['websocket']으로 처음부터 WebSocket만 쓰게 하면 됩니다.
둘째, Redis Pub/Sub은 전달을 보장하지 않습니다. 구독자가 잠시 끊겨 있던 동안 발행된 메시지는 그대로 사라지고, 어댑터는 이를 재전송하지 않습니다. 채팅처럼 메시지 유실이 문제가 되는 서비스는 메시지를 DB에 먼저 저장하고, 실시간 전달은 “빠른 알림” 정도로 보는 구조가 안전합니다. 처음 수평 확장을 할 때 “Redis만 붙이면 끝”이라고 생각하기 쉬운데, 실제로는 세션 라우팅과 유실 복구까지 설계해야 비로소 완성됩니다.
WebSocket 요약
- RFC 6455: 101, opcode, 마스킹, Ping/Pong, close 코드 — 끊김 원인을 좁히는 지도
- WebSocket: 항상 필요하지는 않습니다. 양방향·지속 연결이 실제로 필요할 때 선택
- Socket.io: 재연결·방·폴백이 편하지만 별도 프로토콜이며, 운영 설정(스티키, 타임아웃)은 여전히 직접 챙겨야 함
- Room / 브로드캐스트 / Redis: 수평 확장의 기본 조합, 단 Pub/Sub은 유실 가능
- 지연: 환경마다 다르므로 광고 문구 대신 직접 측정
같이 보면 좋은 글
- Redis로 캐싱과 세션 관리: 데이터 타입, 캐싱 전략, Pub/Sub, 트랜잭션, API 응답 캐싱
- NestJS로 백엔드 만들기
- Kubernetes 핵심 오브젝트: Pod, Deployment, Service, ConfigMap·Secret, Ingress, 헬스체크, HPA
자주 묻는 질문 (FAQ)
Q. WebSocket과 Server-Sent Events 중 무엇이 더 좋은가요?
A. 어느 쪽이 더 좋다기보다 요구가 다르면 답이 갈립니다. 서버에서 클라이언트로만 데이터가 흐르면 SSE가 단순한 경우가 많습니다. 클라이언트도 자주 메시지를 보내야 하는 양방향 통신이라면 WebSocket이 자연스럽습니다. HTTP/1.1에서 SSE는 브라우저의 도메인당 연결 수 제한(보통 6개)에 걸릴 수 있다는 점도 고려해야 하며, HTTP/2 환경에서는 이 제약이 사실상 사라집니다.
Q. WebSocket과 HTTP/2는 어떻게 다른가요?
A. HTTP/2는 하나의 연결에서 여러 요청을 동시에 처리하지만 여전히 요청/응답의 세계입니다. 한 번 열린 채널에서 양쪽이 아무 때나 먼저 말하는 구조가 필요하면 WebSocket(또는 WebTransport 같은 다른 도구)을 검토하게 됩니다.
Q. 연결이 끊기면 어떻게 하나요?
A. Socket.io는 재연결을 기본으로 지원합니다. 네이티브 WebSocket이라면 백오프, 재시도, 상태 복구를 직접 구현해야 합니다. 어느 쪽이든 끊김은 드문 사고가 아니라 기본 시나리오로 보고, 재연결 시 놓친 메시지를 채우는 방법까지 설계해야 합니다.
Q. 프로덕션에서 써도 되나요?
A. 많은 서비스가 프로덕션에서 사용합니다. 다만 널리 쓰인다는 것이 추가 작업 없이 된다는 뜻은 아닙니다. 프록시 설정, 타임아웃, 인증, 스케일링까지 함께 준비해야 합니다.