junyeokk
Blog
NestJS·2025. 11. 15

NestJS + ws 라이브러리 WebSocket 서버

NestJS에서 실시간 양방향 통신이 필요할 때 WebSocket을 사용한다. NestJS는 기본적으로 socket.io를 WebSocket 어댑터로 제공하지만, 모든 상황에서 socket.io가 최선은 아니다. socket.io는 자체 프로토콜 레이어를 추가하고, 클라이언트도 socket.io 전용 라이브러리를 사용해야 한다. 브라우저 네이티브 WebSocket API로는 직접 연결할 수 없다.

ws 라이브러리는 Node.js에서 가장 널리 사용되는 네이티브 WebSocket 구현체다. socket.io처럼 폴링 폴백이나 네임스페이스 같은 고수준 기능은 없지만, 표준 WebSocket 프로토콜을 그대로 구현하기 때문에 브라우저 네이티브 WebSocket API와 바로 통신할 수 있고, 오버헤드가 훨씬 적다.


socket.io vs ws: 언제 뭘 쓸까

두 라이브러리의 차이를 이해해야 올바른 선택을 할 수 있다.

socket.io가 적합한 경우:

  • 자동 재연결, 네임스페이스, 룸(room) 기능이 필요할 때
  • WebSocket을 지원하지 않는 환경에서 폴링 폴백이 필요할 때
  • 클라이언트도 JavaScript이고 socket.io 클라이언트를 쓸 수 있을 때

ws가 적합한 경우:

  • 네이티브 WebSocket 프로토콜만으로 충분할 때
  • 브라우저의 new WebSocket() API로 직접 연결해야 할 때
  • Electron, 모바일 앱 등 네이티브 클라이언트와 통신할 때
  • 성능이 중요하고 불필요한 추상화를 피하고 싶을 때
  • 메시지 프로토콜을 직접 설계하고 싶을 때

socket.io는 자체 핸드셰이크 프로토콜을 사용한다. 클라이언트가 처음 연결할 때 HTTP 폴링으로 시작하고, 이후 WebSocket으로 업그레이드하는 과정을 거친다. 이 과정에서 추가 패킷이 오가고, 메시지에도 socket.io 전용 프레이밍이 붙는다. ws는 이런 오버헤드 없이 WebSocket 프로토콜 그 자체만 사용한다.


NestJS Gateway 방식: WsAdapter 사용

NestJS의 공식적인 WebSocket 통합 방법은 Gateway 패턴이다. @WebSocketGateway() 데코레이터로 클래스를 선언하고, @SubscribeMessage()로 메시지 핸들러를 등록한다. 이 구조는 socket.io와 ws 모두에서 동일하게 작동한다. 어댑터만 교체하면 된다.

설치

bash
npm i @nestjs/websockets @nestjs/platform-ws

@nestjs/platform-socket.io 대신 @nestjs/platform-ws를 설치한다. 이 패키지가 ws 라이브러리 기반의 WsAdapter를 제공한다.

어댑터 교체

typescript
// main.ts
import { NestFactory } from '@nestjs/core';
import { WsAdapter } from '@nestjs/platform-ws';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useWebSocketAdapter(new WsAdapter(app));
  await app.listen(3000);
}
bootstrap();

useWebSocketAdapter()로 기본 socket.io 어댑터를 WsAdapter로 교체한다. 이 한 줄이면 기존 Gateway 코드가 ws 기반으로 동작한다.

Gateway 구현

typescript
// events.gateway.ts
import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  MessageBody,
  ConnectedSocket,
  OnGatewayInit,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server } from 'ws';

@WebSocketGateway({ path: '/ws' })
export class EventsGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  @WebSocketServer()
  server: Server;

  afterInit(server: Server) {
    console.log('WebSocket 서버 초기화 완료');
  }

  handleConnection(client: WebSocket) {
    console.log('클라이언트 연결됨');
  }

  handleDisconnect(client: WebSocket) {
    console.log('클라이언트 연결 해제');
  }

  @SubscribeMessage('ping')
  handlePing(@MessageBody() data: any): { event: string; data: any } {
    return { event: 'pong', data: { received: data, timestamp: Date.now() } };
  }

  @SubscribeMessage('chat')
  handleChat(
    @MessageBody() data: { message: string },
    @ConnectedSocket() client: WebSocket,
  ) {
    // 발신자를 제외한 모든 클라이언트에게 브로드캐스트
    this.server.clients.forEach((c) => {
      if (c !== client && c.readyState === WebSocket.OPEN) {
        c.send(JSON.stringify({ event: 'chat', data }));
      }
    });
  }
}

몇 가지 주의할 점이 있다.

path 옵션: ws에는 socket.io의 네임스페이스 개념이 없다. 대신 path를 사용해서 여러 WebSocket 엔드포인트를 구분할 수 있다. @WebSocketGateway({ path: '/ws' })로 설정하면 ws://localhost:3000/ws로 연결한다.

메시지 형식: WsAdapter는 기본적으로 { event: string, data: any } JSON 형식의 메시지를 기대한다. 클라이언트가 이 형식으로 메시지를 보내야 @SubscribeMessage()의 이벤트 매칭이 동작한다.

javascript
// 클라이언트 측
const ws = new WebSocket('ws://localhost:3000/ws');
ws.send(JSON.stringify({ event: 'ping', data: { hello: 'world' } }));

브로드캐스트: socket.io는 server.emit()으로 모든 클라이언트에게 메시지를 보낼 수 있지만, ws에서는 server.clients를 순회하면서 직접 send()해야 한다. 룸(room) 기능도 없으므로 특정 그룹에 보내려면 클라이언트를 직접 관리해야 한다.

라이프사이클 훅

Gateway는 세 가지 라이프사이클 인터페이스를 제공한다.

인터페이스메서드시점
OnGatewayInitafterInit(server)WebSocket 서버 초기화 직후
OnGatewayConnectionhandleConnection(client, ...args)클라이언트 연결 시
OnGatewayDisconnecthandleDisconnect(client)클라이언트 연결 해제 시

이 인터페이스들을 구현하면 연결/해제 이벤트를 가로채서 인증 처리, 클라이언트 추적, 로깅 등을 할 수 있다.

메시지 파서 커스터마이징

기본 { event, data } 형식이 아닌 다른 메시지 포맷을 사용하고 싶을 수 있다. 예를 들어 [event, data] 배열 형식이나 완전히 커스텀한 프로토콜을 쓰고 싶다면 messageParser를 설정한다.

typescript
const wsAdapter = new WsAdapter(app, {
  messageParser: (data) => {
    const parsed = JSON.parse(data.toString());
    // [event, payload] 배열 형식 처리
    if (Array.isArray(parsed)) {
      return { event: parsed[0], data: parsed[1] };
    }
    // 기본 { event, data } 형식
    return parsed;
  },
});

app.useWebSocketAdapter(wsAdapter);

이렇게 하면 클라이언트가 ["chat", { message: "hello" }] 형태로 보내도 Gateway에서 정상적으로 처리된다.

모듈 등록

Gateway는 NestJS에서 Provider로 취급된다. 모듈의 providers 배열에 등록해야 동작한다.

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

@Module({
  providers: [EventsGateway],
})
export class EventsModule {}

Gateway에 다른 서비스를 주입할 수도 있고, 반대로 Gateway를 다른 서비스에 주입할 수도 있다. 일반적인 NestJS DI 규칙이 그대로 적용된다.


Raw 방식: HttpAdapterHost로 직접 ws 서버 연결

NestJS Gateway 패턴이 제공하는 추상화가 필요 없거나, 더 세밀한 제어가 필요한 경우가 있다. 예를 들어 바이너리 메시지를 직접 다뤄야 하거나, 기존 ws 기반 코드를 NestJS에 통합해야 할 때. 이런 경우 HttpAdapterHost를 사용해서 NestJS의 HTTP 서버 인스턴스에 직접 ws 서버를 붙일 수 있다.

HttpAdapterHost란

HttpAdapterHost는 NestJS가 내부적으로 사용하는 HTTP 서버(Express 또는 Fastify)에 접근할 수 있게 해주는 헬퍼다. httpAdapter.getHttpServer()로 Node.js의 http.Server 인스턴스를 가져올 수 있다.

이걸 ws의 WebSocket.Server 생성자에 server 옵션으로 전달하면, 별도 포트 없이 기존 HTTP 서버와 같은 포트에서 WebSocket 연결을 받을 수 있다.

구현

typescript
// websocket.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { WebSocketServer, WebSocket } from 'ws';

@Injectable()
export class WebSocketService implements OnModuleInit, OnModuleDestroy {
  private wss: WebSocketServer;
  private clients = new Set<WebSocket>();

  constructor(private readonly httpAdapterHost: HttpAdapterHost) {}

  onModuleInit() {
    const server = this.httpAdapterHost.httpAdapter.getHttpServer();

    this.wss = new WebSocketServer({
      server,
      path: '/ws',
    });

    this.wss.on('connection', (ws: WebSocket, req) => {
      console.log(`새 연결: ${req.url}`);
      this.clients.add(ws);

      ws.on('message', (raw: Buffer) => {
        this.handleMessage(ws, raw);
      });

      ws.on('close', () => {
        this.clients.delete(ws);
        console.log('연결 해제');
      });

      ws.on('error', (err) => {
        console.error('WebSocket 에러:', err);
        this.clients.delete(ws);
      });
    });
  }

  onModuleDestroy() {
    this.wss?.close();
  }

  private handleMessage(sender: WebSocket, raw: Buffer) {
    const message = raw.toString();
    console.log('수신:', message);

    // 에코
    sender.send(JSON.stringify({ type: 'echo', payload: message }));
  }

  broadcast(data: any, exclude?: WebSocket) {
    const message = JSON.stringify(data);
    this.clients.forEach((client) => {
      if (client !== exclude && client.readyState === WebSocket.OPEN) {
        client.send(message);
      }
    });
  }
}

이 방식의 핵심은 onModuleInit()에서 WebSocket 서버를 생성하는 것이다. NestJS의 HTTP 서버가 이미 준비된 시점에 실행되기 때문에 안전하게 getHttpServer()를 호출할 수 있다.

Gateway 방식과의 차이

항목Gateway + WsAdapterRaw ws + HttpAdapterHost
메시지 라우팅@SubscribeMessage 데코레이터직접 파싱/분기
DI 통합자동 (Provider)수동 (서비스에서 주입)
Guards/Pipes/Interceptors사용 가능사용 불가
메시지 형식{ event, data } 규약완전 자유
바이너리 처리제한적직접 Buffer 처리
복잡도낮음높음

Gateway 방식은 NestJS의 데코레이터 기반 추상화를 그대로 활용할 수 있어서 코드가 깔끔하고, Guard나 Pipe 같은 NestJS 기능도 적용할 수 있다. 반면 Raw 방식은 ws 라이브러리를 직접 다루기 때문에 자유도가 높지만, 메시지 라우팅이나 인증 같은 공통 관심사를 직접 구현해야 한다.


실전 패턴: 클라이언트 관리

ws에는 socket.io의 룸(room) 기능이 없으므로, 클라이언트 그루핑이 필요하면 직접 구현해야 한다.

Map 기반 클라이언트 추적

typescript
@Injectable()
export class WebSocketService implements OnModuleInit {
  private rooms = new Map<string, Set<WebSocket>>();
  private clientRooms = new Map<WebSocket, Set<string>>();

  join(client: WebSocket, room: string) {
    if (!this.rooms.has(room)) {
      this.rooms.set(room, new Set());
    }
    this.rooms.get(room)!.add(client);

    if (!this.clientRooms.has(client)) {
      this.clientRooms.set(client, new Set());
    }
    this.clientRooms.get(client)!.add(room);
  }

  leave(client: WebSocket, room: string) {
    this.rooms.get(room)?.delete(client);
    this.clientRooms.get(client)?.delete(room);

    // 빈 룸 정리
    if (this.rooms.get(room)?.size === 0) {
      this.rooms.delete(room);
    }
  }

  leaveAll(client: WebSocket) {
    const rooms = this.clientRooms.get(client);
    rooms?.forEach((room) => this.rooms.get(room)?.delete(client));
    this.clientRooms.delete(client);
  }

  toRoom(room: string, data: any, exclude?: WebSocket) {
    const message = JSON.stringify(data);
    this.rooms.get(room)?.forEach((client) => {
      if (client !== exclude && client.readyState === WebSocket.OPEN) {
        client.send(message);
      }
    });
  }
}

handleDisconnect이나 close 이벤트에서 반드시 leaveAll()을 호출해야 한다. 그렇지 않으면 끊어진 연결에 대한 참조가 남아 메모리 누수가 발생한다.

인증 처리

ws 연결 시 HTTP 업그레이드 요청의 헤더에 접근할 수 있다. 이를 이용해 JWT 토큰을 검증할 수 있다.

typescript
// Gateway 방식에서의 인증
@WebSocketGateway({ path: '/ws' })
export class EventsGateway implements OnGatewayConnection {
  constructor(private readonly authService: AuthService) {}

  async handleConnection(client: WebSocket, req: IncomingMessage) {
    try {
      const token = this.extractToken(req);
      const user = await this.authService.verifyToken(token);
      // 클라이언트에 유저 정보 연결
      (client as any).user = user;
    } catch {
      client.close(4401, 'Unauthorized');
    }
  }

  private extractToken(req: IncomingMessage): string {
    // 쿼리 파라미터에서 추출
    const url = new URL(req.url!, `http://${req.headers.host}`);
    const token = url.searchParams.get('token');
    if (token) return token;

    // Authorization 헤더에서 추출
    const auth = req.headers.authorization;
    if (auth?.startsWith('Bearer ')) return auth.slice(7);

    throw new Error('No token');
  }
}

WebSocket은 HTTP와 달리 연결 후에는 헤더를 보낼 수 없다. 그래서 인증 토큰은 보통 두 가지 방식으로 전달한다:

  1. 쿼리 파라미터: ws://localhost:3000/ws?token=xxx — 간단하지만 URL에 토큰이 노출됨
  2. 첫 메시지: 연결 후 첫 메시지로 토큰을 보내고, 서버에서 검증 후 통과시킴

Sec-WebSocket-Protocol 헤더를 토큰 전달에 사용하는 패턴도 있지만, 이는 헤더의 원래 용도(서브프로토콜 협상)와 맞지 않아 권장되지 않는다.


핑/퐁과 연결 유지

WebSocket 프로토콜에는 ping/pong 프레임이 내장되어 있다. ws 라이브러리는 이를 직접 제어할 수 있게 해준다. 연결이 끊어졌는데도 서버가 인지하지 못하는 "좀비 연결" 문제를 방지하려면 주기적인 핑 체크가 필요하다.

typescript
onModuleInit() {
  // ... WebSocket 서버 설정 후

  // 30초마다 핑 체크
  const interval = setInterval(() => {
    this.wss.clients.forEach((ws: any) => {
      if (ws.isAlive === false) {
        return ws.terminate(); // 응답 없으면 강제 종료
      }
      ws.isAlive = false;
      ws.ping(); // 핑 전송
    });
  }, 30000);

  this.wss.on('connection', (ws: any) => {
    ws.isAlive = true;
    ws.on('pong', () => {
      ws.isAlive = true; // 퐁 응답 수신 → 살아있음
    });
  });

  this.wss.on('close', () => clearInterval(interval));
}

동작 원리:

  1. 각 클라이언트에 isAlive 플래그를 부여한다
  2. 30초마다 모든 클라이언트에게 ping을 보내고 isAlive를 false로 설정한다
  3. 클라이언트가 pong으로 응답하면 isAlive가 다시 true가 된다
  4. 다음 체크 시 isAlive가 여전히 false면 연결이 죽은 것으로 판단하고 terminate()한다

close()terminate()의 차이도 알아야 한다. close()는 정상적인 클로즈 핸드셰이크를 수행하고, terminate()는 TCP 연결을 즉시 끊는다. 응답이 없는 좀비 연결에는 terminate()를 써야 한다.


포트 공유 vs 별도 포트

ws 서버를 설정할 때 두 가지 선택지가 있다.

같은 포트 (HTTP 서버 공유)

typescript
const server = this.httpAdapterHost.httpAdapter.getHttpServer();
const wss = new WebSocketServer({ server, path: '/ws' });

HTTP 서버의 upgrade 이벤트를 가로채서 WebSocket 핸드셰이크를 처리한다. 하나의 포트만 열면 되므로 배포가 간단하고, 리버스 프록시 설정도 단순해진다. NestJS에서는 이 방식이 일반적이다.

별도 포트

typescript
const wss = new WebSocketServer({ port: 8080 });

WebSocket 전용 포트를 따로 연다. HTTP 트래픽과 완전히 분리되므로 부하 분산에 유리할 수 있지만, 방화벽이나 프록시 설정이 추가로 필요하다. Gateway 방식에서는 @WebSocketGateway(8080)처럼 포트를 직접 지정할 수 있다.


클라이언트 측 연결

브라우저에서 ws 서버에 연결하는 코드는 네이티브 WebSocket API만으로 충분하다.

javascript
const ws = new WebSocket('ws://localhost:3000/ws');

ws.onopen = () => {
  console.log('연결됨');

  // Gateway 방식: { event, data } 형식
  ws.send(JSON.stringify({
    event: 'ping',
    data: { timestamp: Date.now() },
  }));
};

ws.onmessage = (event) => {
  const { event: type, data } = JSON.parse(event.data);
  console.log(`${type}:`, data);
};

ws.onclose = (event) => {
  console.log(`연결 종료: ${event.code} ${event.reason}`);
};

ws.onerror = (error) => {
  console.error('에러:', error);
};

socket.io와 달리 자동 재연결 기능이 없으므로, 필요하면 직접 구현해야 한다.

javascript
function connect() {
  const ws = new WebSocket('ws://localhost:3000/ws');

  ws.onclose = () => {
    console.log('연결 끊김, 3초 후 재연결...');
    setTimeout(connect, 3000);
  };

  ws.onopen = () => {
    console.log('연결됨');
  };

  return ws;
}

프로덕션에서는 지수 백오프(exponential backoff)를 적용하는 것이 좋다. 서버가 다운된 상태에서 모든 클라이언트가 동시에 재연결을 시도하면 서버가 복구되자마자 다시 과부하가 걸릴 수 있기 때문이다.

javascript
function connectWithBackoff(attempt = 0) {
  const ws = new WebSocket('ws://localhost:3000/ws');
  const maxDelay = 30000;

  ws.onopen = () => {
    console.log('연결됨');
    attempt = 0; // 연결 성공하면 카운터 리셋
  };

  ws.onclose = () => {
    const delay = Math.min(1000 * 2 ** attempt, maxDelay);
    const jitter = delay * (0.5 + Math.random() * 0.5);
    console.log(`${Math.round(jitter)}ms 후 재연결...`);
    setTimeout(() => connectWithBackoff(attempt + 1), jitter);
  };
}

정리

선택추천 상황
Gateway + WsAdapterNestJS 생태계(Guard, Pipe 등)를 활용하고 싶을 때. 대부분의 경우
Raw ws + HttpAdapterHost메시지 형식을 완전히 제어해야 하거나, 바이너리 데이터를 직접 다룰 때
socket.io (IoAdapter)네임스페이스, 룸, 자동 재연결 등 고수준 기능이 필요할 때

NestJS에서 ws를 사용하는 가장 일반적인 패턴은 Gateway + WsAdapter 조합이다. NestJS의 데코레이터 기반 구조를 그대로 활용하면서도 네이티브 WebSocket의 가벼움을 누릴 수 있다. 더 세밀한 제어가 필요한 경우에만 HttpAdapterHost를 통한 Raw 방식을 고려하면 된다.


관련 문서