NestJS WebSocket이란?
NestJS는 @nestjs/websockets와 @nestjs/platform-socket.io 패키지로 실시간 양방향 통신을 지원합니다. REST API가 요청-응답 모델이라면, WebSocket은 서버와 클라이언트가 지속적으로 메시지를 주고받는 모델입니다. 채팅, 실시간 알림, 라이브 대시보드, 협업 에디터 등에 필수적인 기술입니다.
Gateway 기본 구조
NestJS에서 WebSocket 서버는 @WebSocketGateway() 데코레이터로 정의합니다:
import {
WebSocketGateway, WebSocketServer,
SubscribeMessage, MessageBody,
ConnectedSocket, OnGatewayInit,
OnGatewayConnection, OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({
cors: { origin: '*' },
namespace: '/chat',
transports: ['websocket', 'polling'],
})
export class ChatGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer()
server: Server;
private logger = new Logger('ChatGateway');
afterInit(server: Server) {
this.logger.log('WebSocket Gateway initialized');
}
handleConnection(client: Socket) {
this.logger.log(`Client connected: ${client.id}`);
}
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}
@SubscribeMessage('sendMessage')
handleMessage(
@MessageBody() data: { room: string; message: string },
@ConnectedSocket() client: Socket,
) {
// 해당 방의 모든 클라이언트에게 전송 (발신자 제외)
client.to(data.room).emit('newMessage', {
sender: client.id,
message: data.message,
timestamp: new Date().toISOString(),
});
// 발신자에게 확인 응답
return { event: 'messageSent', data: { status: 'ok' } };
}
}
@SubscribeMessage()로 이벤트를 수신하고, server.emit()으로 브로드캐스트합니다. 반환값은 자동으로 클라이언트에게 ACK로 전달됩니다.
Room 기반 메시징
Socket.IO의 Room은 특정 그룹에게만 메시지를 보내는 핵심 기능입니다:
@SubscribeMessage('joinRoom')
handleJoinRoom(
@MessageBody() data: { room: string },
@ConnectedSocket() client: Socket,
) {
client.join(data.room);
client.to(data.room).emit('userJoined', {
userId: client.id,
room: data.room,
});
return { event: 'joinedRoom', data: { room: data.room } };
}
@SubscribeMessage('leaveRoom')
handleLeaveRoom(
@MessageBody() data: { room: string },
@ConnectedSocket() client: Socket,
) {
client.leave(data.room);
client.to(data.room).emit('userLeft', { userId: client.id });
}
// 특정 방에 메시지 전송
sendToRoom(room: string, event: string, data: any) {
this.server.to(room).emit(event, data);
}
// 특정 사용자에게 1:1 전송
sendToUser(socketId: string, event: string, data: any) {
this.server.to(socketId).emit(event, data);
}
인증: Guard 연동
WebSocket에서도 NestJS의 Guard를 그대로 사용할 수 있습니다. NestJS Guard 접근 제어에서 다룬 패턴을 WebSocket에 적용합니다:
@Injectable()
export class WsAuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
canActivate(context: ExecutionContext): boolean {
const client = context.switchToWs().getClient<Socket>();
const token =
client.handshake.auth?.token ||
client.handshake.headers?.authorization?.split(' ')[1];
if (!token) throw new WsException('Unauthorized');
try {
const payload = this.jwtService.verify(token);
client.data.user = payload; // 소켓에 유저 정보 저장
return true;
} catch {
throw new WsException('Invalid token');
}
}
}
// Gateway에 Guard 적용
@WebSocketGateway()
@UseGuards(WsAuthGuard)
export class ChatGateway {
@SubscribeMessage('sendMessage')
handleMessage(
@ConnectedSocket() client: Socket,
@MessageBody() data: any,
) {
const user = client.data.user; // Guard에서 저장한 유저 정보
// ...
}
}
// 연결 시점에 인증 (handleConnection)
async handleConnection(client: Socket) {
try {
const token = client.handshake.auth?.token;
const user = this.jwtService.verify(token);
client.data.user = user;
// 유저별 Room 자동 입장
client.join(`user:${user.id}`);
} catch {
client.disconnect();
}
}
Pipe: 메시지 유효성 검증
// DTO 정의
class SendMessageDto {
@IsString()
@IsNotEmpty()
room: string;
@IsString()
@MaxLength(1000)
message: string;
}
// ValidationPipe 적용
@SubscribeMessage('sendMessage')
@UsePipes(new ValidationPipe({ transform: true }))
handleMessage(
@MessageBody() data: SendMessageDto,
@ConnectedSocket() client: Socket,
) {
// data는 이미 검증·변환됨
client.to(data.room).emit('newMessage', {
sender: client.data.user.name,
message: data.message,
});
}
REST API와 동일한 DTO, 동일한 ValidationPipe를 사용할 수 있습니다. 유효성 검증 실패 시 WsException이 발생합니다.
Exception Filter: 에러 처리
WebSocket 전용 Exception Filter를 구현합니다. NestJS Exception Filter의 WebSocket 버전입니다:
@Catch()
export class WsExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const client = host.switchToWs().getClient<Socket>();
let error = { status: 'error', message: 'Internal error' };
if (exception instanceof WsException) {
error.message = exception.message;
} else if (exception instanceof HttpException) {
error.message = exception.message;
}
client.emit('error', error);
}
}
// Gateway에 적용
@WebSocketGateway()
@UseFilters(new WsExceptionFilter())
export class ChatGateway { ... }
Redis Adapter: 멀티 서버 스케일링
서버가 여러 대일 때 Socket.IO만으로는 서버 간 메시지 전달이 안 됩니다. Redis Adapter로 해결합니다:
// main.ts
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
export class RedisIoAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createAdapter>;
async connectToRedis() {
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
this.adapterConstructor = createAdapter(pubClient, subClient);
}
createIOServer(port: number, options?: any) {
const server = super.createIOServer(port, options);
server.adapter(this.adapterConstructor);
return server;
}
}
// main.ts에서 적용
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const redisIoAdapter = new RedisIoAdapter(app);
await redisIoAdapter.connectToRedis();
app.useWebSocketAdapter(redisIoAdapter);
await app.listen(3000);
}
Redis Adapter를 사용하면 서버 A에 연결된 클라이언트가 보낸 메시지를 서버 B에 연결된 클라이언트도 수신할 수 있습니다. K8s에서 여러 Pod로 스케일 아웃할 때 필수입니다.
이벤트 구조 설계
실전에서 이벤트를 체계적으로 관리하는 패턴입니다:
// 이벤트 상수 관리
export const WS_EVENTS = {
// 클라이언트 → 서버
JOIN_ROOM: 'room:join',
LEAVE_ROOM: 'room:leave',
SEND_MESSAGE: 'message:send',
TYPING_START: 'typing:start',
TYPING_STOP: 'typing:stop',
// 서버 → 클라이언트
NEW_MESSAGE: 'message:new',
USER_JOINED: 'room:user-joined',
USER_LEFT: 'room:user-left',
USER_TYPING: 'typing:update',
ERROR: 'error',
} as const;
// 타입 정의
interface WsPayload<T> {
event: string;
data: T;
timestamp: string;
}
// Gateway에서 사용
@SubscribeMessage(WS_EVENTS.SEND_MESSAGE)
handleMessage(@MessageBody() data: SendMessageDto) { ... }
연결 상태 관리
@Injectable()
export class ConnectionManager {
// userId → Set<socketId> (한 유저가 여러 탭/디바이스)
private connections = new Map<string, Set<string>>();
addConnection(userId: string, socketId: string) {
if (!this.connections.has(userId)) {
this.connections.set(userId, new Set());
}
this.connections.get(userId).add(socketId);
}
removeConnection(userId: string, socketId: string) {
this.connections.get(userId)?.delete(socketId);
if (this.connections.get(userId)?.size === 0) {
this.connections.delete(userId);
}
}
isOnline(userId: string): boolean {
return this.connections.has(userId);
}
getOnlineUsers(): string[] {
return Array.from(this.connections.keys());
}
getUserSockets(userId: string): string[] {
return Array.from(this.connections.get(userId) || []);
}
}
마무리
NestJS WebSocket은 REST API와 동일한 Guard, Pipe, Filter, Interceptor를 사용할 수 있어 일관된 아키텍처를 유지합니다. Room 기반 메시징으로 그룹 통신을 구현하고, Redis Adapter로 멀티 서버 환경을 지원하며, 연결 상태 관리와 이벤트 구조 설계로 확장 가능한 실시간 시스템을 구축할 수 있습니다.