NestJS 실시간 채팅 앱 구현

서버와 클라이언트 간 통신이 실시간으로 발생해야 하는 경우가 종종 있습니다.
이런 기능은 대표적으로 채팅 앱에 많이 사용되는데요.
NestJS에서 WebSocket을 활용해 실시간 채팅 앱을 만들어 원리를 확인 해 봅니다.
실시간 API란 무엇일까요?
실시간 API는 실시간으로 데이터를 교환하는 클라이언트와 서버 간의 통신입니다.
일반적으로 WebSocket을 활용해 구현하는데 WebSocket은 클라이언트와 서버 간 실시간 통신을 가능하게 하는 일종의 프로토콜이라고 보면 됩니다.
WebSocket을 구현하기 위해 프레임워크를 사용할 수 있습니다.(socket.io, ws)
프로젝트 구성
프로젝트 구조를 이야기 하자면, 아래와 같습니다.
- 클라이언트: ReactJS Wep App
- 서버: NestJS Realtime App
우선 프로젝트 폴더부터 생성하고,
mkdir nest-chat-app
프로젝트 폴더에 진입 후 NestJS로 서버 폴더를 설치합니다.
nest new server
서버 폴더에 진입 후 NestJS에서 WebSocket 작업을 시작하기 위해 패키지를 설치합니다.
npm i --save @nestjs/websockets @nestjs/platform-socket.io
서버는 얼추 설치가 마무리 됐고, 다시 프로젝트 폴더로 나온 뒤 이제 React 프로젝트를 생성합니다.
npx create-react-app client
클라이언트 폴더로 진입 후 React에서도 필요한 패키지를 설치합니다.
npm i --save socket.io-client
이렇게 프로젝트 기본 구성을 마쳤습니다!
이제 본격적으로 서버를 만들어 볼까요🔥
서버 구성
NestJS에서 WebSocket을 관리하려면 Gateway를 사용해야 하고 이것을 이용하면 간단히 @WebSocketGateway() 데코레이터를 이용해 간단히 설정이 가능해집니다.
그럼 이제 ChatGateway를 생성해 볼까요!
nest generate gateway chat
이렇게 하면 자동으로 chat 폴더에 chat.gateway.ts, chat.gateway.spec.ts 파일들를 자동으로 생성해 줍니다.
그리고 아래와 같이 ChatGateway 클래스에 @WebSocketGateway() 데코레이터가 추가되고 handleMessage 기능을 통해 메시지를 구독할 수 있게 됩니다. 이렇게 되면 클라이언트로부터 데이터를 전달하는 동안 들어오는 메시지를 처리할 수 있게 됩니다.
import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
@WebSocketGateway()
export class ChatGateway {
@SubscribeMessage('message') // subscribe to message events
handleMessage(client: any, payload: any): string {
return 'Hello world!'; // return the text
}
}
handleMessage 기능은 2개의 파라미터가 있는데, client: 플랫폼 별 소켓 인스턴스, payload: 클라이언트로부터 수신된 데이터 이렇게 2개가 있습니다.
클라이언트 측에서 메시지를 보내려면 어떻게 해야 하나요? socket.io-client 패키지를 활용해 이것이 가능합니다. 또한 아래 두번째 줄과 같이 서버에서 보낸 결과 데이터를 다룰 수도 있습니다.
socket.emit('events', { name: 'Nest' });
socket.emit('events', { name: 'Nest' }, (data) => console.log(data));
단순히 서버에서 클라이언트로 데이터를 반환하는 예를 보았는데, 그것이 아닌 메시지를 subscribe 중인 모든 고객에게 메시지를 broadcast 해 보겠습니다.
import {
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
MessageBody,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { AddMessageDto } from './dto/add-message.dto';
@WebSocketGateway({ cors: { origin: '*' } })
export class ChatGateway {
@WebSocketServer()
server: Server;
private logger = new Logger('ChatGateway');
@SubscribeMessage('chat') // subscribe to chat event messages
handleMessage(@MessageBody() payload: AddMessageDto): AddMessageDto {
this.logger.log(`Message received: ${payload.author} - ${payload.body}`);
this.server.emit('chat', payload); // broadbast a message to all clients
return payload; // return the same payload data
}
}
위와 같이 데코레이터 @WebSocketServer()를 이용해 broadcast를 실행합니다.(chat을 구독중인 모두에게)
그리고 NestJS에서 제공하는 2개의 수명주기를 사용해 편리하게 소켓 연결 시기를 알 수 있습니다.
@WebSocketGateway({ cors: { origin: '*' } })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
...
...
// it will be handled when a client connects to the server
handleConnection(socket: Socket) {
this.logger.log(`Socket connected: ${socket.id}`);
}
// it will be handled when a client disconnects from the server
handleDisconnect(socket: Socket) {
this.logger.log(`Socket disconnected: ${socket.id}`);
}
}
위와 같이 OnGatewayConnection, OnGatewayDisconnect 2개에서 제공하는 handleConnection, handleDisconnect를 활용해 연결 시와 연결 끊김 시의 기능을 구현할 수 있습니다. 그리고 원래는 신뢰할 수 있는 주소에만 적용해야 하지만 테스트 중이니 cors를 모두 통과하도록 안전하지 않은 구성을 진행했습니다.
그럼 서버는 이제 마무리 했고 웹을 구현해 볼 차례입니다.
웹 구성
클라이언트에서 서버와 실시간으로 메시지를 보내고 받는 방법을 구현하기 위해 chat.tsx 파일에 만들 예정입니다.
import { useState, useEffect } from "react";
import { io } from "socket.io-client";
const SystemMessage = {
id: 1,
body: "Welcome to the Nest Chat app",
author: "Bot",
};
// create a new socket instance with localhost URL
const socket = io('http://localhost:4000', { autoConnect: false });
export function Chat({ currentUser, onLogout }) {
const [inputValue, setInputValue] = useState("");
const [messages, setMessages] = useState([SystemMessage]);
useEffect(() => {
socket.connect(); // connect to socket
socket.on("connect", () => { // fire when we have connection
console.log("Socket connected");
});
socket.on("disconnect", () => { // fire when socked is disconnected
console.log("Socket disconnected");
});
// listen chat event messages
socket.on("chat", (newMessage) => {
console.log("New message added", newMessage);
setMessages((previousMessages) => [...previousMessages, newMessage]);
});
// remove all event listeners
return () => {
socket.off("connect");
socket.off("disconnect");
socket.off("chat");
};
}, []);
const handleSendMessage = (e) => {
if (e.key !== "Enter" || inputValue.trim().length === 0) return;
// send a message to the server
socket.emit("chat", { author: currentUser, body: inputValue.trim() });
setInputValue("");
};
const handleLogout = () => {
socket.disconnect(); // disconnect when we do logout
onLogout();
};
return (
<div className="chat">
<div className="chat-header">
<span>Nest Chat App</span>
<button className="button" onClick={handleLogout}>
Logout
</button>
</div>
<div className="chat-message-list">
{messages.map((message, idx) => (
<div
key={idx}
className={`chat-message ${
currentUser === message.author ? "outgoing" : ""
}`}
>
<div className="chat-message-wrapper">
<span className="chat-message-author">{message.author}</span>
<div className="chat-message-bubble">
<span className="chat-message-body">{message.body}</span>
</div>
</div>
</div>
))}
</div>
<div className="chat-composer">
<input
className="chat-composer-input"
placeholder="Type message here"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleSendMessage}
/>
</div>
</div>
);
}
이제 웹 클라이언트를 모두 만들었으니 실제 화면을 공유해 봅니다.
NestJS - ReactJS의 데모 앱 시연
