NestJS WebSocket Gateway

NestJS WebSocket Gateway란? — 실시간 양방향 통신의 선언적 설계

채팅, 실시간 알림, 라이브 대시보드, 협업 편집기 — 모두 서버가 클라이언트에게 먼저 데이터를 푸시해야 하는 기능이다. HTTP의 요청-응답 모델로는 한계가 있고, WebSocket이 필요하다. NestJS는 @WebSocketGateway() 데코레이터로 WebSocket 서버를 REST 컨트롤러처럼 선언적으로 구성할 수 있다.

NestJS의 Gateway는 플랫폼 독립적이다. 기본 어댑터는 Socket.IO이지만, ws 라이브러리나 커스텀 어댑터로 교체할 수 있다. NestJS Exception Filter 심화에서 다룬 것처럼, Guard·Pipe·Interceptor·Filter가 WebSocket에서도 동일하게 동작하므로 HTTP와 WebSocket 간 코드 재사용이 가능하다.

1. 기본 설정 — Gateway 생성과 이벤트 핸들링

# 의존성 설치
npm install @nestjs/websockets @nestjs/platform-socket.io
npm install -D @types/socket.io
import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  MessageBody,
  ConnectedSocket,
  OnGatewayInit,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

@WebSocketGateway({
  cors: {
    origin: ['https://app.example.com'],    // CORS 설정
    credentials: true,
  },
  namespace: '/chat',                        // 네임스페이스 분리
  transports: ['websocket'],                 // polling 비활성화 (선택)
})
export class ChatGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  @WebSocketServer()
  server: Server;                            // Socket.IO Server 인스턴스

  afterInit(server: Server) {
    console.log('WebSocket Gateway initialized');
  }

  handleConnection(client: Socket) {
    console.log(`Client connected: ${client.id}`);
  }

  handleDisconnect(client: Socket) {
    console.log(`Client disconnected: ${client.id}`);
  }

  // 이벤트 핸들러 — HTTP의 @Get(), @Post()와 동일한 역할
  @SubscribeMessage('sendMessage')
  handleMessage(
    @MessageBody() data: { roomId: string; content: string },
    @ConnectedSocket() client: Socket,
  ) {
    // 같은 방의 다른 클라이언트에게 브로드캐스트
    client.to(data.roomId).emit('newMessage', {
      sender: client.id,
      content: data.content,
      timestamp: new Date().toISOString(),
    });

    // 반환값은 자동으로 클라이언트에 ACK 응답
    return { status: 'ok', messageId: generateId() };
  }
}

핵심 포인트: @SubscribeMessage('이벤트명')은 HTTP의 @Get('/경로')에 대응한다. 반환값은 클라이언트의 콜백(acknowledgement)으로 전달된다. 명시적으로 client.emit()을 호출하면 별도의 이벤트를 발행한다.

2. Room 기반 메시징 — 채팅방·그룹 알림 구현

Socket.IO의 Room은 클라이언트를 논리적 그룹으로 묶는 메커니즘이다. 특정 Room에 속한 클라이언트에게만 메시지를 보낼 수 있다.

@WebSocketGateway({ namespace: '/chat' })
export class ChatGateway {
  @WebSocketServer()
  server: Server;

  // Room 입장
  @SubscribeMessage('joinRoom')
  handleJoinRoom(
    @MessageBody() data: { roomId: string },
    @ConnectedSocket() client: Socket,
  ) {
    client.join(data.roomId);

    // 방에 있는 다른 사람들에게 알림
    client.to(data.roomId).emit('userJoined', {
      userId: client.data.userId,           // handshake에서 저장한 유저 정보
      roomId: data.roomId,
    });

    return { status: 'joined', roomId: data.roomId };
  }

  // Room 퇴장
  @SubscribeMessage('leaveRoom')
  handleLeaveRoom(
    @MessageBody() data: { roomId: string },
    @ConnectedSocket() client: Socket,
  ) {
    client.leave(data.roomId);
    client.to(data.roomId).emit('userLeft', {
      userId: client.data.userId,
      roomId: data.roomId,
    });
    return { status: 'left' };
  }

  // 특정 Room에 메시지 전송 (서버 측에서 호출)
  sendToRoom(roomId: string, event: string, data: any) {
    this.server.to(roomId).emit(event, data);
  }

  // 특정 유저에게 1:1 메시지 (소켓 ID 기반)
  sendToUser(socketId: string, event: string, data: any) {
    this.server.to(socketId).emit(event, data);
  }
}

emit 메서드별 전송 범위

메서드 전송 대상 용도
server.emit() 네임스페이스의 모든 클라이언트 전체 공지
server.to(room).emit() 특정 Room의 모든 클라이언트 채팅방 메시지
client.to(room).emit() Room의 다른 클라이언트 (본인 제외) 브로드캐스트
client.emit() 해당 클라이언트만 1:1 응답
server.to(socketId).emit() 특정 소켓 ID의 클라이언트 DM, 개인 알림

3. Guard·Pipe·Interceptor 통합 — HTTP 코드 재사용

NestJS의 실행 파이프라인(Guard → Interceptor → Pipe → Handler → Interceptor → Filter)은 WebSocket에서도 동일하게 동작한다. NestJS Guard 심화에서 만든 Guard를 WebSocket에 그대로 쓸 수 있다.

3-1. WsAuthGuard — WebSocket 인증

@Injectable()
export class WsAuthGuard implements CanActivate {
  constructor(private readonly jwtService: JwtService) {}

  canActivate(context: ExecutionContext): boolean {
    // WebSocket에서는 switchToWs()로 클라이언트 접근
    const client = context.switchToWs().getClient<Socket>();

    // handshake 시 전달된 토큰 검증
    const token =
      client.handshake.auth?.token ||
      client.handshake.headers?.authorization?.split(' ')[1];

    if (!token) {
      throw new WsException('인증 토큰이 없습니다');
    }

    try {
      const payload = this.jwtService.verify(token);
      client.data.userId = payload.sub;         // 소켓에 유저 정보 저장
      client.data.roles = payload.roles;
      return true;
    } catch {
      throw new WsException('유효하지 않은 토큰입니다');
    }
  }
}

// Gateway에 적용
@UseGuards(WsAuthGuard)
@WebSocketGateway({ namespace: '/chat' })
export class ChatGateway {
  // 모든 이벤트 핸들러에 인증 적용
}

3-2. Validation Pipe — 메시지 유효성 검증

// DTO 정의
export class SendMessageDto {
  @IsString()
  @IsNotEmpty()
  roomId: string;

  @IsString()
  @MinLength(1)
  @MaxLength(2000)
  content: string;
}

// Gateway에서 Pipe 적용
@SubscribeMessage('sendMessage')
@UsePipes(new ValidationPipe({ whitelist: true, transform: true }))
handleMessage(
  @MessageBody() data: SendMessageDto,       // 자동 검증 + 변환
  @ConnectedSocket() client: Socket,
) {
  // data는 이미 검증 완료
  return this.chatService.sendMessage(client.data.userId, data);
}

3-3. WsExceptionFilter — WebSocket 에러 처리

@Catch()
export class WsExceptionFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const wsHost = host.switchToWs();
    const client = wsHost.getClient<Socket>();

    const error =
      exception instanceof WsException
        ? { status: 'error', message: exception.message }
        : exception instanceof HttpException
          ? { status: 'error', message: exception.message, code: exception.getStatus() }
          : { status: 'error', message: 'Internal server error' };

    // 에러를 클라이언트에게 전송
    client.emit('exception', error);
  }
}

// 전역 또는 Gateway 레벨에서 적용
@UseFilters(WsExceptionFilter)
@WebSocketGateway({ namespace: '/chat' })
export class ChatGateway { ... }

4. handleConnection에서 인증 — 연결 단계 차단

Guard는 각 이벤트 핸들러 호출 시마다 실행된다. 하지만 연결 자체를 거부하려면 handleConnection에서 인증하거나 Socket.IO 미들웨어를 사용해야 한다.

@WebSocketGateway({ namespace: '/chat' })
export class ChatGateway implements OnGatewayConnection {
  constructor(private readonly jwtService: JwtService) {}

  handleConnection(client: Socket) {
    try {
      const token =
        client.handshake.auth?.token ||
        client.handshake.headers?.authorization?.split(' ')[1];

      if (!token) throw new Error('No token');

      const payload = this.jwtService.verify(token);
      client.data.userId = payload.sub;
      client.data.roles = payload.roles;

      // 유저별 Room에 자동 입장 (1:1 메시지용)
      client.join(`user:${payload.sub}`);

    } catch (error) {
      client.emit('exception', { message: 'Authentication failed' });
      client.disconnect(true);               // 연결 즉시 해제
    }
  }
}

// 또는 Socket.IO 미들웨어 방식 (afterInit에서 등록)
afterInit(server: Server) {
  server.use((socket, next) => {
    const token = socket.handshake.auth?.token;
    if (!token) return next(new Error('Authentication required'));

    try {
      const payload = this.jwtService.verify(token);
      socket.data.userId = payload.sub;
      next();
    } catch {
      next(new Error('Invalid token'));
    }
  });
}

5. 서비스에서 Gateway 이벤트 발행 — HTTP → WebSocket 브릿지

REST API에서 데이터가 변경되면 WebSocket으로 실시간 알림을 보내야 하는 경우가 많다. Gateway를 다른 서비스에 주입하여 사용한다.

// Gateway를 Injectable로 사용
@WebSocketGateway({ namespace: '/notifications' })
export class NotificationGateway {
  @WebSocketServer()
  server: Server;

  // 외부 서비스가 호출하는 메서드
  sendToUser(userId: string, event: string, data: any) {
    this.server.to(`user:${userId}`).emit(event, data);
  }

  sendToAll(event: string, data: any) {
    this.server.emit(event, data);
  }
}

// 주문 서비스에서 사용
@Injectable()
export class OrderService {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly notificationGateway: NotificationGateway,  // Gateway 주입
  ) {}

  async createOrder(userId: string, dto: CreateOrderDto) {
    const order = await this.orderRepository.create(dto);

    // REST API 처리 후 WebSocket으로 실시간 알림
    this.notificationGateway.sendToUser(userId, 'orderCreated', {
      orderId: order.id,
      status: order.status,
    });

    return order;
  }

  async updateOrderStatus(orderId: string, status: OrderStatus) {
    const order = await this.orderRepository.update(orderId, { status });

    // 주문 상태 변경을 실시간으로 푸시
    this.notificationGateway.sendToUser(order.userId, 'orderStatusChanged', {
      orderId: order.id,
      status,
      updatedAt: new Date().toISOString(),
    });

    return order;
  }
}

6. Adapter 교체 — Socket.IO vs ws

Socket.IO가 기본이지만, 순수 WebSocket이 필요하면 ws 어댑터로 교체할 수 있다.

// main.ts에서 어댑터 교체
import { WsAdapter } from '@nestjs/platform-ws';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useWebSocketAdapter(new WsAdapter(app));   // ws 어댑터 사용
  await app.listen(3000);
}
기준 Socket.IO (기본) ws
프로토콜 Socket.IO 프로토콜 (HTTP 롱폴링 폴백) 순수 WebSocket (RFC 6455)
Room/Namespace 내장 지원 직접 구현 필요
자동 재연결 클라이언트 라이브러리에 내장 직접 구현
ACK (응답 확인) 내장 지원 직접 구현
바이너리 전송 자동 직렬화 네이티브 지원
클라이언트 socket.io-client 필수 브라우저 네이티브 WebSocket 사용 가능

선택 기준: Room, 네임스페이스, 자동 재연결이 필요하면 Socket.IO. 브라우저 네이티브 WebSocket이나 경량 바이너리 프로토콜이 필요하면 ws. 대부분의 경우 Socket.IO가 생산성이 높다.

7. 다중 인스턴스 — Redis Adapter로 스케일아웃

Kubernetes에서 Pod이 여러 개일 때, 클라이언트 A가 Pod-1에, 클라이언트 B가 Pod-2에 연결되면 server.to(room).emit()이 같은 Pod의 클라이언트에게만 전달된다. Redis Adapter로 Pod 간 메시지를 동기화해야 한다.

# 의존성
npm install @socket.io/redis-adapter redis
// redis-io.adapter.ts
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
import { ServerOptions } from 'socket.io';
import { INestApplication } from '@nestjs/common';

export class RedisIoAdapter extends IoAdapter {
  private adapterConstructor: ReturnType<typeof createAdapter>;

  constructor(app: INestApplication, private readonly redisUrl: string) {
    super(app);
  }

  async connectToRedis(): Promise<void> {
    const pubClient = createClient({ url: this.redisUrl });
    const subClient = pubClient.duplicate();

    await Promise.all([pubClient.connect(), subClient.connect()]);

    this.adapterConstructor = createAdapter(pubClient, subClient);
  }

  createIOServer(port: number, options?: ServerOptions) {
    const server = super.createIOServer(port, options);
    server.adapter(this.adapterConstructor);
    return server;
  }
}

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const redisAdapter = new RedisIoAdapter(
    app,
    process.env.REDIS_URL || 'redis://localhost:6379',
  );
  await redisAdapter.connectToRedis();
  app.useWebSocketAdapter(redisAdapter);

  await app.listen(3000);
}

Redis Adapter는 Socket.IO의 emit 호출을 Redis Pub/Sub으로 전파한다. Pod-1에서 server.to('room-1').emit()을 호출하면, Redis를 통해 Pod-2도 해당 Room의 클라이언트에게 메시지를 전달한다. Redis Sentinel 심화에서 다룬 것처럼, 운영 환경에서는 Redis Sentinel 또는 Cluster를 연결하라.

8. Kubernetes 운영 — Sticky Session과 Ingress 설정

Socket.IO의 HTTP 롱폴링 폴백을 쓸 때, 같은 클라이언트의 요청이 항상 같은 Pod으로 가야 한다(sticky session). WebSocket-only 모드라면 sticky session이 불필요하지만, 안전을 위해 설정하는 것이 좋다.

# ingress-nginx 설정
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ws-ingress
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"       # WebSocket 유지
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    nginx.ingress.kubernetes.io/upstream-hash-by: "$request_uri"  # sticky routing
    nginx.ingress.kubernetes.io/affinity: "cookie"                # 쿠키 기반 sticky
    nginx.ingress.kubernetes.io/affinity-mode: "persistent"
    nginx.ingress.kubernetes.io/session-cookie-name: "ws-sticky"
    nginx.ingress.kubernetes.io/configuration-snippet: |
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
spec:
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /socket.io
            pathType: Prefix
            backend:
              service:
                name: chat-service
                port:
                  number: 3000

9. Heartbeat와 Graceful Shutdown

@WebSocketGateway({
  namespace: '/chat',
  pingInterval: 25000,       // 25초마다 ping
  pingTimeout: 20000,        // 20초 내 pong 없으면 연결 해제
})
export class ChatGateway implements OnGatewayDisconnect {
  private readonly connectedUsers = new Map<string, Set<string>>();

  handleConnection(client: Socket) {
    const userId = client.data.userId;
    if (!this.connectedUsers.has(userId)) {
      this.connectedUsers.set(userId, new Set());
    }
    this.connectedUsers.get(userId).add(client.id);
  }

  handleDisconnect(client: Socket) {
    const userId = client.data.userId;
    this.connectedUsers.get(userId)?.delete(client.id);
    if (this.connectedUsers.get(userId)?.size === 0) {
      this.connectedUsers.delete(userId);
      // 유저의 모든 소켓이 끊어졌을 때만 오프라인 처리
      this.server.emit('userOffline', { userId });
    }
  }

  // 온라인 유저 수
  getOnlineCount(): number {
    return this.connectedUsers.size;
  }

  // Graceful Shutdown — SIGTERM 시 연결 정리
  async onApplicationShutdown(signal?: string) {
    // 모든 클라이언트에게 재연결 안내
    this.server.emit('serverShutdown', {
      message: 'Server is restarting, please reconnect',
      retryAfter: 5000,
    });

    // 연결 해제 대기
    this.server.disconnectSockets(true);
  }
}

10. 테스트 — WebSocket Gateway 단위·통합 테스트

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { io, Socket as ClientSocket } from 'socket.io-client';

describe('ChatGateway (e2e)', () => {
  let app: INestApplication;
  let clientSocket: ClientSocket;

  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = module.createNestApplication();
    await app.listen(0);                     // 랜덤 포트

    const port = app.getHttpServer().address().port;
    clientSocket = io(`http://localhost:${port}/chat`, {
      auth: { token: 'valid-test-token' },
      transports: ['websocket'],
    });

    await new Promise<void>((resolve) => clientSocket.on('connect', resolve));
  });

  afterAll(async () => {
    clientSocket.disconnect();
    await app.close();
  });

  it('메시지 전송 시 ACK 반환', (done) => {
    clientSocket.emit(
      'sendMessage',
      { roomId: 'test-room', content: 'Hello!' },
      (response: any) => {
        expect(response.status).toBe('ok');
        expect(response.messageId).toBeDefined();
        done();
      },
    );
  });

  it('Room 입장 후 브로드캐스트 수신', (done) => {
    const client2 = io(`http://localhost:${port}/chat`, {
      auth: { token: 'valid-test-token-2' },
      transports: ['websocket'],
    });

    client2.on('connect', () => {
      // 두 클라이언트 모두 같은 방에 입장
      clientSocket.emit('joinRoom', { roomId: 'room-1' });
      client2.emit('joinRoom', { roomId: 'room-1' }, () => {
        // client2가 메시지를 보내면 clientSocket이 수신
        clientSocket.on('newMessage', (data) => {
          expect(data.content).toBe('from client2');
          client2.disconnect();
          done();
        });

        client2.emit('sendMessage', {
          roomId: 'room-1',
          content: 'from client2',
        });
      });
    });
  });
});

11. 운영 체크리스트

항목 권장 설정 위반 시 증상
인증 handleConnection 또는 미들웨어에서 연결 시 검증 미인증 클라이언트가 이벤트 수신
다중 인스턴스 Redis Adapter 필수 Pod 간 메시지 전달 안 됨
Ingress WebSocket upgrade + timeout 설정 WebSocket 연결 실패 또는 1분 후 끊김
Validation ValidationPipe로 메시지 검증 악의적 페이로드에 취약
Heartbeat pingInterval/pingTimeout 조정 좀비 연결 누적 → 메모리 누수
Graceful Shutdown SIGTERM 시 클라이언트에 재연결 안내 배포 시 클라이언트 무한 재연결 루프

마무리 — WebSocket도 NestJS답게

NestJS의 WebSocket Gateway는 REST 컨트롤러의 선언적 패턴을 실시간 통신에 그대로 적용한다. @SubscribeMessage로 이벤트를 핸들링하고, Guard·Pipe·Interceptor·Filter로 횡단 관심사를 분리하며, Redis Adapter로 다중 인스턴스를 지원한다.

핵심은 세 가지다. 첫째, 연결 단계에서 인증하여 미인증 클라이언트를 즉시 차단하라. 둘째, Kubernetes 환경에서는 Redis Adapter로 Pod 간 메시지를 동기화하라. 셋째, Ingress의 WebSocket timeout과 sticky session을 반드시 설정하라. 이 세 가지를 갖추면 NestJS Gateway로 프로덕션 수준의 실시간 서비스를 운영할 수 있다.

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