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로 프로덕션 수준의 실시간 서비스를 운영할 수 있다.