본문 바로가기

JavaScript/NestJS

[NestJS] 채팅 구현하기(with ReactJS)

배경

Web 환경에서 실시간 채팅을 구현하려면 클라이언트와 서버가 양방향 통신을 할 수 있어야 합니다. 따라서, 이 글에선 클라이언트가 서버로 요청을 보내는 단방향 프로토콜인 HTTP 대신, WebSocket 프로토콜을 사용하여 채팅을 구현하는 방법을 소개합니다.


단방향 vs 양방향

HTTP 단방향 네트워크 프로토콜로 설계되었습니다. 클라이언트와 서버가 연결이 성립된 후, 클라이언트가 서버에게 요청을 보내고, 서버가 클라이언트에게 응답을 하면 연결이 종료됩니다. 여기서 주목할 점은 요청은 항상 클라이언트가 하고, 응답은 항상 서버가 한다는 점입니다. 이런 일방적인 프로세스를 단방향이라고 합니다. 라디오를 예로 들 수 있는데요, 라디오를 들으려면 우리는 라디오를 켭니다(요청). 라디오가 켜지면 해당 주파수의 방송을 들을 수 있습니다(응답). 하지만 우리는 그 방송과 직접적으로 또는 실시간으로 커뮤니케이션을 할 수는 없습니다.

 

WebSocket 양방향 네트워크 프로토콜로써, 특정 포트를 통해 클라이언트와 서버가 연결 유지한 채, 서로가 데이터를 주고받을 수 있도록 설계되었습니다. 여기서 주목할 점은 클라이언트와 서버가 서로에게 요청을 할 수 있다는 점입니다. 이러한 것을 양방향이라고 합니다. 전화가 연결된 후에 통화가 유지된 채로 서로 원하는 대화를 이어갈 수 있는 것을 예로 들 수 있습니다.


구조


1. 백엔드(NestJS)

먼저, 아래 패키지를 설치해줍니다.

npm i --save @nestjs/websockets @nestjs/platform-socket.io
npm i -D @types/socket.io

 

다음으로 chat 모듈과 gateway를 생성합니다.

nest g module chat
nest g gateway chat
// chat.module.ts

import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';

@Module({
  providers: [ChatGateway],
})
export class ChatModule {}
// app.module.ts

import ...

@Module({
  imports: [
    ...,
    ChatModule,
  ],
});

 

Nest에서 Gateway는 단순히 @WebSocketGateway() 데코레이터가 추가된 클래스이며, WebSocket 라이브러리와 호환됩니다. 자세한 내용은 아래 링크에서 확인할 수 있습니다.

https://docs.nestjs.com/websockets/gateways

 

이제 chat 게이트웨이를 구현해볼 텐데요, 먼저 @WebSockerGateway() 데코레이터를 작성합니다.

import { WebSocketGateway } from '@nestjs/websockets';

@WebSocketGateway({
  namespace: 'chat',
  cors: {
    origin: [
      'http://localhost:3000',
    ],
  },
})

 

첫 번째 인수로는 number 타입의 값을 전달해줄 수 있으며, 특정 포트 번호가 필요한 경우 넣어줄 수 있습니다.

예) @WebSocketGateway(8080, ...)

 

두 번째 인수로는 옵션값을 넣어줄 수 있는데요, 위 예시의 경우엔 namespace와 cors를 사용하고 있습니다. namespace는 웹소켓을 논리적으로 분리해주는 부분으로, 클라이언트에서 소켓을 연결할 때 사용할 URL인 http://localhost:3000/chat에서 /chat에 해당되는 부분입니다. cors는 Cross-Origin-Resouce-Sharing 헤더 옵션으로써, 연결을 허용하는 도메인을 적어주시면 됩니다.

 

다음으로, 게이트웨이 클래스를 구현체로 만듭니다.

import {
  ...,
  OnGatewayConnection,
  OnGatewayDisconnect,
  OnGatewayInit
} from '@nestjs/websockets';

@WebSocketGateway({
  namespace: 'chat',
  cors: {
    origin: [
      'http://localhost:3000',
    ],
  },
})
export class ChatGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  ...
}

 

다음으로, 소켓 서버가 초기화됐을 때 트리거 되는 부분을 작성합니다.

@WebSocketGateway({
  namespace: 'chat',
  cors: {
    origin: [
      'http://localhost:3000',
    ],
  },
})
export class ChatGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  private readonly logger = new Logger(ChatGateway.name);

  @WebSocketServer() nsp: Namespace;

  afterInit() {
    this.nsp.adapter.on('create-room', (room) => {
      this.logger.log(`"Room:${room}" is created`);
    });

    this.nsp.adapter.on('join-room', (room, id) => {
      this.logger.log(`"Socket:${id}" joined "Room:${room}"`);
    });

    this.nsp.adapter.on('leave-room', (room, id) => {
      this.logger.log(`"Socket:${id}" leaved "Room:${room}"`);
    });

    this.nsp.adapter.on('delete-room', (roomName) => {
      this.logger.log(`"Room:${roomName}" is deleted`);
    });

    this.logger.log('Chat Server Initialized');
  }
}

 

현재 @WebSocketGateway() 데코레이터에 namespace 옵션을 사용했기 때문에 @WebSocketServer() 데코레이터가 반환하는 값은 namespace 값입니다.

 

namespace를 사용하지 않으면 아래와 같이 사용할 수 있습니다.

import { Socket } from 'socket.io'; 

...
@WebSocketServer() server: Socket

 

afterInit() 함수는 OnGatewayInit의 구현체이고, 처음 소켓 서버가 생성될 때 트리거됩니다. 위 예시에선 소켓 서버에 'create-room' 등의 이벤트가 트리거됐을 때 콘솔에 log를 출력하는 작업을 수행하고 있습니다.

 

각 이벤트에는 room이라는 것이 있는데, 이는 소켓을 논리적으로 묶은 단위입니다. 소켓 서버를 논리적으로 나누면 namespace이고, 한 namespace에는 여러 개의 방이라는 논리적인 묶음이 있는 것입니다. 이 방에 소켓들이 모여 있고, 이 방에 있는 사람들에게 이벤트를 broadcast 해줄 수 있습니다.

https://socket.io/docs/v3/rooms/

 

 

 

다음으로,  클라이언트의 소켓이 방을 생성하고, 방에 Join할 수 있도록 구현합니다.

@WebSocketGateway({
  namespace: 'chat',
  cors: {
    origin: [
      'http://localhost:3000',
    ],
  },
})
export class ChatGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  private readonly logger = new Logger(ChatGateway.name);

  @WebSocketServer() nsp: Namespace;
  
  createdRooms: string[] = [];

  afterInit() {
    ...
  }
  
  @SubscribeMessage('join-room')
  handleJoinRoom(
    @ConnectedSocket() socket: Socket,
    @MessageBody() roomName: string,
  ) {
    socket.join(roomName);
    this.createdRooms.push(roomName); // 유저가 생성한 room 목록에 추가

    return { success: true, payload: roomName };
  }
}

 

@SubscribeMessage 데코레이터에 이벤트 이름을 적으면, 해당 이벤트가 서버에 트리거 됐을 때 해당 함수가 동작하게 됩니다. 위 예시에선 'join-room'이라는 이벤트가 트리거되면, 전달받은 방으로 Join 시켜줍니다. Join 할 방이 없으면 방은 자동으로 생성되며, 방에 아무도 없으면 방은 자동으로 삭제됩니다.

 

@ConnectedSocket() 데코레이터는 현재 이벤트를 트리거한 클라이언트의 소켓 정보를 반환합니다.

 

다음으로, 클라이언트 소켓이 전송한 메시지를 다른 소켓들에게 broadcast 해줄 함수를 구현합니다.

interface IChatMessage {
  roomName: string;
  message: string;
}

@WebSocketGateway({
  namespace: 'chat',
  cors: {
    origin: [
      'http://localhost:3000',
    ],
  },
})
export class ChatGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  private readonly logger = new Logger(ChatGateway.name);

  @WebSocketServer() nsp: Namespace;
  
  createdRooms: string[] = [];

  afterInit() {
    ...
  }
  
  @SubscribeMessage('join-room')
  handleJoinRoom(
    @ConnectedSocket() socket: Socket,
    @MessageBody() roomName: string,
  ) {
    ...
  }
  
  @SubscribeMessage('message')
  async handleMessage(
    @ConnectedSocket() socket: Socket,
    @MessageBody() { roomName, message }: IChatMessage,
  ) {
    socket.broadcast.to(roomName).emit('message', {
      username: socket.id,
      message
    });

    return { username: socket.id, lssn_chat_id, lssn_id, user_id, text };
  }
}

 

위 예시는 'message'라는 이벤트가 서버에 트리거되면, 전달받은 특정 방에 트리거한 소켓을 제외한 다른 소켓들에게 message 이벤트를 트리거 시킵니다. 이때, 메시지를 전송한 소켓의 id와 메시지를 함께 전달해주고 있습니다.

 

이렇게 하면 백엔드에서의 준비는 끝입니다.

아래는 전체 소스 코드입니다.

import { Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import {
  ConnectedSocket,
  MessageBody,
  OnGatewayConnection,
  OnGatewayDisconnect,
  OnGatewayInit,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
} from '@nestjs/websockets';
import { Namespace, Socket } from 'socket.io';
import { LessonChat } from 'src/lesson-chat/lesson-chat.entity';
import { Users } from 'src/user/user.entity';
import { DataSource } from 'typeorm';

interface IChatMessage {
  roomName: string;
  text: string;
}

@WebSocketGateway({
  namespace: 'chat',
  cors: {
    origin: [
      'http://localhost:3000',
      'https://dev.lesson-notes.com',
      'https://lesson-notes.com',
    ],
  },
})
export class ChatGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
  private readonly logger = new Logger(ChatGateway.name);

  @WebSocketServer() nsp: Namespace;

  createdRooms: string[] = [];

  // 웹소켓 서버 초기화 직후 실행
  afterInit() {
    this.nsp.adapter.on('create-room', (room) => {
      this.logger.debug(`"Room: ${room}" is created`);
    });

    this.nsp.adapter.on('join-room', (room, id) => {
      this.logger.debug(`"Socket: ${id}" joined "Room: ${room}"`);
    });

    this.nsp.adapter.on('leave-room', (room, id) => {
      this.logger.debug(`"Socket: ${id}" leaved "Room: ${room}"`);
    });

    this.nsp.adapter.on('delete-room', (roomName) => {
      this.logger.debug(`"Room: ${roomName}" is deleted`);
    });

    this.logger.debug('Chat Server Initialized');
  }

  // 소켓이 연결되면 실행
  handleConnection(@ConnectedSocket() socket: Socket) {
    this.logger.debug(`Chat Connected: ${socket.id}`);
  }

  // 소켓 연결이 끊기면 실행
  handleDisconnect(@ConnectedSocket() socket: Socket) {
    this.logger.debug(`Chat Disconnected: ${socket.id}`);
  }

  // 채팅방 생성 및 참여
  @SubscribeMessage('join-room')
  handleJoinRoom(
    @ConnectedSocket() socket: Socket,
    @MessageBody() roomName: string,
  ) {
    socket.join(roomName); // 기존에 없던 room으로 join하면 room이 생성됨
    this.createdRooms.push(roomName); // 유저가 생성한 room 목록에 추가
    return { success: true, payload: roomName };
  }

  // 소켓이 메시지 전송 시 브로드캐스트
  @SubscribeMessage('message')
  async handleMessage(
    @ConnectedSocket() socket: Socket,
    @MessageBody() { roomName, text }: IChatMessage,
  ) {
    socket.broadcast.to(roomName).emit('message', {
      username: socket.id,
      text,
    });
    return { username: socket.id, text };
  }
}

 

다음으로, 프런트엔드(ReactJS)에서 소켓을 연결하고 통신할 수 있도록 구현해보겠습니다.


2. 프런트엔드(ReactJS)

서버와 소켓 통신을 위해 socket.io-client를 설치해줍니다.

npm i socket.io-client

 

다음으로, 서버와 웹소켓 연결을 시도합니다.

import { io } from 'socket.io-client';

export default function Chat() {
  const socket = io('http://localhost:8080/chat');
  
  return (
    ...
  )
}

 

위 주소 중 포트 번호에는 백엔드가 사용하고 있는 포트 번호를 입력하거나, 백엔드에서 특정 포트 번호를 입력하셨다면 그 번호를 입력하시면 됩니다.

 

다음으로 컴포넌트가 렌더링됐을 때 socket에 이벤트를 등록합니다.

import { io } from 'socket.io-client';

interface IMessage {
  username: string; // 소켓 id
  message: string; // 메시지
}

export default function Chat() {
  const socket = io('http://localhost:8080/chat'); // 소켓 연결
  const [messageList, setMessageList] = useState<IMessage[]>([]); // 대화 목록
  
  const handleMessage = (res: IMessage) => {
    setMessageList((prev) => { ...prev, res });
  }
  
  useEffect(() => {
    // 메시지 수신 이벤트 등록
    socket.on('message', handleMessage);
    
    // 서버에 방 생성/참가 이벤트 트리거
    socket.emit('join-room', 'YOUR-ROOM-NAME');
    
    // 컴포넌트가 언마운트 될 때, 이벤트 해제
    return () => {
      socket.off('message');
    }
  }, []);
  
  return (
    <div className={'root'}>
      <div className={'list'}>
        {messageList.map((message) => {
          return <div>
            {message.text}
          </div>
        })}
      </div>
    </div>
  )
}

 

위와 같이 useEffect 훅에서 socket의 이벤트를 등록하고, 언마운트 될 때 해제해주면 됩니다. 이제 서버에서 이벤트를 브로드캐스트 해주면 handleMessage 함수가 동작하고, messageList에 수신한 메시지를 추가하여 새로운 메시지를 렌더해주게 됩니다.

 

또한, socket.emit을 사용하여 서버에 'join-room' 이벤트를 트리거해서, 소켓이 방을 생성하고 참여할 수 있도록 합니다.

 

마지막으로, 메시지 전송 버튼을 구현해주면 완료됩니다.

import { io } from 'socket.io-client';

interface IMessage {
  username: string; // 소켓 id
  message: string; // 메시지
}

export default function Chat() {
  const socket = io('http://localhost:8080/chat'); // 소켓 연결
  const [messageList, setMessageList] = useState<IMessage[]>([]); // 대화 목록
  
  const handleMessage = (res: IMessage) => {
    setMessageList((prev) => { ...prev, res });
  }
  
  useEffect(() => {
    // 메시지 수신 이벤트 등록
    socket.on('message', handleMessage);
    
    // 서버에 방 생성/참가 이벤트 트리거
    socket.emit('join-room', 'YOUR-ROOM-NAME');
    
    // 컴포넌트가 언마운트 될 때, 이벤트 해제
    return () => {
      socket.off('message');
    }
  }, []);
  
  return (
    <div className={'root'}>
      <div className={'list'}>
        {messageList.map((message) => {
          return <div>
            {message.text}
          </div>
        })}
      </div>
      <button onClick={clickSend}>메시지 전송</button>
    </div>
  )
  
  // 메시지 전송 버튼 클릭
  function clickSend() {
    socket.emit(
      'message',
      { roomName: 'YOUR-ROOM-NAME', message: 'hi' },
      handleMessage,
    );
  }
}
  • socket.emit의 첫 번째 인자는 이벤트명, 두 번째 인자는 payload, 세 번째 인자는 콜백 함수입니다.

 

메시지 전송 버튼을 누르면 소켓은 서버에 'message' 이벤트를 트리거하고, 메시지를 전달합니다.  이제 전송된 메시지는 같은 방의 다른 소켓들에게 브로드캐스팅되어 전달될 것입니다.

 


참고

'JavaScript > NestJS' 카테고리의 다른 글

[NestJS] 스케줄러로 반복 작업 설정하기  (0) 2023.05.18
GCS에 파일 업로드(NestJS)  (1) 2023.02.01