캡스톤 설계 [건물별 소통 플랫폼 BBC]

채팅 기능 구현하기

softmoca__ 2024. 3. 26. 23:08
목차

 

 

 npm i @nestjs/common @nestjs/core  @nestjs/jwt @nestjs/platform-express  @nestjs/platform-socket.io @nestjs/typeorm @nestjs/websockets

Socket.io는 패키지간 의존성이 잘 맞지 않는 경우가 많아 주요한 라이브러리들을 다시 설치해서 의존성을 맞혀준다.

 

1.  채팅방과 메세지 엔티티 생성

 

chats.entity.ts

@Entity()
export class ChatsModel extends BaseModel {
  @ManyToMany(() => UsersModel, (user) => user.chats)
  users: UsersModel[];

  @OneToMany(() => MessagesModel, (message) => message.chat)
  messages: MessagesModel;
}

 

messages.entity.ts

@Entity()
export class MessagesModel extends BaseModel {
  @ManyToOne(() => ChatsModel, (chat) => chat.messages)
  chat: ChatsModel;

  @ManyToOne(() => UsersModel, (user) => user.messages)
  author: UsersModel;

  @Column()
  @IsString()
  message: string;
}

채팅방과 메세지 엔티티 생성

 

 

 

 

2.  소켓 연결 시 인가를 위한 사용자 정보 socekt에 넣기

chats.gateway.ts

 async handleConnection(socket: Socket & { user: UsersModel }) {
    console.log(`on connect called : ${socket.id}`);

    const headers = socket.handshake.headers;
    const rawToken = headers["authorization"];

    if (!rawToken) {
      console.log("서버 : 토큰이 없는 에러로 인해 연결을 끊습니다.");
      socket.emit("exception", {
        data: "토큰이 없는 에러로 인해 연결을 끊습니다.",
      });
      socket.disconnect();
    }

    try {
      const token = this.authService.extractTokenFromHeader(rawToken, true);

      const payload = this.authService.verifyToken(token);
      const user = await this.usersService.getUserByEmail(payload.email);

      socket.user = user;

      return true;
    } catch (e) {
      console.log("서버 : 소켓 통신시 에러 발생으로 인해 연결을 끊습니다");
      socket.emit("exception", {
        data: "소켓 통신시 에러 발생으로 인해 연결을 끊습니다.",
      });

      socket.disconnect();
    }
  }

소켓 처음 연결시 해더의 토큰을 추출하고 사용자의 정보를 받아와 socket객체에 넣는다.

이후 연결된 소켓에 있는 사용자의 정보 인증과 인가에 이용한다.

 

소켓 헤더에 토큰이 없으면 위와 같이 바로 에러 문구와 함꼐 연결이 끊킨다.

 

토큰 검증이 잘되어 socket에 사용자의 정보가 잘 담겼으면 연결이  잘 유지 된다.

 

3.  채팅방 생성

 

creat-chat.dot.ts

export class CreateChatDto {
  @IsNumber({}, { each: true })
  userIds: number[];
}

채팅방에 참여할 사용자의 id를 배열로 받는 dto

 

socket-catch-http.exception-filter.ts

import { ArgumentsHost, Catch, HttpException } from "@nestjs/common";
import { BaseWsExceptionFilter } from "@nestjs/websockets";

@Catch(HttpException)
export class SocketCatchHttpExceptionFilter extends BaseWsExceptionFilter<HttpException> {
  catch(exception: HttpException, host: ArgumentsHost): void {
    const socket = host.switchToWs().getClient();

    socket.emit("exception", {
      data: exception.getResponse(),
    });
  }
}

exception필터로 모든 http에러를 잡은 뒤 expection 이벤트로 emit하는 exception필터 생성

 

->  NESTJS의 글로벌 파이프의 DTO 벨리데이션 체크는 RESTAPI 컨트롤러에만 적용이 된다. 

그래서 따로 소켓 컨트롤러에 파이프를 각자 따로 적용시켜 줘야한다.

또한 모든 exception은 http로 나오기 때문에 http exception을 websocket exception으로 변환을 해줘야 서버가 터지지 않고 에러를 걸러 낼 수 있다.

 

 

chats.gateway.ts

 @UsePipes(
    new ValidationPipe({
      transform: true,
      transformOptions: {
        enableImplicitConversion: true,
      },
      whitelist: true,
      forbidNonWhitelisted: true,
    })
  )
  @UseFilters(SocketCatchHttpExceptionFilter)
  @SubscribeMessage("create_chat")
  async createChat(
    @MessageBody() data: CreateChatDto,
    @ConnectedSocket() socket: Socket & { user: UsersModel }
  ) {
    console.log(socket.user);
    const chat = await this.chatsService.createChat(data);
  }

create_chat 이벤트를 받아 채팅방을 생성한다.

또한 초기 소켓에 연결될 때 사용자의 정보를 넣어줬기 때문에 console로 소켓을 찎어보면 사용자의 정보를 확인할 있고 이를 활용할 수 있다.

 

 

chats.service.ts

  async createChat(dto: CreateChatDto) {
    const chat = await this.chatsRepository.save({
      users: dto.userIds.map((id) => ({ id })),
    });

    return this.chatsRepository.findOne({
      where: {
        id: chat.id,
      },
    });
  }

 

입력받은 사용자의 id 들을 chat테이블에 저장한다.

 

 

3-1.  채팅방 생성 목록 조회

chats.controller.ts

  @Get()
  paginateChat(@Query() dto: PaginateChatDto) {
    return this.chatsService.paginateChats(dto);
  }

 

 

chats.service.ts

  paginateChats(dto: PaginateChatDto) {
    return this.commonService.paginate(
      dto,
      this.chatsRepository,
      {
        relations: {
          users: true,
        },
      },
      "chats"
    );
  }

 

위와 같이 create_chat으로 방을 생성한다.

DB에도 잘 저장이 된다.

 

이후 몇가지 다른 방을 생성한 뒤 채팅방 목록을 페이지 네이션으로 조회를 하면 아래와 같이  조회가 잘 된다.

 

 

 

4.  채팅방 입장

enter-chat.dto.ts

export class EnterChatDto {
  @IsNumber({}, { each: true })
  chatIds: number[];
}

입장할 채팅방 id를 배열로 받는다.

 

chats.service.ts

  async checkIfChatExists(chatId: number) {
    const exists = await this.chatsRepository.exists({
      where: {
        id: chatId,
      },
    });

    return exists;
  }

채팅방이 존재하는지 확인하는 함수

 

chats.gateway.ts

@UsePipes(
    new ValidationPipe({
      transform: true,
      transformOptions: {
        enableImplicitConversion: true,
      },
      whitelist: true,
      forbidNonWhitelisted: true,
    })
  )
  @UseFilters(SocketCatchHttpExceptionFilter)
  @SubscribeMessage("enter_chat")
  async enterChat(
    // 방의 chat ID들을 리스트로 받는다.
    @ConnectedSocket() socket: Socket & { user: UsersModel },
    @MessageBody() data: EnterChatDto
  ) {
    for (const chatId of data.chatIds) {
      const exists = await this.chatsService.checkIfChatExists(chatId);

      if (!exists) {
        throw new WsException({
          code: 100,
          message: `존재하지 않는 chat 입니다. chatId: ${chatId}`,
        });
      }
    }

    socket.join(data.chatIds.map((x) => x.toString()));
  }

입력된 채팅방이 존재하는지 확인한 뒤 각 채팅방에 join한다.

 

 

 

 

 

 

 

5. 메세지 보내기

create-messages.dto.ts

export class CreateMessagesDto extends PickType(MessagesModel, ["message"]) {
  @IsNumber()
  chatId: number;
}

메세지를 생성하는 dto.

 

 

 

chats.gateway.ts

 @UsePipes(
    new ValidationPipe({
      transform: true,
      transformOptions: {
        enableImplicitConversion: true,
      },
      whitelist: true,
      forbidNonWhitelisted: true,
    })
  )
  @UseFilters(SocketCatchHttpExceptionFilter)
  @SubscribeMessage("send_message")
  async sendMessage(
    @MessageBody() dto: CreateMessagesDto,
    @ConnectedSocket() socket: Socket & { user: UsersModel }
  ) {
    const chatExists = await this.chatsService.checkIfChatExists(dto.chatId);

    if (!chatExists) {
      throw new WsException(
        `존재하지 않는 채팅방입니다. Chat ID : ${dto.chatId}`
      );
    }

    const message = await this.messagesService.createMessage(
      dto,
      socket.user.id
    );

    socket
      .to(message.chat.id.toString())
      .emit("receive_message", message.message);
  }

클라이언트에서 보낸 메세지를 리스닝한다.

존재하는 채팅방인지 확인 한 후 받은 메세지를 저장하고

메세지의 해당하는 채팅방(id)에 receive_message 이벤트로 메세지를 보낸다.

 

messages.service.ts

  async createMessage(dto: CreateMessagesDto, authorId: number) {
    const message = await this.messagesRepository.save({
      chat: {
        id: dto.chatId,
      },
      author: {
        id: authorId,
      },
      message: dto.message,
    });

    return this.messagesRepository.findOne({
      where: {
        id: message.id,
      },
      relations: {
        chat: true,
      },
    });
  }

메세지를 저장한다.

이전에 생성한 1번 채팅방에 dddd라는 메세지를 보낸다.

 

그럼 이제 해당 메세지가 있는 채팅방과 작성자와 내용이 DB에  잘 저장이 된다.

 

 

3-1.  메세지 목록 조회

 

messages.controller.ts

  @Get()
  paginateMessage(
    @Param("cid", ParseIntPipe) id: number,
    @Query() dto: BasePaginationDto
  ) {
    console.log("dd");
    return this.messagesService.paginteMessages(dto, {
      where: {
        chat: {
          id,
        },
      },
      relations: {
        author: true,
        chat: true,
      },
    });
  }

 

messages.service.ts

  paginteMessages(
    dto: BasePaginationDto,
    overrideFindOptions: FindManyOptions<MessagesModel>
  ) {
    return this.commonService.paginate(
      dto,
      this.messagesRepository,
      overrideFindOptions,
      "messages"
    );
  }

 

 

 

새로운 다른 2번 채팅방에 메시지를 몇개를 보내니 . 잘보내진다.

 

 

DB에도 잘 저장이 된다.

각 채팅방 별 메세지 또한 작성자이름과 함께 잘 조회가 된다 !