안동민 개발노트 아이콘

안동민 개발노트

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

WebSocket과 실시간 애플리케이션

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

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


WebSocket이란?

WebSocket 실시간 애플리케이션은 handshake, room 관리, backpressure, 재접속 기준을 묶었습니다.

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 위에 구축된 라이브러리로, 브라우저 폴백(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() 데코레이터로 정의됩니다.

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는 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/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을 쉽게 구현할 수 있습니다. Lambda 환경에서 HTTP 라우팅 어댑터를 함께 쓰는 경우에는 @codegenie/serverless-express 같은 최신 어댑터 기준으로 이벤트 라우팅을 구성하는 것이 안정적입니다.

마지막으로 WebSocket은 연결 자체보다 운영 계약을 유지하는 일이 더 중요합니다.


WebSocket은 실시간 상호작용이 필요한 현대 웹 애플리케이션의 핵심 기술입니다.

NestJS는 @nestjs/websocketsSocket.IO 통합을 통해 복잡한 실시간 통신 기능을 간결하고 구조적으로 구현하도록 돕습니다.

로드 밸런서, 스케일링, 보안 같은 배포 고려사항을 충분히 이해하고 적용하면 안정적이고 확장 가능한 실시간 서비스를 구축할 수 있습니다.

WebSocket 개념과 NestJS에서 WebSocket 구현 흐름을 함께 정리한 보조 다이어그램입니다.