WebSocket과 실시간 애플리케이션
HTTP와 WebSocket의 통신 차이를 이해하고 Nest 게이트웨이로 연결·메시지·방을 처리하는 실시간 흐름을 구현합니다.
앞 글에서는 NestJS를 서버리스 환경에 배포하는 방법을 알아보았습니다. 이 글에서는 실시간 웹 애플리케이션의 핵심 기술인 WebSocket을 NestJS에서 구현하는 방법을 살펴봅니다.
HTTP는 요청-응답 모델이라 클라이언트가 먼저 요청을 보내야만 응답을 받을 수 있습니다. 하지만 채팅, 실시간 게임, 주식 시세판, 협업 도구는 서버가 클라이언트에게 데이터를 바로 밀어 보내야 합니다. 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 연결 정책은 별도 계약입니다.
위 그림은 이 글의 기본 예제에 운영용 설계를 더한 모습입니다. 클라이언트는 WSS로 로드 밸런서를 거쳐 게이트웨이 복제본 가운데 하나에 연결되고, 게이트웨이는 검증한 신원과 방 권한으로 이벤트를 처리하며, Redis adapter로 다른 복제본의 방에도 메시지를 퍼뜨립니다. 메시지 ID와 순번은 저장소에 남겨 중복과 재접속을 처리합니다. 그림 위에 적힌 것처럼 인증, 방 권한, 중복 제거, 저장, 재접속 이어 받기는 이 글의 기본 예제에는 없는 추가 설계입니다.
WebSocket이란
WebSocket은 웹 브라우저와 웹 서버 사이에 전이중(full-duplex) 통신 채널을 여는 프로토콜입니다. HTTP와 달리, 한 번 연결이 맺어지면 클라이언트와 서버가 언제든 각자 데이터를 보낼 수 있습니다. 연결을 여는 handshake와 메시지 프레임의 구조는 네트워크 교재의 WebSocket과 실시간 통신에서 자세히 다룹니다.
- 지속적인 연결: HTTP도 keep-alive나 다중화로 연결을 다시 쓸 수 있지만, WebSocket은 handshake 뒤에 양방향 메시지 채널을 계속 유지합니다.
- 전이중 통신: 클라이언트와 서버가 동시에 서로에게 데이터를 보낼 수 있습니다.
- 낮은 오버헤드: 메시지마다 붙는 헤더가 HTTP보다 훨씬 작아, 작은 데이터를 자주 주고받을 때 효율적입니다.
- HTTP 호환: WebSocket 연결은 HTTP/1.1의 Upgrade 헤더로 시작하므로 기존 HTTP 포트(80, 443)를 그대로 쓸 수 있습니다.
HTTP 폴링, 롱 폴링, WebSocket은 다음과 같이 비교할 수 있습니다.
| 특징 | HTTP 폴링 | HTTP 롱 폴링 | WebSocket |
|---|---|---|---|
| 연결 방식 | 클라이언트가 주기적으로 요청 | 요청 뒤 서버가 데이터가 생기면 응답 | 한 번 연결한 뒤 유지 |
| 통신 방향 | 클라이언트에서 서버로 | 클라이언트에서 서버로, 서버가 늦게 응답 | 양방향 동시 |
| 오버헤드 | 높음(요청·응답마다 HTTP 헤더) | 높음(요청·응답마다 HTTP 헤더) | 낮음(handshake 뒤 프레임 헤더) |
| 지연 시간 | 폴링 간격만큼 김 | 짧지만 요청을 매번 새로 보냄 | 매우 짧음 |
| 복잡성 | 단순 | 서버 구현이 복잡 | 양쪽 모두 연결 상태 관리 필요 |
| 용도 | 실시간이 아닌 업데이트 | 드문 업데이트 | 채팅, 게임, 알림, 실시간 스트리밍 |
NestJS에서 WebSocket 구현
NestJS는 @nestjs/websockets와 @nestjs/platform-socket.io(또는 @nestjs/platform-ws) 패키지로 WebSocket 기능을 지원합니다. Socket.IO는 WebSocket 위에 만든 라이브러리로, 연결이 어려울 때의 대체 전송(HTTP long-polling), 자동 재연결, 이벤트 기반 통신을 제공합니다. 이 글은 Socket.IO를 중심으로 설명합니다.
패키지 설치
npm install @nestjs/websockets@12 @nestjs/platform-socket.io@12@nestjs/platform-socket.io는 Socket.IO 4.8을 함께 설치하며, Socket.IO는 TypeScript 타입을 직접 제공하므로 @types/socket.io는 필요하지 않습니다.
Gateway 만들기
NestJS에서는 WebSocket handler를 게이트웨이(gateway)라고 부르며, @WebSocketGateway() 데코레이터로 정의합니다. 아래 게이트웨이는 세 부분으로 나눠 읽으면 됩니다. 클래스 선언부의 cors 옵션과 @WebSocketServer() 속성, 연결 수명 주기에 맞춰 불리는 afterInit, handleConnection, handleDisconnect, 그리고 @SubscribeMessage()로 이벤트 이름마다 하나씩 연결한 세 handler입니다. 데코레이터에 포트를 주지 않았으므로 이 게이트웨이는 HTTP 서버와 같은 포트를 씁니다.
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({
cors: {
origin: '*', // 데모용입니다. 운영에서는 허용할 출처를 명시합니다.
credentials: false, // 기본 데모는 교차 출처 쿠키를 쓰지 않습니다.
},
})
export class EventsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer() server: Server; // Socket.IO 서버 인스턴스
private readonly logger = new Logger(EventsGateway.name);
// 게이트웨이 초기화 때 호출됩니다.
afterInit() {
this.logger.log('WebSocket Gateway initialized!');
}
// 클라이언트가 연결되면 호출됩니다.
handleConnection(client: Socket) {
this.logger.log(`Client connected: ${client.id}`);
client.emit('welcome', `Welcome, ${client.id}!`);
}
// 클라이언트 연결이 끊기면 호출됩니다.
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}
// 클라이언트가 "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!'; // 보낸 클라이언트에 ACK로 돌려줍니다.
}
// "joinRoom" 이벤트로 특정 방에 참가합니다.
@SubscribeMessage('joinRoom')
async handleJoinRoom(
@MessageBody('roomId') roomId: string,
@MessageBody('userName') userName: string,
@ConnectedSocket() client: Socket,
) {
await 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,
) {
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: Socket.IO 서버 인스턴스를 주입받습니다. 이것으로 모든 클라이언트나 특정 방에 메시지를 보낼 수 있습니다.OnGatewayInit,OnGatewayConnection,OnGatewayDisconnect: 게이트웨이의 초기화, 연결, 연결 해제 때 호출되는 수명 주기 hook입니다.@SubscribeMessage('eventName'): 클라이언트가eventName이벤트를 보내면 이 메서드를 실행합니다.@MessageBody(): 이벤트 데이터를 꺼냅니다.@MessageBody('keyName')으로 특정 key의 값만 꺼낼 수도 있습니다.@ConnectedSocket(): 이벤트를 보낸 클라이언트의Socket인스턴스를 꺼냅니다.client.emit()은 특정 클라이언트에게,this.server.emit()은 연결된 모든 클라이언트에게,this.server.to(roomId).emit()은 그 방에 참가한 클라이언트에게만 보냅니다.- handler가 값을 반환하면 Nest는 그 값을 이벤트의 ACK로 돌려줍니다. 클라이언트가 ACK 콜백을 넘겼을 때만 그 값을 받을 수 있으며, Nest의 ACK 설명처럼 별도의 이벤트로 오는 메시지와는 다릅니다.
이 예제는 userName과 roomId를 클라이언트에게서 그대로 받고, 인증, 방 권한, 중복 제거를 구현하지 않습니다. 새 방에 참가하기 전에 이전 방을 나가지도 않습니다. 위 그림의 버전이 붙은 ACK, 저장소, 재접속 이어 받기는 추가 설계 범위입니다.
방 이름과 실제 참가자 구분하기
방(room)은 서버가 관리하는 socket 참가자 집합입니다. 반면 아래 클라이언트의 currentRoomId는 화면에서 마지막으로 고른 방 이름일 뿐입니다. joinRoom은 새 방에 참가하지만 이전 방을 나가는 코드는 없습니다. Socket.IO의 방 문서에 따르면 서버의 io.to(room).emit()은 그 방의 참가자 모두에게 보내고, socket.to(room).emit()은 보낸 socket을 빼고 보냅니다.
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로 화면에 표시할 수 있습니다. 화면의 방 선택과 서버의 참가 상태를 같은 것으로 보면 이 현상을 설명할 수 없습니다.
모듈에 게이트웨이 등록하기
게이트웨이도 NestJS 모듈의 providers 배열에 등록해야 합니다. Nest는 게이트웨이를 controller가 아니라 provider로 다루므로, 어떤 모듈의 providers에도 없으면 파일이 있어도 인스턴스를 만들지 않고 이벤트도 받지 않습니다. 아래는 게이트웨이를 담는 EventsModule과, 이 모듈을 imports로 루트 모듈에 연결한 AppModule입니다.
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 서버와 같은 포트(예: 3000)에서 함께 실행됩니다. Socket.IO는 기본으로 /socket.io/ 경로로 들어온 연결만 맡으므로 기존 REST 경로와 겹치지 않습니다. 게이트웨이가 준비되면 afterInit()의 WebSocket Gateway initialized! 메시지가 Nest 로거로 출력되므로, 터미널에서 이 메시지로 게이트웨이가 등록되었는지 확인할 수 있습니다.
npm run start:dev브라우저 클라이언트 예시
브라우저에서 NestJS WebSocket 서버에 연결해 메시지를 주고받는 간단한 코드입니다. Socket.IO 서버는 기본으로 자기 버전과 맞는 클라이언트 스크립트를 /socket.io/socket.io.js에서 제공하므로, 서버에서 바로 불러옵니다. 브라우저의 기본 WebSocket 객체는 Socket.IO 프로토콜을 구현하지 않으므로 Socket.IO 서버와 직접 짝지을 수 없습니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NestJS WebSocket Chat</title>
<!-- 서버가 제공하는 Socket.IO 클라이언트 -->
<script src="http://localhost:3000/socket.io/socket.io.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 });
});
socket.on('welcome', (data) => {
addMessage(`[SERVER] ${data}`);
});
// 모든 클라이언트에 방송된 메시지
socket.on('message', (msg) => {
addMessage(`[GLOBAL] ${msg}`);
});
// 특정 방에 방송된 메시지
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()) {
socket.emit('chatMessage', { roomId: currentRoomId, 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}`);
});
// textContent로 넣어 메시지가 HTML로 해석되지 않게 합니다.
function addMessage(msg) {
const p = document.createElement('p');
p.textContent = msg;
messagesDiv.appendChild(p);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
</script>
</body>
</html>페이지를 열면 연결 직후 connect handler가 joinRoom을 보내므로, 화면에는 서버의 welcome 메시지와 general 방 참가 알림이 표시됩니다. Send Message 버튼은 message가 아니라 chatMessage를 보내므로 현재 방에만 전달되며, [GLOBAL]로 표시될 전체 방송은 이 화면에서 보내지 않습니다. 또 socket.emit에 ACK 콜백을 넘기지 않았으므로, handler가 돌려준 Message sent to room 같은 값은 이 페이지가 받지 않습니다.
WebSocket을 배포할 때 고려할 점
로드 밸런서와 여러 서버
여러 서버 인스턴스를 띄우면 서버들 사이에서 이벤트를 주고받아야, 다른 서버에 연결된 클라이언트에게도 방 메시지가 전달됩니다. Socket.IO는 이를 위해 Redis Pub/Sub adapter를 제공하며, Nest 공식 문서는 IoAdapter를 상속해 Socket.IO 서버를 만드는 메서드만 바꾸는 방법을 안내합니다.
npm install redis @socket.io/redis-adapterimport { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
import { ServerOptions } from 'socket.io';
export class RedisIoAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createAdapter>;
async connectToRedis(): Promise<void> {
const pubClient = createClient({
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
});
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
this.adapterConstructor = createAdapter(pubClient, subClient);
}
createIOServer(port: number, options?: ServerOptions): any {
const server = super.createIOServer(port, options);
server.adapter(this.adapterConstructor);
return server;
}
}import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { RedisIoAdapter } from './redis-io.adapter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const redisIoAdapter = new RedisIoAdapter(app);
await redisIoAdapter.connectToRedis();
app.useWebSocketAdapter(redisIoAdapter);
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();connectToRedis()가 두 Redis 연결을 마친 뒤에 adapter를 등록하므로, Socket.IO 서버가 만들어질 때 Redis adapter가 붙습니다. 설치한 Redis adapter가 Socket.IO 서버 버전과 맞는지는 Redis adapter 문서의 호환 표에서 확인하고(adapter 7.x 이상은 Socket.IO 4.3.1 이상과 맞습니다), Redis 연결이 끊겼을 때의 재연결과 종료 처리는 따로 구성합니다.
Nest 공식 문서는 여러 인스턴스에 Socket.IO를 쓸 때 Redis만으로는 부족하다고 경고합니다. 클라이언트 설정에서 transports: ['websocket']으로 HTTP long-polling을 끄거나, 로드 밸런서에서 쿠키 기반의 스티키 세션을 켜야 합니다. long-polling은 요청을 여러 번 나누어 보내므로 같은 세션의 요청이 같은 서버로 가야 하기 때문입니다. WebSocket 전송만 쓰면 하나의 TCP 연결이 한 서버에 머무르므로 이 이유의 스티키 세션은 필요하지 않습니다. 또 기본 Redis Pub/Sub adapter는 연결이 끊긴 클라이언트를 위해 메시지를 영구 저장하는 큐가 아닙니다.
보안과 운영
- 방화벽과 보안 그룹: WebSocket 트래픽이 지나갈 수 있도록 포트(보통 80, 443)를 엽니다.
- 인증과 인가: WebSocket 연결에도 인증과 인가가 필요합니다. handshake 단계에서 토큰을 검증해 서버가 socket의 신원을 정하고, 클라이언트가 보낸
userName이나roomId를 믿지 않고 그 신원에 허용된 방에만 참가와 발신을 허용합니다. - CORS: 기본 데모는 교차 출처 쿠키를 쓰지 않으므로
credentials를false로 둡니다. 클라이언트가withCredentials: true로 쿠키를 보낸다면, 서버도credentials: true와 구체적인 허용 출처를 함께 설정해야 하며origin: '*'와 함께 쓸 수 없습니다. Socket.IO CORS 문서의 설명처럼 CORS는 브라우저의 HTTP long-polling 요청에 적용될 뿐, WebSocket 연결의 인증을 대신하지 않습니다. 10장의 내장 CSRF 보호도 WebSocket 업그레이드 요청은 검사하지 않으므로, 쿠키로 인증한다면 handshake의Origin헤더를 직접 확인합니다. - TLS: 운영에서는 항상 암호화된 WSS(WebSocket Secure)를 씁니다. 로드 밸런서에서 TLS를 종료하고 내부로는 일반 WebSocket을 보낼 수도 있습니다.
- 서버리스 환경: Lambda처럼 요청마다 실행되는 환경은 연결을 계속 유지하기 어렵습니다. AWS API Gateway WebSocket API, Azure Web PubSub 같은 관리형 서비스를 쓰거나, 연결을 유지할 수 있는 컨테이너 기반 서비스를 고릅니다.
WebSocket은 실시간 상호작용이 필요한 웹 애플리케이션의 핵심 기술이며, NestJS는 @nestjs/websockets와 Socket.IO 통합으로 이를 구조적으로 구현하게 돕습니다. 연결 자체보다 인증, 방 권한, 여러 서버 사이의 전달, 재접속과 중복 처리를 함께 설계해야 안정적인 실시간 서비스가 됩니다.
더 깊이: 끊긴 연결을 알아내는 heartbeat
TCP 연결은 상대가 말없이 사라져도 바로 알려 주지 않으므로, Socket.IO는 자체 heartbeat로 연결이 살아 있는지 확인합니다. Socket.IO 서버 옵션 문서에 따르면 서버는 pingInterval마다 ping 패킷을 보내고, 클라이언트가 pingTimeout 안에 pong으로 답하지 않으면 연결이 닫힌 것으로 봅니다. 기본값은 각각 25000밀리초와 20000밀리초이므로, 클라이언트가 응답 없이 사라졌다면 서버가 알아차리기까지 두 값을 더한 45초 가까이 걸릴 수 있습니다. 그동안은 handleDisconnect()도 호출되지 않으므로, 접속자 목록이나 방 인원을 이 hook으로만 셀 때는 이 지연을 감안합니다.
heartbeat는 로드 밸런서의 유휴 시간 제한과도 맞물립니다. AWS Application Load Balancer는 기본으로 60초 동안 데이터가 오가지 않은 연결을 닫는데, 기본 설정에서는 25초마다 ping과 pong이 오가므로 연결이 유휴 상태로 남지 않습니다. 반대로 pingInterval을 로드 밸런서의 유휴 제한보다 길게 늘리면, 메시지가 없는 연결이 중간에서 끊겨 클라이언트가 재연결을 반복할 수 있습니다.
정리
- WebSocket은 handshake 뒤에 양방향 채널을 유지하는 전이중 프로토콜이며, Socket.IO는 그 위에 대체 전송, 자동 재연결, 이벤트 통신을 더합니다.
- NestJS 게이트웨이는
@WebSocketGateway()로 정의하고,@SubscribeMessage()로 이벤트를 받으며, handler의 반환값은 ACK로 돌아갑니다. server.to(room).emit()은 그 방의 참가자 모두에게,socket.to(room).emit()은 보낸 socket을 빼고 보내며, 화면의 방 이름과 서버의 참가 상태는 다릅니다.- 여러 인스턴스에서는
IoAdapter를 상속한RedisIoAdapter로 이벤트를 공유하고, long-polling을 끄거나 스티키 세션을 켭니다. - 클라이언트가 보낸 사용자 이름과 방 ID를 믿지 않고, handshake에서 검증한 신원으로 방 권한을 확인합니다.
연습 문제
1. 방 메시지는 누가 받을까
A와 B가 general에, C가 tech에 참가한 상태에서 A가 tech를 골랐고, 이어서 B도 tech에 참가했습니다. 이 글의 chatMessage handler로 tech에 메시지를 보내면 누가 받을까요? A가 보내되 서버 코드를 client.to(roomId).emit()으로 바꾸면 어떻게 달라질까요? 클라이언트가 joinRoom을 콜백 없이 보낼 때, 서버의 반환 문자열을 화면 메시지로 받을 수 있을까요?
해설 보기
server.to('tech')의 수신자는 tech 참가자인 A, B, C 모두입니다. A가 보내고 client.to()로 바꾸면 보낸 socket인 A를 빼므로 B와 C만 받습니다. 이는 실제 연결이나 전달 실패를 빼고 계산한 참가자 집합입니다.
handler의 반환값은 ACK로 돌아가지만, 클라이언트가 ACK 콜백을 넘기지 않았으므로 이를 받을 방법이 없습니다. 반환값은 roomMessage 같은 별도 이벤트로 오는 화면 메시지와 다릅니다.
2. Redis adapter만 붙이면 될까
서버 A와 B에 RedisIoAdapter를 올바르게 등록했지만, 클라이언트는 기본 설정 그대로 HTTP long-polling으로 먼저 연결합니다. 로드 밸런서에는 스티키 세션이 없습니다. 연결 도중 같은 세션의 다음 polling 요청이 B로 가면 어떻게 될까요? 클라이언트가 끊겨 있던 동안의 방송을 Redis adapter가 다시 보내 줄까요?
해설 보기
Redis adapter는 서버 사이에 방송을 중계할 뿐, A가 가진 polling 세션 상태를 B에 만들어 주지 않습니다. B는 모르는 세션의 요청을 받게 되어 연결이 실패하거나 다시 맺어집니다. Nest 문서의 경고대로 클라이언트에서 transports: ['websocket']으로 polling을 끄거나, 로드 밸런서에서 스티키 세션을 켜야 합니다.
또 기본 Redis Pub/Sub adapter는 끊긴 클라이언트를 위해 메시지를 저장하지 않으므로, 그동안의 방송은 다시 오지 않습니다. 재접속 뒤에 놓친 메시지를 받으려면 메시지를 저장하고 마지막으로 받은 위치를 기억하는 별도 정책이 필요합니다.
3. 방에 없는 사용자의 발신
클라이언트 M이 secret 방에 참가하지 않은 채로 chatMessage에 roomId: 'secret', userName: 'Admin'을 넣어 보냈습니다. 이 글의 handler는 어떻게 동작할까요? 실제 서비스라면 무엇을 확인해야 할까요?
해설 보기
지금의 handleChatMessage는 참가 여부, 접근 권한, 사용자 이름을 검사하지 않고 server.to(roomId).emit()을 호출합니다. 따라서 요청이 handler까지 오면 secret 방의 참가자들에게 [Admin in secret] ...이라는 메시지가 그대로 방송됩니다. M은 방에 없으므로 그 메시지를 받지 못하지만, 다른 사람으로 행세해 방에 메시지를 넣을 수는 있습니다.
실제 서비스에서는 handshake에서 검증한 신원으로 발신자 이름을 정하고, 그 사용자가 해당 방에 보낼 권한이 있는지 서버가 확인한 뒤에만 방송합니다. 같은 요청의 재시도로 메시지가 두 번 들어가지 않게 하려면 메시지 ID와 서버의 중복 처리 정책도 필요합니다.
다음 글 CQRS 패턴 구현에서는 데이터를 바꾸는 명령과 데이터를 읽는 조회를 분리하는 CQRS 패턴을 NestJS의 @nestjs/cqrs로 구현합니다.