icon
12장 : 고급 주제와 최신 트렌드

WebSocket과 실시간 애플리케이션


지난 절에서는 NestJS 애플리케이션을 서버리스 환경에 배포하는 전략에 대해 알아보았습니다. 이제 12장의 두 번째 절로, 실시간 웹 애플리케이션의 핵심 기술인 WebSocket과 이를 NestJS에서 효과적으로 구현하는 방법에 대해 살펴보겠습니다.

기존의 HTTP는 요청-응답(Request-Response) 모델을 기반으로 하여 클라이언트가 서버에 요청을 보내야만 응답을 받을 수 있습니다. 하지만 채팅 애플리케이션, 실시간 게임, 주식 시세판, 협업 도구 등은 서버에서 클라이언트로 데이터를 즉시 푸시해야 하는 실시간 통신 기능이 필수적입니다. WebSocket은 이러한 요구사항을 충족시키기 위해 등장한 기술입니다.


WebSocket이란?

WebSocket은 웹 브라우저와 웹 서버 간에 전이중(Full-duplex) 통신 채널을 제공하는 통신 프로토콜입니다. HTTP와 달리, 한 번 연결이 수립되면 클라이언트와 서버가 독립적으로 언제든지 데이터를 주고받을 수 있습니다.

WebSocket의 특징

  • 지속적인 연결: HTTP는 요청마다 연결을 맺고 끊는 반면, WebSocket은 한 번 핸드셰이크(Handshake)를 통해 연결을 수립하면 이 연결을 지속적으로 유지합니다.
  • 전이중 통신: 클라이언트와 서버가 동시에 서로에게 데이터를 보낼 수 있습니다.
  • 낮은 오버헤드: HTTP에 비해 헤더 정보가 매우 작아 통신 오버헤드가 적습니다. 이는 실시간으로 많은 양의 작은 데이터를 주고받을 때 효율적입니다.
  • HTTP 호환: WebSocket 연결은 HTTP/1.1 업그레이드 헤더를 통해 시작됩니다. 즉, 기존 HTTP 포트(80 또는 443)를 사용하여 방화벽 문제를 피할 수 있습니다.

WebSocket과 HTTP 폴링/롱 폴링 비교

특징HTTP (Polling)HTTP (Long Polling)WebSocket
연결 방식클라이언트가 주기적으로 요청클라이언트가 요청 후 서버가 데이터 발생 시 응답지속적인 연결 (한 번 연결 후 유지)
통신 방향단방향 (클라이언트 -> 서버)단방향 (클라이언트 -> 서버, 서버가 지연 응답)전이중 (양방향 동시)
오버헤드높음 (요청/응답마다 전체 HTTP 헤더)높음 (요청/응답마다 전체 HTTP 헤더)낮음 (최초 핸드셰이크 후 프레임 헤더)
지연 시간김 (폴링 간격에 따라)짧음 (데이터 발생 즉시), 그러나 요청은 새로 보냄매우 짧음 (실시간 푸시)
복잡성단순서버 측 구현 복잡클라이언트/서버 양측에 상태 관리 필요
용도비실시간 데이터 업데이트적은 빈도의 업데이트채팅, 게임, 알림, 실시간 데이터 스트리밍 등 진정한 실시간 통신

NestJS에서 WebSocket 구현

NestJS는 @nestjs/websockets 패키지와 @nestjs/platform-socket.io (또는 @nestjs/platform-ws)를 통해 WebSocket 기능을 쉽게 구현할 수 있도록 지원합니다. Socket.IO는 WebSocket 위에 구축된 라이브러리로, WebSocket을 지원하지 않는 브라우저를 위한 폴백(fallback) 기능, 자동 재연결, 이벤트 기반 통신 등 추가적인 기능을 제공하여 실시간 애플리케이션 개발을 용이하게 합니다.

이 절에서는 Socket.IO를 중심으로 설명하겠습니다.

필요한 패키지 설치

npm install @nestjs/websockets @nestjs/platform-socket.io socket.io
npm install --save-dev @types/socket.io

게이트웨이(Gateway) 생성

NestJS에서는 WebSocket 핸들러를 게이트웨이(Gateway) 라고 부릅니다. 게이트웨이는 @WebSocketGateway() 데코레이터로 정의됩니다.

// src/events/events.gateway.ts
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: true,
  },
  // 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): 특정 "방"에 있는 클라이언트들에게만 메시지를 보냅니다.

모듈에 게이트웨이 등록

게이트웨이도 NestJS 모듈의 providers 배열에 등록되어야 합니다.

// src/events/events.module.ts
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';

@Module({
  providers: [EventsGateway], // 게이트웨이 등록
})
export class EventsModule {}

// src/app.module.ts
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): WebSocket은 지속적인 연결을 유지하므로, 로드 밸런서가 sticky session(세션 고정) 또는 세션 어피니티(Session Affinity) 를 지원해야 합니다. 즉, 특정 클라이언트의 WebSocket 연결이 항상 동일한 서버 인스턴스로 라우팅되도록 해야 합니다. AWS ALB(Application Load Balancer)는 sticky session을 지원하며, Kubernetes의 Service Type 중 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/redis
    // src/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();

    위 코드는 기본적인 Redis 어댑터 설정 예시이며, 실제 NestJS IoAdapter에 적용하려면 좀 더 세밀한 조정이 필요합니다. 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 서비스를 활용하여 서버리스 환경에서도 WebSocket을 쉽게 구현할 수 있습니다. Lambda와 aws-serverless-express를 사용하면 WebSocket API Gateway를 통해 백엔드 NestJS 람다 함수로 라우팅하는 복잡한 설정이 필요합니다.


WebSocket은 실시간 상호작용이 필요한 현대 웹 애플리케이션의 핵심 기술입니다. NestJS는 @nestjs/websocketsSocket.IO 통합을 통해 이러한 복잡한 실시간 통신 기능을 간결하고 구조적으로 구현할 수 있도록 돕습니다. 로드 밸런서, 스케일링, 보안 등 배포 시 고려사항들을 충분히 이해하고 적용함으로써 안정적이고 확장 가능한 실시간 서비스를 구축할 수 있습니다.