WebSocket과 실시간 애플리케이션
지난 절에서는 NestJS 애플리케이션을 서버리스 환경에 배포하는 전략에 대해 알아보았습니다. 이제 12장의 두 번째 절로, 실시간 웹 애플리케이션의 핵심 기술인 WebSocket과 이를 NestJS에서 효과적으로 구현하는 방법에 대해 살펴보겠습니다.
기존의 HTTP는 요청-응답(Request-Response) 모델을 기반으로 하여 클라이언트가 서버에 요청을 보내야만 응답을 받을 수 있습니다. 하지만 채팅 애플리케이션, 실시간 게임, 주식 시세판, 협업 도구 등은 서버에서 클라이언트로 데이터를 즉시 푸시해야 하는 실시간 통신 기능이 필수적입니다. WebSocket은 이러한 요구사항을 충족시키기 위해 등장한 기술입니다.
WebSocket이란?
WebSocket은 웹 브라우저와 웹 서버 간에 전이중(Full-duplex) 통신 채널을 제공하는 통신 프로토콜입니다. HTTP와 달리, 한 번 연결이 수립되면 클라이언트와 서버가 독립적으로 언제든지 데이터를 주고받을 수 있습니다.
WebSocket의 특징
- 지속적인 연결: HTTP는 요청마다 연결을 맺고 끊는 반면, WebSocket은 한 번 핸드셰이크(Handshake)를 통해 연결을 수립하면 이 연결을 지속적으로 유지합니다.
- 전이중 통신: 클라이언트와 서버가 동시에 서로에게 데이터를 보낼 수 있습니다.
- 낮은 오버헤드: HTTP에 비해 헤더 정보가 매우 작아 통신 오버헤드가 적습니다. 이는 실시간으로 많은 양의 작은 데이터를 주고받을 때 효율적입니다.
- HTTP 호환: WebSocket 연결은 HTTP/1.1 업그레이드 헤더를 통해 시작됩니다. 즉, 기존 HTTP 포트(80 또는 443)를 사용하여 방화벽 문제를 피할 수 있습니다.
WebSocket과 HTTP 폴링/롱 폴링 비교
특징 | HTTP (Polling) | HTTP (Long Polling) | WebSocket |
---|---|---|---|
연결 방식 | 클라이언트가 주기적으로 요청 | 클라이언트가 요청 후 서버가 데이터 발생 시 응답 | 지속적인 연결 (한 번 연결 후 유지) |
통신 방향 | 단방향 (클라이언트 -> 서버) | 단방향 (클라이언트 -> 서버, 서버가 지연 응답) | 전이중 (양방향 동시) |
오버헤드 | 높음 (요청/응답마다 전체 HTTP 헤더) | 높음 (요청/응답마다 전체 HTTP 헤더) | 낮음 (최초 핸드셰이크 후 프레임 헤더) |
지연 시간 | 김 (폴링 간격에 따라) | 짧음 (데이터 발생 즉시), 그러나 요청은 새로 보냄 | 매우 짧음 (실시간 푸시) |
복잡성 | 단순 | 서버 측 구현 복잡 | 클라이언트/서버 양측에 상태 관리 필요 |
용도 | 비실시간 데이터 업데이트 | 적은 빈도의 업데이트 | 채팅, 게임, 알림, 실시간 데이터 스트리밍 등 진정한 실시간 통신 |
NestJS에서 WebSocket 구현
NestJS는 @nestjs/websockets
패키지와 @nestjs/platform-socket.io
(또는 @nestjs/platform-ws
)를 통해 WebSocket 기능을 쉽게 구현할 수 있도록 지원합니다. Socket.IO
는 WebSocket 위에 구축된 라이브러리로, WebSocket을 지원하지 않는 브라우저를 위한 폴백(fallback) 기능, 자동 재연결, 이벤트 기반 통신 등 추가적인 기능을 제공하여 실시간 애플리케이션 개발을 용이하게 합니다.
이 절에서는 Socket.IO
를 중심으로 설명하겠습니다.
필요한 패키지 설치
npm install @nestjs/websockets @nestjs/platform-socket.io socket.io
npm install --save-dev @types/socket.io
게이트웨이(Gateway) 생성
NestJS에서는 WebSocket 핸들러를 게이트웨이(Gateway) 라고 부릅니다. 게이트웨이는 @WebSocketGateway()
데코레이터로 정의됩니다.
// src/events/events.gateway.ts
import {
WebSocketGateway,
SubscribeMessage,
MessageBody,
WebSocketServer,
ConnectedSocket,
OnGatewayInit,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
@WebSocketGateway({
cors: {
origin: '*', // 모든 도메인에서의 접속 허용 (운영 환경에서는 특정 도메인으로 제한 권장)
credentials: true,
},
// path: '/socket.io', // Socket.IO 경로 설정 (선택 사항)
})
export class EventsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server; // Socket.IO 서버 인스턴스 주입
private readonly logger = new Logger(EventsGateway.name);
// 게이트웨이 초기화 시 호출
afterInit(server: Server) {
this.logger.log('WebSocket Gateway initialized!');
}
// 클라이언트 연결 시 호출
handleConnection(@ConnectedSocket() client: Socket, ...args: any[]) {
this.logger.log(`Client connected: ${client.id}`);
// 연결된 클라이언트에게 메시지 전송
client.emit('welcome', `Welcome, ${client.id}!`);
}
// 클라이언트 연결 해제 시 호출
handleDisconnect(@ConnectedSocket() client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}
// "message" 이벤트를 구독 (클라이언트가 "message" 이벤트를 보낼 때 실행)
@SubscribeMessage('message')
handleMessage(
@MessageBody() data: string, // 클라이언트로부터 받은 메시지 데이터
@ConnectedSocket() client: Socket, // 연결된 클라이언트 소켓 인스턴스
): string {
this.logger.log(`Message received from ${client.id}: ${data}`);
// 모든 연결된 클라이언트에게 메시지 브로드캐스트
this.server.emit('message', `[${client.id}] ${data}`);
return 'Message received!'; // 클라이언트에게 직접 응답
}
// "joinRoom" 이벤트를 구독하여 특정 방에 조인
@SubscribeMessage('joinRoom')
handleJoinRoom(
@MessageBody('roomId') roomId: string,
@MessageBody('userName') userName: string,
@ConnectedSocket() client: Socket,
) {
client.join(roomId); // 클라이언트를 해당 룸에 조인
this.server.to(roomId).emit('roomMessage', `${userName} has joined room ${roomId}`);
this.logger.log(`${userName} joined room ${roomId} (client ID: ${client.id})`);
return `Joined room ${roomId}`;
}
// "chatMessage" 이벤트를 구독하여 특정 방에 메시지 전송
@SubscribeMessage('chatMessage')
handleChatMessage(
@MessageBody('roomId') roomId: string,
@MessageBody('message') message: string,
@MessageBody('userName') userName: string,
@ConnectedSocket() client: Socket,
) {
// 특정 방에 있는 클라이언트들에게만 메시지 전송
this.server.to(roomId).emit('roomMessage', `[${userName} in ${roomId}] ${message}`);
this.logger.log(`Chat message from ${userName} in ${roomId}: ${message}`);
return 'Message sent to room';
}
}
@WebSocketGateway()
: 이 클래스가 WebSocket 게이트웨이임을 선언합니다.cors
옵션을 통해 클라이언트 도메인 제한을 설정할 수 있습니다.@WebSocketServer() server: Server;
:Server
타입의 Socket.IO 서버 인스턴스를 주입받습니다. 이 인스턴스를 통해 모든 클라이언트에게 메시지를 보내거나 특정 클라이언트/방에 메시지를 보낼 수 있습니다.OnGatewayInit
,OnGatewayConnection
,OnGatewayDisconnect
: 게이트웨이의 라이프사이클 훅(hook)입니다. 초기화, 클라이언트 연결/연결 해제 시 로직을 추가할 수 있습니다.@SubscribeMessage('eventName')
: 클라이언트가eventName
으로 이벤트를 보낼 때 이 메서드가 실행되도록 합니다.@MessageBody()
: 이벤트 데이터(payload)를 가져옵니다. 특정 키의 값을 가져오려면@MessageBody('keyName')
을 사용합니다.@ConnectedSocket()
: 현재 연결된 클라이언트의Socket
인스턴스를 가져옵니다.client.emit('eventName', data)
: 특정 클라이언트에게 메시지를 보냅니다.this.server.emit('eventName', data)
: 모든 연결된 클라이언트에게 메시지를 브로드캐스트합니다.client.join(roomId)
: 클라이언트를 특정 "방(room)"에 조인시킵니다.this.server.to(roomId).emit('eventName', data)
: 특정 "방"에 있는 클라이언트들에게만 메시지를 보냅니다.
모듈에 게이트웨이 등록
게이트웨이도 NestJS 모듈의 providers
배열에 등록되어야 합니다.
// src/events/events.module.ts
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';
@Module({
providers: [EventsGateway], // 게이트웨이 등록
})
export class EventsModule {}
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { EventsModule } from './events/events.module'; // EventsModule 임포트
@Module({
imports: [EventsModule], // EventsModule 추가
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
애플리케이션 시작
NestJS 애플리케이션을 평소처럼 시작합니다. WebSocket 서버는 HTTP 서버와 함께 실행됩니다.
npm run start:dev
이제 NestJS 애플리케이션은 기본적으로 HTTP 서버(예: 3000번 포트)와 함께 WebSocket 서버를 함께 구동합니다.
클라이언트 (JavaScript) 예시
브라우저에서 NestJS WebSocket 서버에 연결하고 메시지를 주고받는 간단한 JavaScript 코드입니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NestJS WebSocket Chat</title>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
</head>
<body>
<h1>NestJS WebSocket Chat</h1>
<input type="text" id="usernameInput" placeholder="Enter your name" value="Guest">
<input type="text" id="messageInput" placeholder="Type a message...">
<button id="sendMessageBtn">Send Message</button>
<br><br>
<input type="text" id="roomInput" placeholder="Enter room ID" value="general">
<button id="joinRoomBtn">Join Room</button>
<br><br>
<div id="messages"></div>
<script>
const socket = io('http://localhost:3000'); // NestJS 서버 주소
const usernameInput = document.getElementById('usernameInput');
const messageInput = document.getElementById('messageInput');
const sendMessageBtn = document.getElementById('sendMessageBtn');
const roomInput = document.getElementById('roomInput');
const joinRoomBtn = document.getElementById('joinRoomBtn');
const messagesDiv = document.getElementById('messages');
let currentUserName = usernameInput.value;
let currentRoomId = roomInput.value;
// 연결 이벤트
socket.on('connect', () => {
console.log('Connected to WebSocket server:', socket.id);
// 연결 시 초기 룸 조인
socket.emit('joinRoom', { roomId: currentRoomId, userName: currentUserName });
});
// 서버에서 'welcome' 메시지 수신
socket.on('welcome', (data) => {
console.log('Server welcome:', data);
addMessage(`[SERVER] ${data}`);
});
// 서버에서 'message' 메시지 수신 (모든 클라이언트에 브로드캐스트된 메시지)
socket.on('message', (msg) => {
addMessage(`[GLOBAL] ${msg}`);
});
// 서버에서 'roomMessage' 메시지 수신 (특정 방에 브로드캐스트된 메시지)
socket.on('roomMessage', (msg) => {
addMessage(`[ROOM ${currentRoomId}] ${msg}`);
});
// 연결 해제 이벤트
socket.on('disconnect', () => {
console.log('Disconnected from WebSocket server');
});
// 메시지 전송 버튼 클릭 이벤트
sendMessageBtn.addEventListener('click', () => {
const message = messageInput.value;
if (message.trim()) {
// 'chatMessage' 이벤트를 통해 현재 방으로 메시지 전송
socket.emit('chatMessage', { roomId: currentRoomId, message: message, userName: currentUserName });
messageInput.value = '';
}
});
// 방 조인 버튼 클릭 이벤트
joinRoomBtn.addEventListener('click', () => {
currentUserName = usernameInput.value;
currentRoomId = roomInput.value;
socket.emit('joinRoom', { roomId: currentRoomId, userName: currentUserName });
addMessage(`You joined room: ${currentRoomId}`);
});
// 메시지를 화면에 추가하는 함수
function addMessage(msg) {
const p = document.createElement('p');
p.textContent = msg;
messagesDiv.appendChild(p);
messagesDiv.scrollTop = messagesDiv.scrollHeight; // 스크롤 하단으로
}
</script>
</body>
</html>
WebSocket 배포 시 고려사항
WebSocket 애플리케이션을 프로덕션 환경에 배포할 때는 몇 가지 중요한 고려사항이 있습니다.
-
로드 밸런서 (Load Balancer): WebSocket은 지속적인 연결을 유지하므로, 로드 밸런서가 sticky session(세션 고정) 또는 세션 어피니티(Session Affinity) 를 지원해야 합니다. 즉, 특정 클라이언트의 WebSocket 연결이 항상 동일한 서버 인스턴스로 라우팅되도록 해야 합니다. AWS ALB(Application Load Balancer)는 sticky session을 지원하며, Kubernetes의 Service Type 중 NodePort나 LoadBalancer, Ingress 컨트롤러 등이 WebSocket 연결을 잘 처리할 수 있도록 설정해야 합니다.
-
스케일링: WebSocket 서버는 연결 수가 늘어날수록 메모리와 CPU 사용량이 증가합니다. 여러 인스턴스로 스케일 아웃할 경우, 각 인스턴스의 WebSocket 서버 간에 이벤트를 동기화해야 합니다.
Socket.IO
는 이를 위해 Redis Pub/Sub 어댑터(Adapter)를 제공합니다.npm install @socket.io/redis-adapter redis npm install --save-dev @types/redis
// src/main.ts (Redis 어댑터 설정 추가) import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { IoAdapter } from '@nestjs/platform-socket.io'; // IoAdapter 임포트 import { createAdapter } from '@socket.io/redis-adapter'; import { createClient } from 'redis'; async function bootstrap() { const app = await NestFactory.create(AppModule); // Redis 어댑터 설정 const pubClient = createClient({ url: `redis://localhost:6379` }); // 실제 Redis 서버 주소 const subClient = pubClient.duplicate(); await Promise.all([pubClient.connect(), subClient.connect()]); app.useWebSocketAdapter(new IoAdapter(app)); // 기본 어댑터 사용 const redisAdapter = createAdapter(pubClient, subClient); app.getHttpAdapter().getInstance().set('io adapter', redisAdapter); // Socket.IO 인스턴스에 어댑터 적용 await app.listen(3000); } bootstrap();
위 코드는 기본적인 Redis 어댑터 설정 예시이며, 실제 NestJS
IoAdapter
에 적용하려면 좀 더 세밀한 조정이 필요합니다.IoAdapter
를 상속받아 커스텀 어댑터를 만들고 그 안에서 Redis 어댑터를 사용하는 것이 일반적입니다. -
방화벽 및 보안 그룹: WebSocket 트래픽이 허용되도록 포트(기본 80/443, 또는 다른 포트)를 열어주어야 합니다.
-
인증 및 권한 부여: WebSocket 연결 시에도 사용자 인증 및 권한 부여가 중요합니다. JWT 토큰을 쿼리 파라미터나 헤더로 전달받아 연결을 수립하기 전에 검증하거나, 핸드셰이크 중에 검증 로직을 추가할 수 있습니다.
-
TLS/SSL (HTTPS/WSS): 프로덕션 환경에서는 항상 암호화된 통신(WSS: WebSocket Secure)을 사용해야 합니다. HTTP 로드 밸런서에서 TLS를 종료하고 백엔드로는 일반 WebSocket 통신을 하거나, End-to-End 암호화를 구현할 수 있습니다.
-
서버리스 환경에서의 WebSocket: AWS API Gateway의 WebSocket API, AWS IoT Core, Google Cloud Run with WebSockets, Azure Web PubSub 등 클라우드 플랫폼의 관리형 WebSocket 서비스를 활용하여 서버리스 환경에서도 WebSocket을 쉽게 구현할 수 있습니다. Lambda와
aws-serverless-express
를 사용하면 WebSocket API Gateway를 통해 백엔드 NestJS 람다 함수로 라우팅하는 복잡한 설정이 필요합니다.
WebSocket은 실시간 상호작용이 필요한 현대 웹 애플리케이션의 핵심 기술입니다. NestJS는 @nestjs/websockets
와 Socket.IO
통합을 통해 이러한 복잡한 실시간 통신 기능을 간결하고 구조적으로 구현할 수 있도록 돕습니다. 로드 밸런서, 스케일링, 보안 등 배포 시 고려사항들을 충분히 이해하고 적용함으로써 안정적이고 확장 가능한 실시간 서비스를 구축할 수 있습니다.