junyeokk
Blog
NestJS·2024. 11. 20

NestJS WebSocket Gateway

HTTP는 클라이언트가 요청해야만 서버가 응답하는 단방향 통신이다. 채팅처럼 서버가 먼저 데이터를 보내야 하는 경우에는 이 구조가 맞지 않는다. 폴링으로 해결할 수는 있지만, 주기적으로 요청을 보내야 하니 지연이 생기고 불필요한 요청이 쌓인다.

WebSocket은 이 문제를 해결한다. 한 번 연결하면 양방향 통신이 가능해서 서버와 클라이언트가 자유롭게 메시지를 주고받을 수 있다. NestJS에서는 @WebSocketGateway 데코레이터를 사용해서 WebSocket 서버를 선언적으로 구현할 수 있다.


Gateway vs Controller

NestJS에서 HTTP 요청은 Controller가 처리하고, WebSocket 연결은 Gateway가 처리한다. 역할은 비슷하지만 동작 방식이 다르다.

구분ControllerGateway
프로토콜HTTPWebSocket
통신 방식요청-응답양방향 지속 연결
데코레이터@Controller()@WebSocketGateway()
메서드 라우팅@Get(), @Post()@SubscribeMessage()
수명 주기요청마다 생성/소멸연결 유지 동안 살아있음

Gateway는 기본적으로 NestJS의 DI 시스템에 통합되어 있어서, Service나 Repository를 그대로 주입받아 사용할 수 있다. HTTP 컨트롤러와 같은 방식으로 비즈니스 로직을 분리할 수 있다는 점이 장점이다.


기본 구조

Gateway를 만들려면 @WebSocketGateway() 데코레이터를 클래스에 붙이면 된다.

typescript
import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

@WebSocketGateway()
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  handleConnection(client: Socket) {
    console.log(`클라이언트 연결: ${client.id}`);
  }

  handleDisconnect(client: Socket) {
    console.log(`클라이언트 연결 해제: ${client.id}`);
  }

  @SubscribeMessage('message')
  handleMessage(client: Socket, payload: { text: string }) {
    // 모든 클라이언트에게 브로드캐스트
    this.server.emit('message', {
      userId: client.id,
      text: payload.text,
      timestamp: new Date(),
    });
  }
}

각 요소를 하나씩 살펴보자.

@WebSocketGateway()

WebSocket 서버를 생성하는 데코레이터다. 옵션으로 포트, CORS, 경로 등을 설정할 수 있다.

typescript
@WebSocketGateway({
  cors: {
    origin: 'https://my-app.com',
  },
  path: '/chat',
  namespace: '/live',
})
옵션설명기본값
corsCORS 설정 (origin, credentials 등)없음
pathWebSocket 핸드셰이크 경로/socket.io
namespaceSocket.IO 네임스페이스/
transports전송 방식 (['websocket', 'polling'])둘 다

pathnamespace는 헷갈리기 쉽다. path는 HTTP 핸드셰이크가 일어나는 실제 URL 경로이고, namespace는 그 위에서 논리적으로 채널을 나누는 개념이다. 하나의 path 위에 여러 namespace를 둘 수 있다.

@WebSocketServer()

Gateway 내부에서 Socket.IO의 Server 인스턴스에 접근하기 위한 데코레이터다. 이걸 통해 연결된 모든 클라이언트에게 메시지를 보내거나, 특정 room에 emit할 수 있다.

typescript
@WebSocketServer()
server: Server;

// 전체 브로드캐스트
this.server.emit('event', data);

// 특정 room에만
this.server.to('room-1').emit('event', data);

@SubscribeMessage()

클라이언트가 보내는 특정 이벤트를 수신하는 데코레이터다. HTTP의 @Get('path')처럼, 이벤트 이름으로 라우팅한다.

typescript
@SubscribeMessage('message')
handleMessage(client: Socket, payload: any) {
  return { event: 'response', data: 'received' };
}

반환값이 있으면 해당 클라이언트에게 자동으로 응답이 전송된다. eventdata 속성을 가진 객체를 반환하면 클라이언트가 해당 이벤트로 응답을 받는다. 브로드캐스트가 필요하면 this.server.emit()을 직접 호출해야 한다.


연결 수명 주기 인터페이스

Gateway는 세 가지 수명 주기 인터페이스를 제공한다.

typescript
interface OnGatewayConnection {
  handleConnection(client: Socket, ...args: any[]): void;
}

interface OnGatewayDisconnect {
  handleDisconnect(client: Socket): void;
}

interface OnGatewayInit {
  afterInit(server: Server): void;
}
인터페이스호출 시점용도
OnGatewayInitGateway 초기화 후서버 설정, 미들웨어 등록
OnGatewayConnection클라이언트 연결 시인증, 사용자 등록, 히스토리 전송
OnGatewayDisconnect클라이언트 연결 해제 시정리 작업, 사용자 수 갱신

실제 사용 예를 보면, 연결 시점에 채팅 히스토리를 전송하고 연결 수를 관리하는 패턴이 흔하다.

typescript
async handleConnection(client: Socket) {
  const userCount = this.server.engine.clientsCount;

  // 최대 접속자 초과 시 연결 거부
  if (userCount > MAX_CLIENTS) {
    client.emit('error', { message: '서버가 꽉 찼습니다' });
    client.disconnect(true);
    return;
  }

  // 채팅 히스토리 전송
  const history = await this.chatService.getChatHistory();
  client.emit('chatHistory', history);

  // 접속자 수 브로드캐스트
  this.server.emit('updateUserCount', { userCount });
}

handleConnection에서 client.disconnect(true)를 호출하면 연결을 강제로 끊을 수 있다. 이때 true를 전달하면 클라이언트에게 close 패킷을 보낸 뒤 연결을 닫는다.


Socket.IO 어댑터

NestJS의 WebSocket Gateway는 기본적으로 Socket.IO를 어댑터로 사용한다. 순수 WebSocket(ws 라이브러리)을 쓸 수도 있지만, Socket.IO가 제공하는 기능이 많아서 대부분 Socket.IO를 선택한다.

Socket.IO vs 순수 WebSocket

기능Socket.IOws
자동 재연결❌ (직접 구현)
Room/Namespace
폴백 (polling)
바이너리 지원
브라우저 호환성높음WebSocket 미지원 시 불가
오버헤드약간 있음최소

Socket.IO는 WebSocket 위에 자체 프로토콜 레이어를 얹는다. 연결이 불안정한 환경에서 자동으로 폴링으로 폴백하고, 재연결 로직도 내장되어 있다. 단, 클라이언트도 Socket.IO 클라이언트를 사용해야 한다. 일반 WebSocket 클라이언트로는 연결할 수 없다.

어댑터를 변경하려면 패키지를 설치하고 main.ts에서 설정한다.

typescript
// Socket.IO (기본)
import { IoAdapter } from '@nestjs/platform-socket.io';
app.useWebSocketAdapter(new IoAdapter(app));

// ws 라이브러리
import { WsAdapter } from '@nestjs/platform-ws';
app.useWebSocketAdapter(new WsAdapter(app));

Room과 Namespace

Socket.IO에서 메시지를 특정 그룹에만 보내야 할 때 Room과 Namespace를 사용한다.

Namespace

논리적인 통신 채널이다. 같은 서버에서 기능별로 연결을 분리할 때 쓴다.

typescript
// 채팅용 Gateway
@WebSocketGateway({ namespace: '/chat' })
export class ChatGateway { ... }

// 알림용 Gateway
@WebSocketGateway({ namespace: '/notification' })
export class NotificationGateway { ... }

클라이언트에서는 namespace를 지정해서 연결한다.

typescript
const chatSocket = io('http://localhost:3000/chat');
const notiSocket = io('http://localhost:3000/notification');

Room

Namespace 안에서 더 세분화된 그룹이다. 클라이언트를 동적으로 Room에 넣고 빼면서 특정 그룹에만 메시지를 보낼 수 있다.

typescript
// 클라이언트를 room에 참가시킴
client.join('room-123');

// 해당 room에만 메시지 전송
this.server.to('room-123').emit('message', data);

// room에서 퇴장
client.leave('room-123');

// 특정 room에 있는 클라이언트 수 확인
const clients = await this.server.in('room-123').fetchSockets();
console.log(clients.length);

Namespace는 정적으로 나누고, Room은 동적으로 나눈다. 채팅 서비스를 예로 들면, /chat namespace에 room-123, room-456 같은 채팅방을 Room으로 관리한다.


모듈 등록

Gateway도 NestJS의 provider이기 때문에 모듈에 등록해야 한다.

typescript
import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';
import { ChatService } from './chat.service';

@Module({
  providers: [ChatGateway, ChatService],
})
export class ChatModule {}

Gateway에서 사용하는 Service를 같은 모듈에 등록하면 자동으로 DI가 된다. HTTP 컨트롤러와 동일한 패턴이다.


Guard, Interceptor, Pipe 적용

Gateway에도 HTTP와 동일한 방식으로 Guard, Pipe, Interceptor를 적용할 수 있다.

typescript
@WebSocketGateway()
@UseGuards(WsAuthGuard)
@UseInterceptors(LoggingInterceptor)
export class ChatGateway {

  @SubscribeMessage('message')
  @UsePipes(new ValidationPipe())
  handleMessage(client: Socket, payload: CreateMessageDto) {
    // payload가 DTO로 검증됨
  }
}

단, WebSocket에서의 Guard는 HTTP와 약간 다르게 동작한다. ExecutionContext에서 switchToWs()를 호출해야 클라이언트와 데이터에 접근할 수 있다.

typescript
@Injectable()
export class WsAuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const client = context.switchToWs().getClient<Socket>();
    const token = client.handshake.auth?.token;
    // 토큰 검증 로직
    return !!token;
  }
}

client.handshake에서 연결 시 전달된 인증 정보, 쿼리 파라미터, 헤더 등에 접근할 수 있다. 클라이언트에서 연결할 때 auth 옵션으로 토큰을 전달하는 패턴이 일반적이다.

typescript
// 클라이언트
const socket = io('http://localhost:3000', {
  auth: {
    token: 'jwt-token-here',
  },
});

예외 처리

WebSocket에서 예외가 발생하면 HTTP처럼 상태 코드를 반환할 수 없다. 대신 WsException을 throw하면 클라이언트가 exception 이벤트로 에러를 수신한다.

typescript
import { WsException } from '@nestjs/websockets';

@SubscribeMessage('message')
handleMessage(client: Socket, payload: any) {
  if (!payload.text) {
    throw new WsException('메시지 내용이 비어있습니다');
  }
  // ...
}

클라이언트에서는 이렇게 수신한다.

typescript
socket.on('exception', (error) => {
  console.log(error.message); // '메시지 내용이 비어있습니다'
});

커스텀 에러 필터를 만들어서 에러 형식을 통일할 수도 있다.

typescript
@Catch(WsException)
export class WsExceptionFilter implements ExceptionFilter {
  catch(exception: WsException, host: ArgumentsHost) {
    const client = host.switchToWs().getClient<Socket>();
    client.emit('error', {
      status: 'error',
      message: exception.message,
    });
  }
}

Prometheus 메트릭 연동

실시간 서비스에서는 동시 접속자 수나 메시지 처리량 같은 메트릭을 수집하는 것이 중요하다. Gateway에 Prometheus 메트릭을 주입해서 모니터링할 수 있다.

typescript
@WebSocketGateway()
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
  constructor(
    @InjectMetric('chat_user_count')
    private readonly userCount: Gauge,
    @InjectMetric('chat_message_count')
    private readonly messageCount: Counter,
  ) {}

  handleConnection() {
    this.userCount.inc({ room: 'anonymous' });
  }

  handleDisconnect() {
    this.userCount.dec({ room: 'anonymous' });
  }

  @SubscribeMessage('message')
  handleMessage(client: Socket, payload: any) {
    this.messageCount.inc({ room: 'anonymous' });
    // ...
  }
}

Gauge는 증가/감소 모두 가능한 메트릭(동시 접속자 수), Counter는 단조 증가만 하는 메트릭(총 메시지 수)에 적합하다. 라벨(room)을 붙이면 채팅방별로 메트릭을 분리해서 수집할 수 있다.


클라이언트 사이드 연결

서버 Gateway를 만들었으면 클라이언트에서 연결해야 한다. Socket.IO 클라이언트를 사용한다.

typescript
import { io } from 'socket.io-client';

const socket = io('http://localhost:3000', {
  path: '/chat',
  auth: { token: 'my-jwt' },
  transports: ['websocket'],
});

// 연결 이벤트
socket.on('connect', () => {
  console.log('연결됨:', socket.id);
});

// 메시지 수신
socket.on('message', (data) => {
  console.log('메시지:', data);
});

// 메시지 발신
socket.emit('message', { text: '안녕하세요' });

// 연결 해제
socket.on('disconnect', (reason) => {
  console.log('연결 해제:', reason);
});

transports: ['websocket']을 명시하면 polling 폴백 없이 바로 WebSocket으로 연결한다. 네트워크가 안정적인 환경에서는 이렇게 설정하는 것이 초기 연결 속도가 빠르다. 기본값은 polling으로 먼저 연결한 뒤 WebSocket으로 업그레이드하는 방식이다.


정리

  • @WebSocketGateway로 선언적으로 WebSocket 서버를 구현하고, DI·Guard·Pipe·Interceptor를 HTTP와 동일하게 적용할 수 있다
  • Room으로 동적 그루핑, Namespace로 기능별 정적 분리를 조합해서 메시지 라우팅을 설계한다
  • 인증은 handshake.auth에서 토큰을 검증하고, 예외는 WsException으로 클라이언트에 전달한다

관련 문서