NestJS와 Socket.IO를 사용하여 실시간 채팅 애플리케이션 백엔드를 구축하는 방법을 단계별로 안내합니다. WebSocket 기반의 효율적인 서버 개발 노하우를 공개합니다.
사용자들은 이제 단순히 정보를 주고받는 것을 넘어, 실시간으로 소통하고 반응하는 경험을 기대합니다. 웹사이트나 모바일 앱에서 친구와 대화하거나, 특정 주제에 대해 즉각적인 피드백을 주고받는 것은 선택이 아닌 필수가 되었습니다. 하지만 기존의 HTTP 요청-응답 방식만으로는 이러한 실시간 통신의 요구사항을 충족시키기 어렵습니다. 매번 서버에 새로운 데이터를 요청하는 폴링(Polling) 방식은 비효율적이며, 불필요한 네트워크 트래픽과 서버 부하를 유발합니다.
이런 상황에서 어떻게 하면 효율적이고 안정적인 실시간 통신 환경을 구축할 수 있을까요? 바로 WebSocket 기술과 이를 강력하게 지원하는 프레임워크 및 라이브러리를 활용하는 것입니다. 본 가이드에서는 NestJS의 견고함과 Socket.IO의 유연성을 결합하여, 확장성 있는 실시간 채팅 애플리케이션 백엔드를 단계별로 구축하는 방법을 상세히 안내합니다. 문제를 해결하고 실제 서비스에 적용 가능한 노하우를 얻어가세요.
📑 목차
- NestJS 프로젝트 초기 설정 및 기본 구조 잡기
- NestJS CLI 설치 및 프로젝트 생성
- 채팅 모듈 및 서비스 구조화
- Socket.IO 게이트웨이 구현: WebSocket 연결 관리
- @WebSocketGateway() 데코레이터 활용
- 연결된 클라이언트 관리 (ChatService)
- 메시지 송수신 로직 개발: 이벤트 핸들링
- @SubscribeMessage()를 이용한 이벤트 구독
- 메시지 브로드캐스트
- 채팅방(Room) 기능 구현: 특정 사용자 그룹 관리
- socket.join() 및 socket.leave()로 채팅방 참여/나가기
- 특정 방으로 메시지 브로드캐스트
- ChatService에서 채팅방 정보 관리
- 사용자 상태 관리 및 데이터 지속성
- 사용자 상태 관리: 접속 상태 및 프로필 정보
- 데이터 지속성: 메시지 기록 저장
- 보안 및 배포 고려 사항
- 보안: 인증(Authentication) 및 권한(Authorization)
- CORS (Cross-Origin Resource Sharing) 설정
- 배포 전략 및 확장성
- 결론: NestJS와 Socket.IO로 실시간 서비스 구축의 가능성
Image by VinzentWeinbeer on Pixabay
NestJS 프로젝트 초기 설정 및 기본 구조 잡기
본격적인 실시간 채팅 백엔드 개발에 앞서, NestJS 프로젝트를 초기 설정하고 필요한 의존성을 설치하는 과정이 필요합니다. NestJS는 타입스크립트 기반의 서버 사이드 프레임워크로, 모듈화된 구조와 강력한 의존성 주입(DI) 기능을 제공하여 대규모 애플리케이션 개발에 매우 적합합니다. Socket.IO를 NestJS에 통합하는 과정은 매우 직관적입니다.
NestJS CLI 설치 및 프로젝트 생성
가장 먼저 NestJS CLI(Command Line Interface)를 전역으로 설치합니다. CLI를 사용하면 프로젝트 생성, 모듈 및 컴포넌트 생성 등 개발 과정을 효율적으로 관리할 수 있습니다.
npm i -g @nestjs/cli
nest new realtime-chat-backend
cd realtime-chat-backend
위 명령어를 실행하면 `realtime-chat-backend`라는 이름의 새로운 NestJS 프로젝트가 생성됩니다. 프로젝트 디렉토리로 이동한 후, Socket.IO와 NestJS에서 Socket.IO를 사용하기 위한 플랫폼 패키지를 설치해야 합니다.
npm i @nestjs/platform-socket.io socket.io
@nestjs/platform-socket.io는 NestJS가 Socket.IO와 상호작용할 수 있도록 도와주는 어댑터 역할을 하며, socket.io는 실제 Socket.IO 서버를 구동하는 핵심 라이브러리입니다. 이 두 패키지가 설치되면, NestJS 애플리케이션 내에서 WebSocket 통신을 위한 기반이 마련됩니다.
채팅 모듈 및 서비스 구조화
NestJS는 모듈 기반의 아키텍처를 권장합니다. 채팅 기능을 위한 별도의 모듈을 생성하여 코드의 응집도를 높이고 관리를 용이하게 할 수 있습니다. chat 모듈을 생성하고, 그 안에 게이트웨이(Gateway)와 서비스(Service)를 추가할 것입니다.
nest g module chat
nest g gateway chat --no-spec
nest g service chat --no-spec
--no-spec 옵션은 테스트 파일을 생성하지 않도록 합니다. 이렇게 생성된 파일들은 다음과 같은 구조를 가집니다:
- `src/chat/chat.module.ts`
- `src/chat/chat.gateway.ts`
- `src/chat/chat.service.ts`
chat.module.ts 파일에는 게이트웨이와 서비스를 등록하여 NestJS 애플리케이션이 이들을 인식하고 의존성 주입을 처리할 수 있도록 해야 합니다. AppModule에 ChatModule을 임포트하는 것을 잊지 마세요.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ChatModule } from './chat/chat.module'; // ChatModule 임포트
@Module({
imports: [ChatModule], // ChatModule 등록
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
이제 기본적인 NestJS 프로젝트 설정과 채팅 기능을 위한 모듈 구조가 완료되었습니다. 다음 단계에서는 Socket.IO 게이트웨이를 구현하여 클라이언트와의 WebSocket 연결을 관리하는 방법을 살펴보겠습니다.
Socket.IO 게이트웨이 구현: WebSocket 연결 관리
NestJS에서 Socket.IO를 사용하기 위한 핵심 컴포넌트는 게이트웨이(Gateway)입니다. 게이트웨이는 클라이언트의 WebSocket 연결을 처리하고, 특정 이벤트 메시지를 수신하며, 다시 클라이언트로 이벤트를 방출(emit)하는 역할을 수행합니다. 마치 REST API의 컨트롤러와 유사하지만, 실시간 양방향 통신에 특화되어 있습니다.
@WebSocketGateway() 데코레이터 활용
src/chat/chat.gateway.ts 파일에 게이트웨이 로직을 작성합니다. @WebSocketGateway() 데코레이터를 사용하여 클래스를 WebSocket 게이트웨이로 선언합니다. 이 데코레이터는 여러 옵션을 가질 수 있습니다. 예를 들어, 포트 번호, 네임스페이스, CORS 설정 등을 지정할 수 있습니다.
// src/chat/chat.gateway.ts
import {
WebSocketGateway,
WebSocketServer,
OnGatewayInit,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
MessageBody,
ConnectedSocket,
} from '@nestjs/platform-socket.io';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
import { ChatService } from './chat.service'; // ChatService 주입을 위해 임포트
@WebSocketGateway({
cors: {
origin: '*', // 개발 환경에서는 모든 출처 허용, 프로덕션에서는 특정 출처만 허용
},
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server; // Socket.IO 서버 인스턴스
private readonly logger = new Logger(ChatGateway.name);
constructor(private readonly chatService: ChatService) {} // ChatService 주입
afterInit(server: Server) {
this.logger.log('WebSocket Gateway Initialized');
}
handleConnection(@ConnectedSocket() client: Socket, ...args: any[]) {
this.logger.log(`Client connected: ${client.id}`);
this.chatService.registerClient(client); // 클라이언트 등록
// 연결된 클라이언트에게 현재 접속자 수 등 정보 전송 가능
this.server.emit('connectedClients', this.chatService.getConnectedClientsCount());
}
handleDisconnect(@ConnectedSocket() client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
this.chatService.unregisterClient(client); // 클라이언트 등록 해제
// 접속 해제된 클라이언트에게 현재 접속자 수 등 정보 전송 가능
this.server.emit('connectedClients', this.chatService.getConnectedClientsCount());
}
}
위 코드에서 몇 가지 중요한 부분을 설명합니다.
@WebSocketServer() server: Server;: 이 데코레이터는 Socket.IO 서버 인스턴스를 주입받아, 게이트웨이 내에서 클라이언트로 이벤트를 방출(emit)하는 데 사용됩니다.OnGatewayInit,OnGatewayConnection,OnGatewayDisconnect: 이 인터페이스들을 구현함으로써, 게이트웨이의 라이프사이클 훅(lifecycle hook)을 활용할 수 있습니다.afterInit(): 게이트웨이가 초기화된 후 호출됩니다.handleConnection(): 새로운 클라이언트가 WebSocket에 연결될 때 호출됩니다. 여기서는 클라이언트 ID를 로깅하고,ChatService를 통해 클라이언트를 등록합니다.handleDisconnect(): 클라이언트가 연결을 끊을 때 호출됩니다. 여기서는 클라이언트 ID를 로깅하고,ChatService를 통해 클라이언트 등록을 해제합니다.
Logger: NestJS의 내장 로거를 사용하여 콘솔에 유용한 정보를 출력합니다.ChatService주입:ChatService를 주입하여 연결된 클라이언트들을 관리할 수 있도록 합니다.
연결된 클라이언트 관리 (ChatService)
ChatService는 연결된 클라이언트들의 목록을 관리하고, 채팅방 관련 로직을 처리하는 역할을 합니다. src/chat/chat.service.ts 파일을 다음과 같이 수정합니다.
// src/chat/chat.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Socket } from 'socket.io';
@Injectable()
export class ChatService {
private readonly logger = new Logger(ChatService.name);
private connectedClients: Map<string, Socket> = new Map(); // 연결된 클라이언트들을 저장
registerClient(client: Socket): void {
this.connectedClients.set(client.id, client);
this.logger.log(`Client registered: ${client.id}. Total connected: ${this.connectedClients.size}`);
}
unregisterClient(client: Socket): void {
this.connectedClients.delete(client.id);
this.logger.log(`Client unregistered: ${client.id}. Total connected: ${this.connectedClients.size}`);
}
getConnectedClientsCount(): number {
return this.connectedClients.size;
}
getClientSocket(clientId: string): Socket | undefined {
return this.connectedClients.get(clientId);
}
getAllConnectedClientIds(): string[] {
return Array.from(this.connectedClients.keys());
}
}
ChatService는 Map 객체를 사용하여 연결된 클라이언트의 socket.id를 키로, Socket 객체를 값으로 저장합니다. 이를 통해 특정 클라이언트에게 메시지를 보내거나, 현재 접속자 수를 파악하는 등의 기능을 구현할 수 있습니다. 클라이언트 연결 및 해제를 서비스 레이어에서 관리함으로써, 게이트웨이는 통신 로직에 집중하고 서비스는 비즈니스 로직에 집중하는 관심사의 분리를 달성할 수 있습니다.
메시지 송수신 로직 개발: 이벤트 핸들링
WebSocket 통신은 특정 이벤트에 대한 메시지 송수신 방식으로 이루어집니다. 클라이언트가 특정 이벤트를 발생시키면(emit), 서버의 게이트웨이가 해당 이벤트를 구독(subscribe)하여 메시지를 처리하고, 다시 클라이언트로 응답 이벤트를 방출하는 구조입니다. NestJS의 @SubscribeMessage() 데코레이터는 이 과정을 매우 간결하게 만들어 줍니다.
@SubscribeMessage()를 이용한 이벤트 구독
클라이언트가 메시지를 보낼 때 발생하는 이벤트를 처리하기 위해 ChatGateway에 @SubscribeMessage() 데코레이터를 사용합니다. 예를 들어, 클라이언트가 'chatMessage' 이벤트를 통해 메시지를 보낸다고 가정합니다.
// src/chat/chat.gateway.ts (이전 코드에서 이어짐)
// ...
interface ChatMessage {
senderId: string;
senderName: string;
content: string;
timestamp: string;
}
@WebSocketGateway({
cors: {
origin: '*',
},
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
// ... (생성자, 라이프사이클 훅)
@SubscribeMessage('chatMessage') // 클라이언트에서 'chatMessage' 이벤트를 구독
handleChatMessage(
@MessageBody() data: { senderId: string; senderName: string; content: string },
@ConnectedSocket() client: Socket,
): void {
const message: ChatMessage = {
senderId: data.senderId,
senderName: data.senderName,
content: data.content,
timestamp: new Date().toISOString(),
};
this.logger.log(`Message received from ${client.id} (${data.senderName}): ${data.content}`);
// 연결된 모든 클라이언트에게 'newMessage' 이벤트로 메시지 브로드캐스트
this.server.emit('newMessage', message);
// 특정 클라이언트에게만 응답하고 싶다면: client.emit('messageAcknowledged', { status: 'success' });
}
// ...
}
여기서 중요한 두 가지 데코레이터가 있습니다:
@SubscribeMessage('chatMessage'): 이 메서드가 'chatMessage'라는 이름의 WebSocket 이벤트를 처리하도록 지정합니다. 클라이언트가socket.emit('chatMessage', {...})를 호출하면 이 메서드가 실행됩니다.@MessageBody() data: {...}: 클라이언트가 보낸 메시지 페이로드(payload)를 인자로 받아옵니다. 여기서는senderId,senderName,content를 포함하는 객체를 기대합니다.@ConnectedSocket() client: Socket: 현재 이벤트를 발생시킨 클라이언트의 Socket 인스턴스를 주입받습니다. 이를 통해 특정 클라이언트에게만 응답을 보내거나, 클라이언트 정보를 활용할 수 있습니다.
메시지 브로드캐스트
클라이언트로부터 메시지를 수신하면, 일반적으로는 그 메시지를 다른 모든 연결된 클라이언트에게 전송(브로드캐스트)해야 합니다. 이는 this.server.emit() 메서드를 사용하여 수행합니다.
this.server.emit('newMessage', message);
이 코드는 현재 Socket.IO 서버에 연결된 모든 클라이언트에게 'newMessage'라는 이벤트와 함께 message 객체를 전송합니다. 클라이언트는 이 'newMessage' 이벤트를 수신하여 화면에 새로운 메시지를 표시하게 됩니다. 이렇게 함으로써 실시간 채팅의 핵심 기능인 메시지 동기화가 이루어집니다.
클라이언트 측에서는 다음과 같이 메시지를 보내고 받을 수 있습니다 (예시):
// 클라이언트 (React, Vue, Angular 등에서 Socket.IO 클라이언트 라이브러리 사용)
import io from 'socket.io-client';
const socket = io('http://localhost:3000'); // NestJS 서버 주소
socket.on('connect', () => {
console.log('Connected to server!');
const senderId = 'user123'; // 실제 사용자 ID
const senderName = 'Developer A'; // 실제 사용자 이름
const messageContent = '안녕하세요, 실시간 채팅 테스트입니다!';
// 서버로 메시지 전송
socket.emit('chatMessage', { senderId, senderName, content: messageContent });
});
socket.on('newMessage', (message) => {
console.log('New message received:', message);
// 받은 메시지를 UI에 표시
});
socket.on('disconnect', () => {
console.log('Disconnected from server!');
});
이러한 이벤트 기반의 통신 방식은 실시간 상호작용을 구현하는 데 매우 효과적입니다. 다음 단계에서는 채팅방(Room) 기능을 추가하여 특정 그룹 간의 대화를 가능하게 하는 방법을 알아보겠습니다.
Image by doki7 on Pixabay
채팅방(Room) 기능 구현: 특정 사용자 그룹 관리
실시간 채팅 애플리케이션에서 채팅방(Room) 기능은 필수적입니다. 모든 사용자가 하나의 공통된 채널에서 대화하는 것보다, 특정 주제나 목적에 따라 여러 개의 분리된 대화 공간을 제공하는 것이 사용자 경험을 크게 향상시킵니다. Socket.IO는 이 채팅방 기능을 매우 쉽게 구현할 수 있도록 지원합니다.
socket.join() 및 socket.leave()로 채팅방 참여/나가기
클라이언트는 특정 채팅방에 참여하거나 나갈 수 있습니다. Socket.IO에서는 socket.join(roomName) 메서드를 사용하여 소켓을 특정 방에 할당하고, socket.leave(roomName) 메서드를 사용하여 방에서 제거합니다. 이를 ChatGateway에 새로운 이벤트로 구현할 수 있습니다.
// src/chat/chat.gateway.ts (이전 코드에서 이어짐)
// ...
@WebSocketGateway({
cors: {
origin: '*',
},
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
// ... (생성자, 라이프사이클 훅, handleChatMessage)
@SubscribeMessage('joinRoom')
handleJoinRoom(
@MessageBody() data: { roomId: string; userId: string; userName: string },
@ConnectedSocket() client: Socket,
): void {
const { roomId, userId, userName } = data;
client.join(roomId); // 클라이언트를 특정 방에 조인
this.logger.log(`${userName} (${userId}) joined room: ${roomId}`);
// 해당 방의 모든 클라이언트에게 참여 메시지 전송 (자기 자신 포함)
this.server.to(roomId).emit('roomMessage', {
type: 'system',
content: `${userName} 님이 ${roomId} 방에 입장했습니다.`,
timestamp: new Date().toISOString(),
});
// ChatService에서 사용자-방 정보 관리 (선택 사항)
this.chatService.joinRoom(client.id, roomId, userName);
this.server.to(roomId).emit('roomMembers', this.chatService.getRoomMembers(roomId));
}
@SubscribeMessage('leaveRoom')
handleLeaveRoom(
@MessageBody() data: { roomId: string; userId: string; userName: string },
@ConnectedSocket() client: Socket,
): void {
const { roomId, userId, userName } = data;
client.leave(roomId); // 클라이언트를 특정 방에서 제거
this.logger.log(`${userName} (${userId}) left room: ${roomId}`);
// 해당 방의 모든 클라이언트에게 퇴장 메시지 전송 (자기 자신 제외)
client.to(roomId).emit('roomMessage', {
type: 'system',
content: `${userName} 님이 ${roomId} 방에서 퇴장했습니다.`,
timestamp: new Date().toISOString(),
});
// ChatService에서 사용자-방 정보 관리 (선택 사항)
this.chatService.leaveRoom(client.id, roomId);
this.server.to(roomId).emit('roomMembers', this.chatService.getRoomMembers(roomId));
}
@SubscribeMessage('roomChatMessage')
handleRoomChatMessage(
@MessageBody() data: { roomId: string; senderId: string; senderName: string; content: string },
@ConnectedSocket() client: Socket,
): void {
const { roomId, senderId, senderName, content } = data;
const message: ChatMessage = {
senderId,
senderName,
content,
timestamp: new Date().toISOString(),
};
this.logger.log(`Room [${roomId}] message from ${senderName}: ${content}`);
// 특정 방의 모든 클라이언트에게 메시지 전송
this.server.to(roomId).emit('newRoomMessage', message);
}
}
여기서 client.join(roomId)는 현재 이벤트를 발생시킨 클라이언트 소켓을 roomId라는 이름의 방에 추가합니다. client.leave(roomId)는 반대로 방에서 제거합니다. @MessageBody()를 통해 클라이언트가 어떤 방에 참여하고 싶은지, 그리고 어떤 사용자 이름으로 참여하는지 정보를 받아옵니다.
특정 방으로 메시지 브로드캐스트
일반 메시지 브로드캐스트는 this.server.emit()을 사용했지만, 특정 방에만 메시지를 보내려면 this.server.to(roomName).emit() 또는 client.to(roomName).emit()을 사용합니다.
this.server.to(roomId).emit('eventName', data):roomId에 속한 모든 클라이언트에게 이벤트를 전송합니다. 이벤트를 보낸 클라이언트 자신도 포함됩니다.client.to(roomId).emit('eventName', data):roomId에 속한 클라이언트 중, 이벤트를 보낸 클라이언트 자신을 제외한 모든 클라이언트에게 이벤트를 전송합니다. 시스템 메시지 등 특정 상황에서 유용합니다.
handleRoomChatMessage 메서드에서는 this.server.to(roomId).emit('newRoomMessage', message)를 사용하여 해당 방에 속한 모든 사용자에게 메시지를 전달합니다. 이를 통해 각 채팅방은 독립적인 대화 공간을 가질 수 있게 됩니다.
ChatService에서 채팅방 정보 관리
ChatService에 채팅방과 관련된 추가적인 로직을 구현하여, 현재 어떤 방들이 있고 어떤 사용자들이 어떤 방에 참여하고 있는지 관리할 수 있습니다.
// src/chat/chat.service.ts (이전 코드에서 이어짐)
// ...
interface RoomMember {
socketId: string;
userName: string;
}
@Injectable()
export class ChatService {
// ... (connectedClients, registerClient, unregisterClient 등)
private rooms: Map<string, RoomMember[]> = new Map(); // roomId -> RoomMember[]
joinRoom(socketId: string, roomId: string, userName: string): void {
if (!this.rooms.has(roomId)) {
this.rooms.set(roomId, []);
}
const members = this.rooms.get(roomId);
if (!members.some(member => member.socketId === socketId)) {
members.push({ socketId, userName });
this.logger.log(`${userName} (${socketId}) joined room ${roomId}. Current members: ${members.length}`);
}
}
leaveRoom(socketId: string, roomId: string): void {
if (this.rooms.has(roomId)) {
const members = this.rooms.get(roomId);
const initialLength = members.length;
this.rooms.set(roomId, members.filter(member => member.socketId !== socketId));
if (this.rooms.get(roomId).length < initialLength) {
this.logger.log(`${socketId} left room ${roomId}. Current members: ${this.rooms.get(roomId).length}`);
}
if (this.rooms.get(roomId).length === 0) {
this.rooms.delete(roomId); // 방에 아무도 없으면 방 삭제
this.logger.log(`Room ${roomId} is now empty and deleted.`);
}
}
}
getRoomMembers(roomId: string): RoomMember[] {
return this.rooms.get(roomId) || [];
}
getRoomCount(): number {
return this.rooms.size;
}
}
ChatService는 Map을 사용하여 각 채팅방별 멤버 목록을 관리합니다. 클라이언트가 방에 참여하거나 나갈 때 이 Map을 업데이트하여, 특정 방에 누가 있는지, 방의 개수는 몇 개인지 등의 정보를 추적할 수 있습니다. 이러한 정보는 UI에 현재 접속자 목록을 표시하거나, 방의 활성화 상태를 관리하는 데 사용될 수 있습니다. 채팅방 기능은 다양한 실시간 협업 도구나 게임 등에서 핵심적인 역할을 합니다.
사용자 상태 관리 및 데이터 지속성
실시간 채팅 애플리케이션의 백엔드는 단순한 메시지 송수신을 넘어, 사용자 상태 관리와 데이터 지속성을 고려해야 합니다. 누가 접속해 있는지, 어떤 메시지가 오갔는지 등의 정보는 애플리케이션의 핵심적인 자산이며, 서버가 재시작되더라도 유지되어야 합니다.
사용자 상태 관리: 접속 상태 및 프로필 정보
앞서 ChatService에서 연결된 클라이언트의 socket.id를 관리하는 방법을 살펴보았습니다. 하지만 실제 애플리케이션에서는 socket.id 외에 사용자 ID, 사용자 이름(닉네임), 프로필 이미지 등의 정보가 필요합니다. 클라이언트가 연결될 때 이러한 정보를 서버로 전송하고, 서버는 이를 관리하는 것이 일반적입니다.
// src/chat/chat.gateway.ts (handleConnection 수정)
// ...
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
// ...
handleConnection(@ConnectedSocket() client: Socket, ...args: any[]) {
this.logger.log(`Client connected: ${client.id}`);
// 클라이언트가 연결 시 사용자 정보(예: userId, userName)를 전달한다고 가정
// 실제 구현에서는 클라이언트로부터 인증 토큰을 받아 사용자 정보를 추출하는 방식이 일반적
const userId = client.handshake.query.userId as string || client.id; // 예시: 쿼리 파라미터에서 userId 가져오기
const userName = client.handshake.query.userName as string || `Guest-${client.id.substring(0, 4)}`; // 예시: userName 가져오기
this.chatService.registerClient(client.id, { userId, userName, socket: client }); // userId, userName도 함께 등록
this.server.emit('connectedClientsCount', this.chatService.getConnectedClientsCount());
this.server.emit('activeUsers', this.chatService.getActiveUsers()); // 현재 활성 사용자 목록 전송
}
handleDisconnect(@ConnectedSocket() client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
this.chatService.unregisterClient(client.id);
this.server.emit('connectedClientsCount', this.chatService.getConnectedClientsCount());
this.server.emit('activeUsers', this.chatService.getActiveUsers()); // 현재 활성 사용자 목록 전송
}
// ...
}
// src/chat/chat.service.ts (수정)
// ...
interface UserInfo {
userId: string;
userName: string;
socket: Socket;
}
@Injectable()
export class ChatService {
private connectedClients: Map<string, UserInfo> = new Map(); // socketId -> UserInfo
registerClient(socketId: string, userInfo: UserInfo): void {
this.connectedClients.set(socketId, userInfo);
this.logger.log(`Client ${userInfo.userName} (${userInfo.userId}) registered with socket ${socketId}. Total: ${this.connectedClients.size}`);
}
unregisterClient(socketId: string): void {
if (this.connectedClients.has(socketId)) {
const userInfo = this.connectedClients.get(socketId);
this.connectedClients.delete(socketId);
this.logger.log(`Client ${userInfo.userName} (${userInfo.userId}) unregistered from socket ${socketId}. Total: ${this.connectedClients.size}`);
}
}
getConnectedClientsCount(): number {
return this.connectedClients.size;
}
getActiveUsers(): { userId: string; userName: string }[] {
return Array.from(this.connectedClients.values()).map(user => ({ userId: user.userId, userName: user.userName }));
}
// ... (채팅방 관련 로직도 userId/userName을 포함하도록 수정 가능)
}
이와 같이 ChatService가 UserInfo 객체를 관리하도록 함으로써, 각 소켓에 실제 사용자 정보를 매핑할 수 있습니다. client.handshake.query를 통해 초기 연결 시 클라이언트가 전달하는 정보를 활용할 수 있지만, 보안상 중요한 정보(예: 인증 토큰)는 HTTP 헤더를 통해 전달받아 서버에서 검증하는 것이 좋습니다.
데이터 지속성: 메시지 기록 저장
채팅 애플리케이션의 핵심 데이터는 역시 메시지 기록입니다. 사용자가 앱을 다시 실행하거나, 서버가 재시작되더라도 이전 대화 내용을 볼 수 있어야 합니다. 이는 데이터베이스를 통해 구현됩니다. NestJS는 TypeORM, Mongoose 등 다양한 ORM/ODM을 지원하여 데이터베이스 연동을 쉽게 할 수 있습니다.
예를 들어, MongoDB와 Mongoose를 사용하여 메시지를 저장한다고 가정해 봅시다. 먼저 관련 패키지를 설치합니다:
npm i @nestjs/mongoose mongoose
MongooseModule을 AppModule에 임포트하고, 메시지 스키마를 정의합니다.
// src/messages/schemas/message.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
export type MessageDocument = Message & Document;
@Schema()
export class Message {
@Prop({ required: true })
roomId: string;
@Prop({ required: true })
senderId: string;
@Prop({ required: true })
senderName: string;
@Prop({ required: true })
content: string;
@Prop({ default: Date.now })
timestamp: Date;
}
export const MessageSchema = SchemaFactory.createForClass(Message);
그리고 ChatService 또는 별도의 MessageService에서 메시지를 저장하고 불러오는 로직을 추가합니다.
// src/chat/chat.service.ts (메시지 저장 기능 추가)
import { Injectable, Logger } from '@nestjs/common';
import { Socket } from 'socket.io';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Message, MessageDocument } from '../messages/schemas/message.schema'; // 메시지 스키마 임포트
// ... (UserInfo, RoomMember 인터페이스)
@Injectable()
export class ChatService {
// ... (connectedClients, rooms 등 기존 속성)
constructor(
private readonly logger: Logger,
@InjectModel(Message.name) private messageModel: Model, // Message 모델 주입
) {}
async saveMessage(message: { roomId: string; senderId: string; senderName: string; content: string }): Promise {
const newMessage = new this.messageModel(message);
return newMessage.save();
}
async getRoomMessages(roomId: string, limit = 50): Promise<MessageDocument[]> {
return this.messageModel.find({ roomId })
.sort({ timestamp: 1 }) // 오래된 메시지부터 정렬
.limit(limit)
.exec();
}
// ...
}
ChatGateway에서 메시지를 수신하면, ChatService.saveMessage()를 호출하여 데이터베이스에 저장하고, 클라이언트가 특정 방에 입장할 때 ChatService.getRoomMessages()를 호출하여 이전 메시지 기록을 전송해 줄 수 있습니다.
// src/chat/chat.gateway.ts (handleChatMessage 및 handleJoinRoom 수정)
// ...
@WebSocketGateway({
cors: {
origin: '*',
},
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
// ...
@SubscribeMessage('chatMessage')
async handleChatMessage( // async 키워드 추가
@MessageBody() data: { roomId: string; senderId: string; senderName: string; content: string },
@ConnectedSocket() client: Socket,
): Promise {
const message: ChatMessage = {
senderId: data.senderId,
senderName: data.senderName,
content: data.content,
timestamp: new Date().toISOString(),
};
this.logger.log(`Room [${data.roomId}] message from ${data.senderName}: ${data.content}`);
// 메시지를 데이터베이스에 저장
await this.chatService.saveMessage({ ...data, timestamp: message.timestamp });
// 특정 방의 모든 클라이언트에게 메시지 전송
this.server.to(data.roomId).emit('newRoomMessage', message);
}
@SubscribeMessage('joinRoom')
async handleJoinRoom( // async 키워드 추가
@MessageBody() data: { roomId: string; userId: string; userName: string },
@ConnectedSocket() client: Socket,
): Promise {
// ... (기존 joinRoom 로직)
client.join(roomId);
this.logger.log(`${userName} (${userId}) joined room: ${roomId}`);
this.server.to(roomId).emit('roomMessage', {
type: 'system',
content: `${userName} 님이 ${roomId} 방에 입장했습니다.`,
timestamp: new Date().toISOString(),
});
this.chatService.joinRoom(client.id, roomId, userName);
this.server.to(roomId).emit('roomMembers', this.chatService.getRoomMembers(roomId));
// 방 입장 시 이전 메시지 기록 전송
const previousMessages = await this.chatService.getRoomMessages(roomId);
client.emit('previousMessages', previousMessages); // 입장한 클라이언트에게만 전송
}
// ...
}
이러한 방식으로 사용자 상태 관리와 데이터 지속성을 확보하면, 단순한 채팅 기능을 넘어 안정적이고 기능이 풍부한 실시간 애플리케이션을 구축할 수 있습니다. 데이터베이스 연동은 애플리케이션의 견고함과 신뢰성을 크게 향상시킵니다.
Image by antonbe on Pixabay
보안 및 배포 고려 사항
실시간 채팅 애플리케이션을 개발할 때 기능 구현만큼 중요한 것이 바로 보안과 배포입니다. 보안 취약점은 사용자 데이터 유출로 이어질 수 있으며, 효율적인 배포 전략 없이는 안정적인 서비스 운영이 불가능합니다.
보안: 인증(Authentication) 및 권한(Authorization)
지금까지의 예시는 모든 연결된 클라이언트가 자유롭게 메시지를 보내고 방에 참여할 수 있도록 구성되어 있습니다. 실제 서비스에서는 사용자 인증을 통해 누가 메시지를 보내는 클라이언트인지 확인하고, 권한 부여를 통해 특정 방에 접근하거나 특정 작업을 수행할 수 있는지를 제어해야 합니다.
인증 방법:
- JWT (JSON Web Token): 가장 널리 사용되는 방법 중 하나입니다. 클라이언트가 로그인하면 서버는 JWT를 발급하고, 클라이언트는 이 토큰을 WebSocket 연결 시 (예: 핸드셰이크 쿼리 파라미터나 HTTP 헤더) 서버로 전송합니다. 서버는 이 토큰을 검증하여 사용자를 식별합니다.
- 세션 기반 인증: HTTP 세션을 사용하는 경우, WebSocket 연결 시 클라이언트의 세션 ID를 통해 사용자를 식별할 수 있습니다.
NestJS에서는 미들웨어, 가드(Guard), 인터셉터(Interceptor) 등을 활용하여 WebSocket 연결 및 메시지 이벤트에 대한 인증 및 권한 부여 로직을 구현할 수 있습니다. 예를 들어, AuthGuard를 @SubscribeMessage() 메서드에 적용하여 특정 이벤트 핸들러가 인증된 사용자만 접근하도록 할 수 있습니다.
// src/chat/chat.gateway.ts (가드 적용 예시)
import { UseGuards } from '@nestjs/common';
import { WsJwtAuthGuard } from './ws-jwt-auth.guard'; // 가드 구현 필요
// ...
@WebSocketGateway({
cors: {
origin: '*',
},
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
// ...
@UseGuards(WsJwtAuthGuard) // 여기에 인증 가드를 적용
@SubscribeMessage('roomChatMessage')
handleRoomChatMessage(
@MessageBody() data: { roomId: string; content: string },
@ConnectedSocket() client: Socket,
): void {
// 가드가 통과되면 client.user에 사용자 정보가 있다고 가정
const senderId = client.user.userId;
const senderName = client.user.userName;
const { roomId, content } = data;
// ... (메시지 처리 로직)
}
// ...
}
WsJwtAuthGuard는 WebSocket 컨텍스트에서 JWT를 추출하고 검증하는 로직을 포함해야 합니다. 이는 일반 HTTP 요청에 대한 Guard와는 약간 다르게 구현될 수 있습니다.
CORS (Cross-Origin Resource Sharing) 설정
클라이언트 애플리케이션(예: React 프론트엔드)이 백엔드 서버와 다른 도메인에서 호스팅될 경우, CORS 문제가 발생할 수 있습니다. @WebSocketGateway() 데코레이터에 cors 옵션을 설정하여 이를 해결할 수 있습니다.
@WebSocketGateway({
cors: {
origin: ['http://localhost:3000', 'https://your-frontend-domain.com'], // 특정 출처만 허용
methods: ['GET', 'POST'],
credentials: true,
},
})
프로덕션 환경에서는 origin: '*' 대신 명확하게 허용할 출처 도메인 목록을 지정하는 것이 보안상 매우 중요합니다.
배포 전략 및 확장성
개발이 완료된 백엔드 애플리케이션은 실제 서비스 환경에 배포되어야 합니다. NestJS 애플리케이션은 Node.js 기반이므로, 다양한 Node.js 배포 전략을 따를 수 있습니다.
| 고려 사항 | 설명 |
|---|---|
| 프로세스 관리 | PM2와 같은 Node.js 프로세스 매니저를 사용하여 애플리케이션을 안정적으로 실행하고, 크래시 시 자동 재시작, 로드 밸런싱 등을 관리할 수 있습니다. |
| 컨테이너화 (Docker) | Docker를 사용하여 애플리케이션과 그 의존성을 컨테이너로 패키징하면, 개발 환경과 프로덕션 환경 간의 일관성을 유지하고 배포를 간소화할 수 있습니다. Kubernetes와 같은 오케스트레이션 도구와 결합하여 대규모 배포 및 관리가 가능합니다. |
| 로드 밸런싱 | 사용자가 많아지면 단일 서버로는 모든 요청을 처리하기 어렵습니다. 여러 서버 인스턴스에 트래픽을 분산시키는 로드 밸런서를 사용해야 합니다. Socket.IO의 경우, 여러 서버 인스턴스 간에 이벤트를 동기화하기 위해 Redis Adapter를 사용하는 것이 일반적입니다. |
| Redis Adapter | 여러 NestJS/Socket.IO 서버 인스턴스를 운영할 때, 한 서버에서 보낸 메시지가 다른 서버에 연결된 클라이언트에게도 전달되도록 하려면 Socket.IO Redis Adapter가 필수적입니다. 이 어댑터는 모든 서버 인스턴스가 Redis Pub/Sub 채널을 통해 이벤트를 공유하도록 합니다. |
// src/main.ts (Redis Adapter 설정 예시)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Redis Adapter 설정
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
app.useWebSocketAdapter(new IoAdapter(app)); // 기본 어댑터 사용
const redisAdapter = createAdapter(pubClient, subClient);
app.getHttpAdapter().getInstance().use(redisAdapter); // Socket.IO 게이트웨이에 Redis Adapter 적용
await app.listen(3000);
console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();
위 예시 코드는 NestJS 애플리케이션의 main.ts 파일에서 Redis Adapter를 설정하는 방법을 보여줍니다. 이를 통해 여러 서버 인스턴스에 걸쳐 메시지 브로드캐스트의 일관성을 유지하고, 수평 확장성을 확보할 수 있습니다. 보안과 배포는 서비스의 안정성과 신뢰성에 직결되는 부분이므로, 충분한 계획과 테스트를 거쳐야 합니다.
결론: NestJS와 Socket.IO로 실시간 서비스 구축의 가능성
지금까지 NestJS와 Socket.IO를 활용하여 실시간 채팅 애플리케이션 백엔드를 구축하는 과정을 단계별로 살펴보았습니다. 프로젝트 초기 설정부터 WebSocket 게이트웨이 구현, 메시지 송수신, 채팅방 관리, 사용자 상태 관리 및 데이터 지속성, 그리고 보안과 배포 고려 사항에 이르기까지, 실시간 서비스 개발에 필요한 핵심적인 요소들을 다루었습니다.
NestJS는 강력한 모듈 시스템, 의존성 주입, 그리고 타입스크립트 기반의 안정성을 제공하여 대규모 실시간 애플리케이션의 백엔드를 체계적이고 효율적으로 구축할 수 있게 합니다. 여기에 Socket.IO의 유연한 이벤트 기반 통신 모델이 결합되면서, 개발자는 복잡한 WebSocket 프로토콜을 직접 다루지 않고도 다양한 실시간 기능을 손쉽게 구현할 수 있습니다.
이 가이드에서 다룬 내용을 바탕으로 여러분은 다음과 같은 이점을 얻을 수 있습니다:
- 문제 해결 능력 향상: 실시간 통신이 필요한 서비스의 기술적 난제를 NestJS와 Socket.IO로 어떻게 해결할 수 있는지 이해할 수 있습니다.
- 실용적인 개발 지식: 실제 프로젝트에 바로 적용 가능한 코드 예시와 구조화 전략을 익힐 수 있습니다.
- 확장 가능한 아키텍처: 채팅방, 사용자 관리, 데이터베이스 연동, 그리고 다중 서버 환경에서의 확장성까지 고려한 설계 방식을 배울 수 있습니다.
NestJS와 Socket.IO의 조합은 실시간 채팅뿐만 아니라 협업 도구, 알림 서비스, 게임 백엔드 등 다양한 종류의 실시간 웹 애플리케이션을 구축하는 데 강력한 솔루션이 됩니다. 이 두 기술의 시너지를 통해 사용자에게 더 몰입감 있고 반응성이 뛰어난 경험을 제공할 수 있을 것입니다.
이 가이드가 여러분의 실시간 애플리케이션 개발 여정에 도움이 되기를 바랍니다. 궁금한 점이나 더 깊이 논의하고 싶은 부분이 있다면 언제든지 댓글로 남겨주세요. 여러분의 소중한 의견을 기다립니다!
감사합니다.
📌 함께 읽으면 좋은 글
- [개발 책 리뷰] 데이터 중심 애플리케이션 설계 리뷰: 확장성과 견고성을 위한 아키텍처 원리
- [이슈 분석] AI 개발 윤리: 기술 발전이 사회에 미치는 영향과 개발자의 역할 재정립
- [튜토리얼] 2024년 최신 eBPF 실무 활용법: 실시간 컨테이너 네트워크 성능 모니터링 시스템 완벽 구축 가이드
이 글이 도움이 되셨다면 공감(♥)과 댓글로 응원해 주세요!
궁금한 점이나 다루었으면 하는 주제가 있다면 댓글로 남겨주세요.
'튜토리얼' 카테고리의 다른 글
| Docker Compose로 로컬 개발 환경 구축: 다중 서비스 연동 실전 가이드 (0) | 2026.03.19 |
|---|---|
| Playwright E2E 테스트 환경 구축: 웹 자동화 실전 가이드 (0) | 2026.03.18 |
| Vite와 React로 구축하는 초고속 웹 개발 환경 가이드 (0) | 2026.03.16 |
| LLM 기반 RAG 애플리케이션 구축: LangChain과 벡터 데이터베이스 실전 가이드 (0) | 2026.03.16 |
| 2024년 최신 eBPF 실무 활용법: 실시간 컨테이너 네트워크 성능 모니터링 시스템 완벽 구축 가이드 (0) | 2026.03.14 |