NestJS WebSocket 실시간 통신

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로 멀티 서버 환경을 지원하며, 연결 상태 관리와 이벤트 구조 설계로 확장 가능한 실시간 시스템을 구축할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux