NestJS WebSocket Gateway
HTTP는 클라이언트가 요청해야만 서버가 응답하는 단방향 통신이다. 채팅처럼 서버가 먼저 데이터를 보내야 하는 경우에는 이 구조가 맞지 않는다. 폴링으로 해결할 수는 있지만, 주기적으로 요청을 보내야 하니 지연이 생기고 불필요한 요청이 쌓인다.
WebSocket은 이 문제를 해결한다. 한 번 연결하면 양방향 통신이 가능해서 서버와 클라이언트가 자유롭게 메시지를 주고받을 수 있다. NestJS에서는 @WebSocketGateway 데코레이터를 사용해서 WebSocket 서버를 선언적으로 구현할 수 있다.
Gateway vs Controller
NestJS에서 HTTP 요청은 Controller가 처리하고, WebSocket 연결은 Gateway가 처리한다. 역할은 비슷하지만 동작 방식이 다르다.
| 구분 | Controller | Gateway |
|---|---|---|
| 프로토콜 | HTTP | WebSocket |
| 통신 방식 | 요청-응답 | 양방향 지속 연결 |
| 데코레이터 | @Controller() | @WebSocketGateway() |
| 메서드 라우팅 | @Get(), @Post() 등 | @SubscribeMessage() |
| 수명 주기 | 요청마다 생성/소멸 | 연결 유지 동안 살아있음 |
Gateway는 기본적으로 NestJS의 DI 시스템에 통합되어 있어서, Service나 Repository를 그대로 주입받아 사용할 수 있다. HTTP 컨트롤러와 같은 방식으로 비즈니스 로직을 분리할 수 있다는 점이 장점이다.
기본 구조
Gateway를 만들려면 @WebSocketGateway() 데코레이터를 클래스에 붙이면 된다.
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, 경로 등을 설정할 수 있다.
@WebSocketGateway({
cors: {
origin: 'https://my-app.com',
},
path: '/chat',
namespace: '/live',
})
| 옵션 | 설명 | 기본값 |
|---|---|---|
cors | CORS 설정 (origin, credentials 등) | 없음 |
path | WebSocket 핸드셰이크 경로 | /socket.io |
namespace | Socket.IO 네임스페이스 | / |
transports | 전송 방식 (['websocket', 'polling']) | 둘 다 |
path와 namespace는 헷갈리기 쉽다. path는 HTTP 핸드셰이크가 일어나는 실제 URL 경로이고, namespace는 그 위에서 논리적으로 채널을 나누는 개념이다. 하나의 path 위에 여러 namespace를 둘 수 있다.
@WebSocketServer()
Gateway 내부에서 Socket.IO의 Server 인스턴스에 접근하기 위한 데코레이터다. 이걸 통해 연결된 모든 클라이언트에게 메시지를 보내거나, 특정 room에 emit할 수 있다.
@WebSocketServer()
server: Server;
// 전체 브로드캐스트
this.server.emit('event', data);
// 특정 room에만
this.server.to('room-1').emit('event', data);
@SubscribeMessage()
클라이언트가 보내는 특정 이벤트를 수신하는 데코레이터다. HTTP의 @Get('path')처럼, 이벤트 이름으로 라우팅한다.
@SubscribeMessage('message')
handleMessage(client: Socket, payload: any) {
return { event: 'response', data: 'received' };
}
반환값이 있으면 해당 클라이언트에게 자동으로 응답이 전송된다. event와 data 속성을 가진 객체를 반환하면 클라이언트가 해당 이벤트로 응답을 받는다. 브로드캐스트가 필요하면 this.server.emit()을 직접 호출해야 한다.
연결 수명 주기 인터페이스
Gateway는 세 가지 수명 주기 인터페이스를 제공한다.
interface OnGatewayConnection {
handleConnection(client: Socket, ...args: any[]): void;
}
interface OnGatewayDisconnect {
handleDisconnect(client: Socket): void;
}
interface OnGatewayInit {
afterInit(server: Server): void;
}
| 인터페이스 | 호출 시점 | 용도 |
|---|---|---|
OnGatewayInit | Gateway 초기화 후 | 서버 설정, 미들웨어 등록 |
OnGatewayConnection | 클라이언트 연결 시 | 인증, 사용자 등록, 히스토리 전송 |
OnGatewayDisconnect | 클라이언트 연결 해제 시 | 정리 작업, 사용자 수 갱신 |
실제 사용 예를 보면, 연결 시점에 채팅 히스토리를 전송하고 연결 수를 관리하는 패턴이 흔하다.
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.IO | ws |
|---|---|---|
| 자동 재연결 | ✅ | ❌ (직접 구현) |
| Room/Namespace | ✅ | ❌ |
| 폴백 (polling) | ✅ | ❌ |
| 바이너리 지원 | ✅ | ✅ |
| 브라우저 호환성 | 높음 | WebSocket 미지원 시 불가 |
| 오버헤드 | 약간 있음 | 최소 |
Socket.IO는 WebSocket 위에 자체 프로토콜 레이어를 얹는다. 연결이 불안정한 환경에서 자동으로 폴링으로 폴백하고, 재연결 로직도 내장되어 있다. 단, 클라이언트도 Socket.IO 클라이언트를 사용해야 한다. 일반 WebSocket 클라이언트로는 연결할 수 없다.
어댑터를 변경하려면 패키지를 설치하고 main.ts에서 설정한다.
// 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
논리적인 통신 채널이다. 같은 서버에서 기능별로 연결을 분리할 때 쓴다.
// 채팅용 Gateway
@WebSocketGateway({ namespace: '/chat' })
export class ChatGateway { ... }
// 알림용 Gateway
@WebSocketGateway({ namespace: '/notification' })
export class NotificationGateway { ... }
클라이언트에서는 namespace를 지정해서 연결한다.
const chatSocket = io('http://localhost:3000/chat');
const notiSocket = io('http://localhost:3000/notification');
Room
Namespace 안에서 더 세분화된 그룹이다. 클라이언트를 동적으로 Room에 넣고 빼면서 특정 그룹에만 메시지를 보낼 수 있다.
// 클라이언트를 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이기 때문에 모듈에 등록해야 한다.
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를 적용할 수 있다.
@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()를 호출해야 클라이언트와 데이터에 접근할 수 있다.
@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 옵션으로 토큰을 전달하는 패턴이 일반적이다.
// 클라이언트
const socket = io('http://localhost:3000', {
auth: {
token: 'jwt-token-here',
},
});
예외 처리
WebSocket에서 예외가 발생하면 HTTP처럼 상태 코드를 반환할 수 없다. 대신 WsException을 throw하면 클라이언트가 exception 이벤트로 에러를 수신한다.
import { WsException } from '@nestjs/websockets';
@SubscribeMessage('message')
handleMessage(client: Socket, payload: any) {
if (!payload.text) {
throw new WsException('메시지 내용이 비어있습니다');
}
// ...
}
클라이언트에서는 이렇게 수신한다.
socket.on('exception', (error) => {
console.log(error.message); // '메시지 내용이 비어있습니다'
});
커스텀 에러 필터를 만들어서 에러 형식을 통일할 수도 있다.
@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 메트릭을 주입해서 모니터링할 수 있다.
@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 클라이언트를 사용한다.
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으로 클라이언트에 전달한다