WebSocket 실시간 통신: RFC 6455 업그레이드와 프레임, Socket.io 채팅, Redis 확장, 프로덕션 디버깅
이 글의 핵심
WebSocket 핸드셰이크와 프레임 구조, 브라우저 API와 Socket.io로 채팅 만들기, 서버를 늘릴 때 Redis와 스티키 세션이 필요한 이유, 프록시 설정과 타임아웃 때문에 프로덕션에서만 연결이 끊길 때 어디부터 확인할지를 정리합니다.
들어가며: WebSocket이 필요한 경우와 아닌 경우
WebSocket 사용법보다 먼저 짚어 둘 것은 WebSocket이 항상 답은 아니라는 점입니다. 알림처럼 서버에서 클라이언트로만 흐르면 되는 데이터라면 SSE(Server-Sent Events)나 짧은 폴링이 운영, 디버깅, 프록시 통과 측면에서 더 싸게 먹히는 경우가 많습니다. “실시간”이라는 요구가 나왔다고 곧장 소켓부터 열면, 연결 상태 관리와 스케일링이라는 비용을 나중에 치르게 됩니다.
그럼에도 양방향 통신, 낮은 지연, 연결 유지가 실제로 필요하다면 WebSocket은 여전히 가장 유력한 후보입니다. 아래에서는 그때 쓰는 기본 API와 Socket.io 채팅 예제, 여러 서버로 늘릴 때의 구조, 그리고 프로덕션에서만 연결이 끊길 때의 점검 순서를 함께 다룹니다.
폴링이 비효율적인 이유부터 보겠습니다. 1초마다 API를 호출하면 대부분의 응답이 “변경 없음”인데도 매번 HTTP 헤더, 인증 검사, DB 조회가 반복됩니다. WebSocket은 연결 하나를 오래 유지하므로 변경이 있을 때만 데이터가 오갑니다. 다만 사용자가 수만 명이면 그만큼의 연결을 서버가 계속 들고 있어야 한다는 반대편 비용이 생깁니다.
지연도 문제입니다. 폴링은 구조상 폴링 간격만큼 늦을 수 있고, 간격을 줄이면 부하가 늘고 늘리면 지연이 커집니다. WebSocket은 변경이 생기는 즉시 서버가 푸시하는 방식으로 이 딜레마를 풉니다.
협업 도구나 게임처럼 양쪽에서 입력이 자주 오가는 경우에는 양방향 채널이 자연스럽습니다. 다만 동시 편집의 충돌 해결은 CRDT나 OT 같은 별도 알고리즘의 몫이고, WebSocket은 그 데이터를 실어 나르는 파이프일 뿐입니다.
하나의 TCP 연결로 주고받는 양방향 메시지
핵심만
WebSocket은 하나의 TCP 연결 위에서 서버와 클라이언트가 양방향으로 메시지를 주고받는 프로토콜입니다. 특징은 대략 다음과 같습니다.
- 양방향: 서버와 클라이언트 모두 먼저 메시지를 보낼 수 있습니다.
- 연결 재사용: 한 번 연결하면 메시지마다 HTTP 요청/응답을 새로 만들지 않습니다.
- 낮은 오버헤드: 연결 후에는 메시지당 2~14바이트의 프레임 헤더만 붙습니다(실제 지연은 네트워크 환경에 따라 다르므로 직접 측정해야 합니다).
- 브라우저가 기본 API(
WebSocket)를 제공합니다.
HTTP와 비교하면, HTTP는 기본적으로 클라이언트가 묻고 서버가 답하는 구조이고, WebSocket은 HTTP로 한 번 업그레이드한 뒤 그 소켓을 계속 열어 둔 채 양쪽이 자유롭게 이야기하는 구조입니다. HTTP/2는 하나의 연결에서 여러 요청을 동시에 처리하지만 여전히 요청/응답 모델이므로, 서버가 아무 때나 먼저 말해야 하는 요구를 대신하지는 못합니다.
선택 기준을 정리하면, 서버에서 클라이언트로만 흐르는 데이터는 SSE를 먼저 검토하고, 가끔만 갱신되는 데이터라면 캐시와 짧은 폴링도 충분히 좋은 선택입니다. SSE는 일반 HTTP 응답이라 기존 인증 미들웨어, 로깅, HTTP/2 멀티플렉싱을 그대로 쓰고, 브라우저가 재연결과 Last-Event-ID 전송까지 자동으로 처리합니다. WebSocket은 이 모든 것을 직접 만들어야 한다는 점을 비용에 포함해야 합니다. 반대로 HTTP/1.1에서는 SSE 연결 하나하나가 브라우저의 도메인당 동시 연결 수 제한(보통 6개)을 차지하므로 탭을 여러 개 여는 서비스라면 걸릴 수 있는데, HTTP/2에서는 하나의 연결에 다중화되어 이 제약이 사실상 사라집니다.
프로토콜 내부: 업그레이드·프레임·하트비트 (RFC 6455)
ws, Socket.io, 브라우저 WebSocket이 프레임과 마스킹은 대신 처리해 줍니다. 하지만 101 응답이 오지 않음, 중간에 조용히 끊김, 갑자기 1006 코드 같은 증상은 결국 RFC 6455의 뼈대를 알아야 원인을 좁힐 수 있습니다. 특히 앞단에 프록시가 하나 끼는 순간 라이브러리가 처리해 주지 못하는 문제가 생깁니다.
그림: 실제 실행 결과 — Python websockets 라이브러리 서버에 RFC 6455 예시 키를 보낸 실제 응답. 101 Switching Protocols와 함께 표준 문서에 나온 값과 같은 Sec-WebSocket-Accept가 돌아온다. (환경: Ubuntu 24.04)
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이 짧으면 조용한 연결은 그냥 잘립니다. Nginx의 기본 proxy_read_timeout이 60초이므로 “정확히 60초마다 끊긴다”는 증상이 흔하고, Ping 주기(보통 20~30초)를 경로상의 가장 짧은 idle timeout보다 확실히 짧게 맞추면 해결됩니다. 끊기는 간격이 일정하다면 거의 항상 어딘가의 타임아웃 값입니다.
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 중 무엇으로 풀지를 먼저 정해야 합니다(아래 Redis 절 참고). 느린 클라이언트에게 send를 무한정 쌓지 않도록 백프레셔를 두고, 연결 수, 핸드셰이크 실패 수, 1006 같은 비정상 종료 비율을 지표로 수집해 두면 장애 원인을 좁히기 쉽습니다. 클라이언트 재연결은 지수 백오프에 무작위 지연(jitter)을 더하고, 마지막으로 받은 이벤트 ID를 보내 놓친 메시지를 다시 받는 식으로 상태를 맞춥니다.
재연결에 무작위 지연을 넣는 이유는 서버 재배포 때문입니다. 서버가 재시작되면 수천 개의 클라이언트가 동시에 끊기고, 모두 똑같이 1초 뒤 재연결을 시도하면 새 서버가 뜨자마자 핸드셰이크 폭주로 다시 쓰러집니다. 백오프 간격에 무작위 값을 섞어 재연결 시점을 흩어 놓아야 합니다.
스테이징은 되는데 프로덕션에서만 끊길 때
로컬과 스테이징에서는 잘 붙는데 프로덕션에서만 몇십 분 뒤 끊기거나 처음부터 101이 오지 않고, 서버 로그에는 가끔 ECONNRESET, 브라우저에는 1006만 남는 증상은 흔한 패턴입니다. 같은 빌드가 다른 환경에서 동작한다면 코드보다 배포 환경의 경계를 먼저 의심하는 것이 효율적이고, 대체로 다음 순서로 좁혀 갑니다.
- 앞단 프록시(Nginx, 로드밸런서) 설정을 확인합니다.
proxy_http_version 1.1,Upgrade와Connection헤더 전달, 그리고 WebSocket용location이 실제 요청 경로에 매칭되는지를 봅니다. 경로 접두사 하나가 달라서 WebSocket 요청이 일반location으로 빠지는 경우가 의외로 흔합니다. - 경로상의 idle/read timeout을 확인합니다. 앱은 살아 있어도 중간 프록시가 조용한 연결을 잘라 내는 경우라면, Ping 주기를 가장 짧은 timeout보다 짧게 조정합니다.
wss사용 여부와 혼합 콘텐츠를 확인합니다. HTTPS 페이지에서ws://로 연결하면 브라우저가 차단하며, 콘솔에 그 이유가 표시됩니다.- 그다음에 애플리케이션을 봅니다. 메모리 누수,
send폭주, 특정 인스턴스에만 연결이 몰리는지(스티키 세션) 등입니다.
스테이징에서 재현되지 않는 가장 흔한 이유는 스테이징이 프록시를 거치지 않고 앱에 직접 붙어 있는 구성입니다. 연결이 끊기는 원인은 TCP 자체보다 그 사이에 있는 프록시나 로드밸런서인 경우가 많으므로, 장애가 나면 브라우저 Network 탭과 프록시 access log부터 보고, 스테이징도 프로덕션과 같은 프록시 계층을 거치도록 맞춰 두는 것이 좋습니다.
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만 붙이면 끝”이라고 생각하기 쉬운데, 실제로는 세션 라우팅과 유실 복구까지 설계해야 비로소 완성됩니다.