Backend/Nest.js

[Nest.js] Socket.io 사용법

okojin 2024. 9. 27. 16:12

Socket.io

웹 서비스를 개발하다보면 사용자끼리 1:1 혹은 다수의 인원끼리 소통하기 위해서 실시간 통신이 필요한 경우가 있다.

이 때 WebSocket을 활용하면 실시간 통신을 손쉽게 구현할 수 있는데, 이번 글에서는 WebSocket을 활용하는 socket.io 라이브러리를 Nest.js에서 활용하여 1:1 채팅방을 구현하는 방법을 소개한다.

 

1. Nest.js 프로젝트 생성 및 초기 설정

1-1. Nest.js 프로젝트 생성

npx @nestjs/cli new chat-app
cd chat-app

1-2. 필수 패키지 설치

  • WebSocket 및 Socket.IO 관련 패키지 설치:
 
npm install @nestjs/websockets @nestjs/platform-socket.io socket.io
  • JWT 인증 관련 패키지 설치:
npm install @nestjs/jwt passport-jwt
  • TypeORM 및 MySQL 설정 (또는 다른 데이터베이스):
npm install @nestjs/typeorm typeorm mysql2
 

1-3 TypeORM 설정

 src/app.module.ts 파일에서  파일에서 TypeORM 설정을 추가하여 데이터베이스 연결을 설정한다.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'password',
      database: 'chat_app',
      autoLoadEntities: true,
      synchronize: true, // 개발용으로만 사용 (프로덕션에서는 마이그레이션 권장)
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

2. 엔티티 정의

사용자의 데이터를 정의하는 User 엔티티

채팅방의 데이터를 정의하는 ChatRoom 엔티티

메시지 데이터를 정의하는 Message 엔티티

위 3가지를 아래와 같이 작성한다.

2-1 User 엔티티

 
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: string;
  
  @Column()
  username: string;
  
  @Column()
  email: string;
}

2-2 ChatRoom 엔티티

import { Entity, PrimaryGeneratedColumn, ManyToMany, JoinTable } from 'typeorm';
import { User } from './user.entity';

@Entity()
export class ChatRoom {
  @PrimaryGeneratedColumn()
  id: number;

  @ManyToMany(() => User)
  @JoinTable()
  participants: User[];
}

2-3 Message 엔티티

import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm';
import { ChatRoom } from './chat-room.entity';

@Entity()
export class Message {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  content: string;

  @ManyToOne(() => User)
  sender: BaseUser;

  @ManyToOne(() => ChatRoom)
  room: ChatRoom;

  @Column({ default: false })
  isRead: boolean;
}

3. 서비스 구현

 

채팅방의 기능을 동작시키기위해 아래의 3가지 기능을 구현해준다.
  • 채팅방 찾기(이미 존재하는 채팅방을 리턴하고 존재하지 않을 경우 새로운 채팅방 생성 후 리턴)
  • 메시지 전송
  • 채팅 내역 조회(채팅방을 기준으로 메시지 데이터 전체 조회)

3-1 ChatService (대화방 및 메시지 관리)

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ChatRoom } from './chat-room.entity';
import { Message } from './message.entity';
import { User } from './user.entity';

@Injectable()
export class ChatService {
  constructor(
    @InjectRepository(ChatRoom)
    private chatRoomRepository: Repository<ChatRoom>,
    @InjectRepository(Message)
    private messageRepository: Repository<Message>,
  ) {}

  // 채팅방 찾기
  async findOrCreateRoom(user1: User, user2: User): Promise<ChatRoom> {
    let room = await this.chatRoomRepository.findOne({
      where: { participants: [user1, user2] },
    });

    if (!room) {
      room = this.chatRoomRepository.create({ participants: [user1, user2] });
      await this.chatRoomRepository.save(room);
    }

    return room;
  }

  // 메시지 전송
  async sendMessage(sender: User, room: ChatRoom, content: string) {
    const message = this.messageRepository.create({ sender, room, content });
    await this.messageRepository.save(message);
    return message;
  }

  // 채팅 내역 조회
  async getMessages(room: ChatRoom): Promise<Message[]> {
    return this.messageRepository.find({ where: { room } });
  }
}

4. WebSocket Gateway 구현 (Socket.IO 사용)

GateWay는 클라이언트와 서버가 실시간으로 통신할 수 있게 하는 진입점으로 실시간 데이터 전송이 필요한 시스템에서 많이 사용된다. GateWay의 주요 역할은 아래와 같다.

GateWay의 역할

  1. 클라이언트 연결 관리: 클라이언트가 연결되면 이를 처리하고, 연결을 해제하거나 재연결할 때도 관리한다.
  2. 이벤트 처리: 클라이언트에서 특정 이벤트가 발생하면 이를 서버에서 받아 처리할 수 있다. 클라이언트에서 서버로 메시지를 보내면 서버에서 이를 받아 처리하거나, 서버가 클라이언트로 데이터를 보낼 수 있다.
  3. 실시간 데이터 전송: 클라이언트가 서버와 연결된 동안 실시간으로 데이터를 주고받을 수 있다.

Nest.js에서는 WebSocket 통신을 관리하기 위해 @WebSocketGateway() 데코레이터를 사용하여 Gateway 클래스를 정의한다. 이는 WebSocket 서버의 역할을 하며, 클라이언트와의 소켓 통신을 처리한다.

4-1 ChatGateway

import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  MessageBody,
  ConnectedSocket,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Socket, Server } from 'socket.io';
import { ChatService } from './chat.service';
import { JwtService } from '@nestjs/jwt';

@WebSocketGateway({ namespace: '/chat' })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  constructor(
    private chatService: ChatService,
    private jwtService: JwtService,
  ) {}

  async handleConnection(socket: Socket) {
    const token = socket.handshake.query.token as string;
    const user = this.jwtService.verify(token);

    if (!user) {
      socket.disconnect();
      return;
    }

    socket.data.user = user;
    console.log(`User ${user.username} connected`);
  }

  async handleDisconnect(socket: Socket) {
    const user = socket.data.user;
    console.log(`User ${user.username} disconnected`);
  }

  @SubscribeMessage('joinRoom')
  async handleJoinRoom(
    @ConnectedSocket() socket: Socket,
    @MessageBody() roomId: number,
  ) {
    const user = socket.data.user;
    const room = await this.chatService.findRoomById(roomId);

    if (!room) {
      socket.emit('error', 'Room not found');
      return;
    }

    socket.join(roomId.toString());
    console.log(`User ${user.username} joined room ${roomId}`);
  }

  @SubscribeMessage('sendMessage')
  async handleMessage(
    @ConnectedSocket() socket: Socket,
    @MessageBody() { roomId, content }: { roomId: number; content: string },
  ) {
    const user = socket.data.user;
    const room = await this.chatService.findRoomById(roomId);

    if (!room) {
      socket.emit('error', 'Room not found');
      return;
    }

    const message = await this.chatService.sendMessage(user, room, content);
    this.server.to(roomId.toString()).emit('newMessage', {
      sender: user.username,
      content: message.content,
    });
  }
}