WebSocket과 실시간 애플리케이션
HTTP와 WebSocket의 통신 차이를 이해하고 Nest 게이트웨이로 연결·메시지·방을 처리하는 실시간 흐름을 구현합니다.
지난 절에서는 NestJS 애플리케이션을 서버리스 환경에 배포하는 전략에 대해 알아보았습니다.
이제 12장의 두 번째 절로, 실시간 웹 애플리케이션의 핵심 기술인 WebSocket과 이를 NestJS에서 효과적으로 구현하는 방법에 대해 살펴보겠습니다.
기존의 HTTP는 요청-응답(Request-Response) 모델을 기반으로 하여 클라이언트가 서버에 요청을 보내야만 응답을 받을 수 있습니다.
하지만 채팅 애플리케이션, 실시간 게임, 주식 시세판, 협업 도구 등은 서버에서 클라이언트로 데이터를 즉시 푸시해야 하는 실시간 통신 기능이 필수적입니다.
WebSocket은 이러한 요구사항을 충족시키기 위해 등장한 기술입니다.
Nest · Socket.IO delivery design
Socket.IO 운영 확장 설계
본문은 메시지 전달과 room 참가의 기본 예제입니다. 인증·방 권한·중복 제거·저장·재접속 resume은 추가 설계이며, replica는 연결과 room 상태를 계속 보유합니다.
-
Handshake 인증
WSS와 명시적 origin allowlist를 적용하고 token을 검증해 서버가 socket identity를 만듭니다.
-
전송 전략 고정
websocket-only면 polling을 끕니다. polling을 유지한다면 cookie 기반 sticky routing을 별도로 구성합니다.
-
Room 권한
payload의 사용자 이름을 신뢰하지 않고 인증 identity가 허용한 room만 join하며, 새 room 전에 이전 room을 leave합니다.
-
멱등 ack
ack에 version을 넣고
(userId, messageId)중복은 같은 결과를 반환하며 다시 broadcast하지 않습니다. -
수평 fan-out
공식 Nest IoAdapter가 Redis adapter를 Socket.IO server에 붙여 다른 replica의 room으로 전달합니다.
-
재접속 resume
durable store의 증가 sequence를 기준으로 마지막 확인 offset 다음 메시지만 다시 전달합니다.
(userId, messageId) 저장과 중복 판단 뒤 새 메시지만 fan-out합니다. 송신 queue 상한과 처리 예산을 두고 queue depth·ACK 지연·reconnect·resume gap을 관찰합니다. Redis adapter는 room 전달 경로이며 durable delivery와 load balancer 연결 정책은 별도 계약입니다.
WebSocket이란?
WebSocket 실시간 애플리케이션을 다룰 때는 handshake, room 관리, backpressure, 재접속 기준을 함께 살펴봅니다.
WebSocket은 웹 브라우저와 웹 서버 간에 전이중(Full-duplex) 통신 채널을 제공하는 통신 프로토콜입니다.
HTTP와 달리, 한 번 연결이 수립되면 클라이언트와 서버가 독립적으로 언제든지 데이터를 주고받을 수 있습니다.
WebSocket의 특징- 지속적인 연결: HTTP도 keep-alive나 다중화로 연결을 재사용할 수 있습니다. WebSocket은 핸드셰이크 뒤 양방향 메시지 채널을 지속적으로 유지합니다.
- 전이중 통신: 클라이언트와 서버가 동시에 서로에게 데이터를 보낼 수 있습니다.
- 낮은 오버헤드: HTTP에 비해 헤더 정보가 매우 작아 통신 오버헤드가 적습니다. 이는 실시간으로 많은 양의 작은 데이터를 주고받을 때 효율적입니다.
- HTTP 호환: WebSocket 연결은 HTTP/1.1 업그레이드 헤더를 통해 시작됩니다. 즉, 기존 HTTP 포트(80 또는 443)를 사용하여 방화벽 문제를 피할 수 있습니다.
| 특징 | HTTP (Polling) | HTTP (Long Polling) | WebSocket |
|---|---|---|---|
| 연결 방식 | 클라이언트가 주기적으로 요청 | 클라이언트가 요청 후 서버가 데이터 발생 시 응답 | 지속적인 연결 (한 번 연결 후 유지) |
| 통신 방향 | 단방향 (클라이언트 -> 서버) | 단방향 (클라이언트 -> 서버, 서버가 지연 응답) | 전이중 (양방향 동시) |
| 오버헤드 | 높음 (요청/응답마다 전체 HTTP 헤더) | 높음 (요청/응답마다 전체 HTTP 헤더) | 낮음 (최초 핸드셰이크 후 프레임 헤더) |
| 지연 시간 | 김 (폴링 간격에 따라) | 짧음 (데이터 발생 즉시), 그러나 요청은 새로 보냄 | 매우 짧음 (실시간 푸시) |
| 복잡성 | 단순 | 서버 측 구현 복잡 | 클라이언트/서버 양측에 상태 관리 필요 |
| 용도 | 비실시간 데이터 업데이트 | 적은 빈도의 업데이트 | 채팅, 게임, 알림, 실시간 데이터 스트리밍 등 진정한 실시간 통신 |
NestJS에서 WebSocket 구현
NestJS는 @nestjs/websockets와 @nestjs/platform-socket.io (또는 @nestjs/platform-ws) 패키지를 통해 WebSocket 기능을 쉽게 구현하도록 지원합니다.
Socket.IO는 WebSocket 위에 구축된 라이브러리로, 브라우저 폴백(fallback), 자동 재연결, 이벤트 기반 통신 같은 기능을 제공해 실시간 애플리케이션 개발을 쉽게 만듭니다.
이 절에서는 Socket.IO를 중심으로 설명하겠습니다.
WebSocket 패키지 설치
npm install @nestjs/websockets @nestjs/platform-socket.io socket.io
npm install --save-dev @types/socket.io게이트웨이(Gateway) 생성
NestJS에서는 WebSocket 핸들러를 게이트웨이(Gateway)라고 부릅니다.
게이트웨이는 @WebSocketGateway() 데코레이터로 정의됩니다.
import {
WebSocketGateway,
SubscribeMessage,
MessageBody,
WebSocketServer,
ConnectedSocket,
OnGatewayInit,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
@WebSocketGateway({
cors: {
origin: '*', // 모든 도메인에서의 접속 허용 (운영 환경에서는 특정 도메인으로 제한 권장)
credentials: false, // 기본 데모는 교차 출처 쿠키 인증을 사용하지 않음
},
// path: '/socket.io', // Socket.IO 경로 설정 (선택 사항)
})
export class EventsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server; // Socket.IO 서버 인스턴스 주입
private readonly logger = new Logger(EventsGateway.name);
// 게이트웨이 초기화 시 호출
afterInit(server: Server) {
this.logger.log('WebSocket Gateway initialized!');
}
// 클라이언트 연결 시 호출
handleConnection(@ConnectedSocket() client: Socket, ...args: any[]) {
this.logger.log(`Client connected: ${client.id}`);
// 연결된 클라이언트에게 메시지 전송
client.emit('welcome', `Welcome, ${client.id}!`);
}
// 클라이언트 연결 해제 시 호출
handleDisconnect(@ConnectedSocket() client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}
// "message" 이벤트를 구독 (클라이언트가 "message" 이벤트를 보낼 때 실행)
@SubscribeMessage('message')
handleMessage(
@MessageBody() data: string, // 클라이언트로부터 받은 메시지 데이터
@ConnectedSocket() client: Socket, // 연결된 클라이언트 소켓 인스턴스
): string {
this.logger.log(`Message received from ${client.id}: ${data}`);
// 모든 연결된 클라이언트에게 메시지 브로드캐스트
this.server.emit('message', `[${client.id}] ${data}`);
return 'Message received!'; // 클라이언트에게 직접 응답
}
// "joinRoom" 이벤트를 구독하여 특정 방에 조인
@SubscribeMessage('joinRoom')
handleJoinRoom(
@MessageBody('roomId') roomId: string,
@MessageBody('userName') userName: string,
@ConnectedSocket() client: Socket,
) {
client.join(roomId); // 클라이언트를 해당 룸에 조인
this.server.to(roomId).emit('roomMessage', `${userName} has joined room ${roomId}`);
this.logger.log(`${userName} joined room ${roomId} (client ID: ${client.id})`);
return `Joined room ${roomId}`;
}
// "chatMessage" 이벤트를 구독하여 특정 방에 메시지 전송
@SubscribeMessage('chatMessage')
handleChatMessage(
@MessageBody('roomId') roomId: string,
@MessageBody('message') message: string,
@MessageBody('userName') userName: string,
@ConnectedSocket() client: Socket,
) {
// 특정 방에 있는 클라이언트들에게만 메시지 전송
this.server.to(roomId).emit('roomMessage', `[${userName} in ${roomId}] ${message}`);
this.logger.log(`Chat message from ${userName} in ${roomId}: ${message}`);
return 'Message sent to room';
}
}@WebSocketGateway(): 이 클래스가 WebSocket 게이트웨이임을 선언합니다.cors옵션을 통해 클라이언트 도메인 제한을 설정할 수 있습니다.@WebSocketServer() server: Server;:Server타입의 Socket.IO 서버 인스턴스를 주입받습니다. 이 인스턴스를 통해 모든 클라이언트에게 메시지를 보내거나 특정 클라이언트/방에 메시지를 보낼 수 있습니다.OnGatewayInit,OnGatewayConnection,OnGatewayDisconnect: 게이트웨이의 라이프사이클 훅(hook)입니다. 초기화, 클라이언트 연결/연결 해제 시 로직을 추가할 수 있습니다.@SubscribeMessage('eventName'): 클라이언트가eventName으로 이벤트를 보낼 때 이 메서드가 실행되도록 합니다.@MessageBody(): 이벤트 데이터(payload)를 가져옵니다. 특정 키의 값을 가져오려면@MessageBody('keyName')을 사용합니다.@ConnectedSocket(): 현재 연결된 클라이언트의Socket인스턴스를 가져옵니다.client.emit('eventName', data): 특정 클라이언트에게 메시지를 보냅니다.this.server.emit('eventName', data): 모든 연결된 클라이언트에게 메시지를 브로드캐스트합니다.client.join(roomId): 클라이언트를 특정 방(room)에 조인시킵니다.this.server.to(roomId).emit('eventName', data): 특정 방에 있는 클라이언트들에게만 메시지를 보냅니다.
본문 Gateway는 userName·roomId를 직접 받고 인증·room 권한·중복 제거를 구현하지 않습니다. 새 방 참가 전에 이전 방을 leave하지 않으며, 클라이언트도 ACK 콜백을 넘기지 않습니다. 그림의 versioned ACK·durable store·resume은 추가 설계 범위입니다.
방 이름과 실제 참가 집합을 구별하기방(room)은 서버가 관리하는 socket 참가 집합입니다. 원 클라이언트의 currentRoomId 값은 화면에서 마지막으로 고른 방 이름입니다. joinRoom은 새 방에 참가하지만 이전 방을 나가는 코드는 없습니다. Socket.IO의 방 문서에서 서버의 io.to(room).emit은 해당 방 참가자에게 전달하며, 송신 socket을 제외하는 socket.to(room).emit과 수신 집합이 다릅니다.
A·B가 general, C가 tech에 참가했다고 합시다. A가 tech를 선택하면 A는 general과 tech 양쪽에 남고 currentRoomId 값은 tech가 됩니다. 원 서버의 server.to('tech').emit 수신자는 A·C이고 B는 받지 않습니다. server.emit('message', ...)는 세 연결 모두에 보냅니다. 이후 general 메시지가 A에게 도착해도 클라이언트는 현재의 tech 이름으로 화면에 표시할 수 있습니다. 방 선택 UI와 서버 참가 상태를 같은 것으로 읽으면 이 현상을 설명할 수 없습니다.
독립 확인: 위 상태에서 B도 tech에 참가합니다. 원 chatMessage 처리로 tech 메시지를 보낼 때 수신자는 누구인가요? A가 보내되 서버 코드를 client.to(roomId).emit으로 바꾸면 어떻게 달라지나요? 원 클라이언트가 join 이벤트를 콜백 없이 보낼 때 서버의 반환 문자열을 화면 메시지로 받는다고 결론 내릴 수 있나요?
풀이 확인
원 server.to에서는 tech 참가자인 A·B·C 모두가 수신 대상입니다. A의 client.to로 바꾼 변형에서는 송신 socket A를 제외하므로 B·C가 대상입니다. 이는 실제 연결·전달 실패를 제외한 참가 집합 계산입니다. Nest 게이트웨이의 반환 값은 해당 이벤트의 ACK로 처리될 수 있지만, 원 클라이언트는 ACK 콜백을 전달하지 않았으므로 이를 새 화면 메시지로 받는다고 결론 내리지 않습니다. Nest의 ACK 설명을 별도 roomMessage 이벤트와 구분하세요. 이전 방 탈퇴·서버 인증 신원·방 권한·내구 저장은 앞의 운영 확장 그림에 추가한 계약이며 원 기본 예제에 구현된 기능은 아닙니다.
모듈에 게이트웨이 등록
게이트웨이도 NestJS 모듈의 providers 배열에 등록되어야 합니다.
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';
@Module({
providers: [EventsGateway], // 게이트웨이 등록
})
export class EventsModule {}import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { EventsModule } from './events/events.module'; // EventsModule 임포트
@Module({
imports: [EventsModule], // EventsModule 추가
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}애플리케이션 시작
NestJS 애플리케이션을 평소처럼 시작합니다.
WebSocket 서버는 HTTP 서버와 함께 실행됩니다.
npm run start:dev이제 NestJS 애플리케이션은 기본적으로 HTTP 서버(예: 3000번 포트)와 함께 WebSocket 서버를 구동합니다.
클라이언트 (JavaScript) 예시
브라우저에서 NestJS WebSocket 서버에 연결하고 메시지를 주고받는 간단한 JavaScript 코드입니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NestJS WebSocket Chat</title>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
</head>
<body>
<h1>NestJS WebSocket Chat</h1>
<input type="text" id="usernameInput" placeholder="Enter your name" value="Guest">
<input type="text" id="messageInput" placeholder="Type a message...">
<button id="sendMessageBtn">Send Message</button>
<br><br>
<input type="text" id="roomInput" placeholder="Enter room ID" value="general">
<button id="joinRoomBtn">Join Room</button>
<br><br>
<div id="messages"></div>
<script>
const socket = io('http://localhost:3000'); // NestJS 서버 주소
const usernameInput = document.getElementById('usernameInput');
const messageInput = document.getElementById('messageInput');
const sendMessageBtn = document.getElementById('sendMessageBtn');
const roomInput = document.getElementById('roomInput');
const joinRoomBtn = document.getElementById('joinRoomBtn');
const messagesDiv = document.getElementById('messages');
let currentUserName = usernameInput.value;
let currentRoomId = roomInput.value;
// 연결 이벤트
socket.on('connect', () => {
console.log('Connected to WebSocket server:', socket.id);
// 연결 시 초기 룸 조인
socket.emit('joinRoom', { roomId: currentRoomId, userName: currentUserName });
});
// 서버에서 'welcome' 메시지 수신
socket.on('welcome', (data) => {
console.log('Server welcome:', data);
addMessage(`[SERVER] ${data}`);
});
// 서버에서 'message' 메시지 수신 (모든 클라이언트에 브로드캐스트된 메시지)
socket.on('message', (msg) => {
addMessage(`[GLOBAL] ${msg}`);
});
// 서버에서 'roomMessage' 메시지 수신 (특정 방에 브로드캐스트된 메시지)
socket.on('roomMessage', (msg) => {
addMessage(`[ROOM ${currentRoomId}] ${msg}`);
});
// 연결 해제 이벤트
socket.on('disconnect', () => {
console.log('Disconnected from WebSocket server');
});
// 메시지 전송 버튼 클릭 이벤트
sendMessageBtn.addEventListener('click', () => {
const message = messageInput.value;
if (message.trim()) {
// 'chatMessage' 이벤트를 통해 현재 방으로 메시지 전송
socket.emit('chatMessage', { roomId: currentRoomId, message: message, userName: currentUserName });
messageInput.value = '';
}
});
// 방 조인 버튼 클릭 이벤트
joinRoomBtn.addEventListener('click', () => {
currentUserName = usernameInput.value;
currentRoomId = roomInput.value;
socket.emit('joinRoom', { roomId: currentRoomId, userName: currentUserName });
addMessage(`You joined room: ${currentRoomId}`);
});
// 메시지를 화면에 추가하는 함수
function addMessage(msg) {
const p = document.createElement('p');
p.textContent = msg;
messagesDiv.appendChild(p);
messagesDiv.scrollTop = messagesDiv.scrollHeight; // 스크롤 하단으로
}
</script>
</body>
</html>WebSocket 배포 시 고려사항
WebSocket 애플리케이션을 프로덕션 환경에 배포할 때는 몇 가지 중요한 고려사항이 있습니다.
- 로드 밸런서 (Load Balancer): Socket.IO의 HTTP long-polling을 여러 서버에서 사용할 때는 sticky session이 필요합니다. WebSocket 전송만 사용하면 같은 TCP 연결의 프레임은 한 서버로 가므로 이 이유로 별도 sticky session을 요구하지 않습니다. 즉 특정 클라이언트의 WebSocket 연결이 항상 동일 서버 인스턴스로 라우팅되어야 합니다.
AWS ALB는 sticky session을 지원하며, Kubernetes에서는 NodePort/LoadBalancer/Ingress 컨트롤러가 WebSocket 연결을 올바르게 처리하도록 설정해야 합니다.
-
스케일링: WebSocket 서버는 연결 수가 늘어날수록 메모리와 CPU 사용량이 증가합니다. 여러 인스턴스로 스케일 아웃할 경우, 각 인스턴스의 WebSocket 서버 간에 이벤트를 동기화해야 합니다.
Socket.IO는 이를 위해 Redis Pub/Sub 어댑터(Adapter)를 제공합니다.npm install @socket.io/redis-adapter redis npm install --save-dev @types/redissrc/main.ts (Redis 어댑터 설정 추가) import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { IoAdapter } from '@nestjs/platform-socket.io'; // IoAdapter 임포트 import { createAdapter } from '@socket.io/redis-adapter'; import { createClient } from 'redis'; async function bootstrap() { const app = await NestFactory.create(AppModule); // Redis 어댑터 설정 const pubClient = createClient({ url: `redis://localhost:6379` }); // 실제 Redis 서버 주소 const subClient = pubClient.duplicate(); await Promise.all([pubClient.connect(), subClient.connect()]); app.useWebSocketAdapter(new IoAdapter(app)); // 기본 어댑터 사용 const redisAdapter = createAdapter(pubClient, subClient); app.getHttpAdapter().getInstance().set('io adapter', redisAdapter); // Socket.IO 인스턴스에 어댑터 적용 await app.listen(3000); } bootstrap();
위 코드는 개념 초안입니다. Express 인스턴스의 set('io adapter', ...)만으로 Socket.IO 서버에 Redis adapter가 붙지는 않습니다. 실제 IoAdapter에서 생성한 Socket.IO 서버의 adapter에 연결해야 하며, 이 초안을 다중 서버 전달이 완성된 실행 코드로 해석하지 않습니다.
IoAdapter를 상속받아 커스텀 어댑터를 만들고 그 안에서 Redis 어댑터를 사용하는 것이 일반적입니다.
-
방화벽 및 보안 그룹: WebSocket 트래픽이 허용되도록 포트(기본 80/443, 또는 다른 포트)를 열어주어야 합니다.
-
인증 및 권한 부여: WebSocket 연결 시에도 사용자 인증 및 권한 부여가 중요합니다. JWT 토큰을 쿼리 파라미터나 헤더로 전달받아 연결을 수립하기 전에 검증하거나, 핸드셰이크 중에 검증 로직을 추가할 수 있습니다.
-
TLS/SSL (HTTPS/WSS): 프로덕션 환경에서는 항상 암호화된 통신(WSS: WebSocket Secure)을 사용해야 합니다. HTTP 로드 밸런서에서 TLS를 종료하고 백엔드로는 일반 WebSocket 통신을 하거나, End-to-End 암호화를 구현할 수 있습니다.
-
서버리스 환경에서의 WebSocket: AWS API Gateway WebSocket API, AWS IoT Core, Google Cloud Run with WebSockets, Azure Web PubSub 같은 관리형 서비스를 활용하면 서버리스 환경에서도 WebSocket을 쉽게 구현할 수 있습니다. Lambda 환경에서 HTTP 라우팅 어댑터를 함께 쓰는 경우에는
@codegenie/serverless-express같은 최신 어댑터 기준으로 이벤트 라우팅을 구성하는 것이 안정적입니다.
아래 다이어그램은 로드 밸런서, Redis Adapter, 인증, WSS를 WebSocket 운영 조건으로 묶어 정리한 것입니다.
마지막으로 WebSocket은 연결 자체보다 운영 계약을 유지하는 일이 더 중요합니다.
WebSocket은 실시간 상호작용이 필요한 현대 웹 애플리케이션의 핵심 기술입니다.
NestJS는 @nestjs/websockets와 Socket.IO 통합을 통해 복잡한 실시간 통신 기능을 간결하고 구조적으로 구현하도록 돕습니다.
로드 밸런서, 스케일링, 보안 같은 배포 고려사항을 충분히 이해하고 적용하면 안정적이고 확장 가능한 실시간 서비스를 구축할 수 있습니다.
연결·권한·전송 완료를 각각 확인하기
이 예제는 Nest의 Socket.IO 게이트웨이와 Socket.IO 브라우저 클라이언트를 짝으로 사용합니다. 브라우저의 기본 WebSocket 객체로 URL만 바꿔 연결하는 코드는 Socket.IO 프로토콜까지 구현하지 않습니다. 또한 아래 세 가지 조건은 서로 다른 곳에서 확인해야 합니다.
| 확인할 조건 | 확인하는 위치 | 이 예제만으로 확정할 수 없는 결과 |
|---|---|---|
| 브라우저의 교차 출처 요청 허용 | HTTP long-polling의 CORS 설정 | 사용자의 신원·방 접근 권한 |
| 방으로 메시지를 보낼 권한 | 서버가 검증한 사용자와 방의 접근 규칙 | 클라이언트가 보낸 roomId·userName의 진실성 |
| 재접속 후 메시지 복구 | 메시지 ID·저장·수신 위치·재전송 정책 | ACK 하나로 모든 수신자에게 영구 전달됨 |
기본 데모는 교차 출처 쿠키를 사용하지 않으므로 게이트웨이의 credentials를 false로 둡니다. 클라이언트에 withCredentials: true를 추가해 쿠키를 보내는 구성이면, 서버도 credentials: true와 허용할 구체적인 origin을 함께 설정해야 합니다. origin: '*'와 credential 요청은 함께 사용할 수 없습니다. CORS는 브라우저의 HTTP long-polling에 적용되며 WebSocket 연결 자체의 접근 제어·인증을 대신하지 않습니다. Socket.IO CORS 문서
Redis 어댑터를 적용할 실제 대상
앞의 초안에서 Express에 set('io adapter', ...)를 호출해도 Socket.IO 서버의 어댑터가 연결되지 않습니다. 아래 클래스는 Nest의 IoAdapter가 만든 Socket.IO 서버에 adapter(...)를 적용하는 부분입니다. Redis publisher/subscriber 연결은 기존 bootstrap에서 먼저 완료하고 이 클래스를 사용합니다.
import { INestApplicationContext } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { Server, ServerOptions } from 'socket.io';
export class RedisIoAdapter extends IoAdapter {
constructor(
app: INestApplicationContext,
private readonly redisAdapter: ReturnType<typeof createAdapter>,
) {
super(app);
}
createIOServer(port: number, options?: ServerOptions): Server {
const server = super.createIOServer(port, options) as Server;
server.adapter(this.redisAdapter);
return server;
}
}기존 bootstrap에 이 클래스의 import를 추가하고, pubClient·subClient 연결 이후부터 app.listen 이전까지의 어댑터 설정을 app.useWebSocketAdapter(new RedisIoAdapter(app, createAdapter(pubClient, subClient)))로 교체합니다. 기본 IoAdapter 등록과 Express의 set('io adapter', ...)는 이 등록으로 대체합니다. 이 코드만으로 Redis 연결 종료 처리나 운영 환경 설정까지 완성되는 것은 아닙니다.
Redis 어댑터는 다른 서버에 연결된 클라이언트까지 패킷을 전달하도록 돕습니다. HTTP long-polling을 사용하는 여러 서버 구성에는 해당 세션의 요청이 같은 서버로 가는 sticky session도 필요합니다. 기본 Redis Pub/Sub 어댑터는 끊긴 클라이언트의 메시지를 영구 저장하는 큐가 아닙니다. Redis 어댑터 문서
예제: 방에 가입하지 않은 발신자의 요청
클라이언트 M이 secret 방에 가입하지 않았지만 chatMessage에 roomId: 'secret'과 userName: 'Admin'을 넣었다고 가정합니다. 현재 handleChatMessage는 가입 여부·접근 권한·사용자 이름을 검사하지 않고 server.to(roomId).emit(...)를 호출합니다. 따라서 호출이 핸들러까지 도달했다면 secret 방의 수신자들을 대상으로 방송을 시도합니다. 발신자 M이 그 방의 메시지를 받을 수 있는지는 별개입니다.
실제 앱에서는 검증한 신원으로 발신자 이름을 만들고, 해당 사용자가 방에 보낼 수 있는지를 검사한 뒤 방송해야 합니다. 단순한 가입 여부 검사도 전체 업무 권한을 대신하지는 않습니다. 같은 요청을 재시도했을 때 중복 처리를 막으려면 메시지 ID와 서버의 중복 처리 정책이 추가로 필요합니다.
조건을 바꿔 보는 추가 연습
- 페이지에서
withCredentials: true를 켰는데 서버는origin: '*'입니다. 구체적인 origin으로 바꾸면 방의 권한 검사도 완료될까요? - 서버 A와 B에 Redis 어댑터를 올바르게 등록했습니다. HTTP long-polling 세션의 다음 요청이 B로 이동하면 Redis가 세션을 자동 공유해 주나요? 클라이언트가 끊긴 동안의 방송도 기본 어댑터가 다시 보내 주나요?
- M이
chatMessage를 같은 내용으로 두 번 호출했고 두 호출 모두 현재 핸들러까지 도달했습니다. 방송 호출은 몇 번 발생하나요? 첫 ACK가 유실되어 재시도했다면 “정확히 한 번 저장·전달됐다”는 결과를 얻기 위해 무엇을 추가해야 하나요?
풀이
- credential 요청에는 구체적인 허용 origin과 서버의
credentials: true를 맞춰야 합니다. 이것은 브라우저 요청 허용 조건이며 사용자의 방 접근 권한과 다릅니다. 서버에서 인증한 사용자에게 발신 권한이 있는지를 별도로 검사해야 합니다. - Redis 어댑터의 방송 중계가 서버 A의 HTTP 세션 상태를 B에 만들어 주지는 않습니다. polling 요청은 sticky session으로 같은 서버에 보내야 합니다. 기본 Redis Pub/Sub 어댑터는 끊긴 클라이언트용 메시지를 영구 보관하지 않으므로, 재접속 복구에는 저장된 메시지와 수신 위치를 이용하는 별도 정책이 필요합니다.
- 현재 코드에는 중복 ID 검사가 없어 두 번 방송을 호출합니다. ACK 유실은 첫 처리의 실패를 확정하지 않습니다. 서버는 메시지 ID를 기준으로 저장·중복 처리를 제어하고, 수신자는 필요한 수신 위치와 중복 제거를 구현해야 합니다. Socket.IO의 기본 전달 보장은 추가 재전송·저장 정책을 대신하지 않습니다. Socket.IO 전달 보장 문서