백엔드 개발자(node.js)가 되는 과정

socket.io를 활용한 채팅 기능 구현에 대한 회고

soopy 2023. 12. 8. 16:01
728x90

 

이전 라이브 스트리밍 프로젝트에서 작성한 채팅 구현 코드를 회고하는 과정에서 문제점을 개선했습니다.
먼저 WebSocket과 Socket.io의 차이점에 대해서 다시 살펴보았습니다.

WebSocket: 웹 표준 프로토콜로, RFC 6455에 정의되어 있습니다. 이는 웹 브라우저와 웹 서버 간의 표준화된 양방향 통신 프로토콜입니다.
Socket.io: WebSocket을 기반으로 하지만, 실제로는 여러 프로토콜을 사용할 수 있습니다. 먼저 WebSocket을 시도하고, 그 후에 다른 전송 방법(예: 폴링)을 사용할 수 있습니다. 또한 이벤트 처리, 룸, 네임스페이스 등의 기능을 내장하고 있어 WebSocket 연결 이후 여러 부수적인 기능을 지원하는 편의성을 지니고 있습니다.

결론적으로, WebSocket은 표준화된 프로토콜을 말하고, Socket.io는 websocket 통신과 더불어 추가적인 기능과 사용의 편의성을 제공하여 개발자들이 보다 쉽게 실시간 양방향 통신을 구현할 수 있도록 도와줍니다. 

 

기존 코드 개선 후 구현하기

채팅 기능을 구현하고자 하는 페이지의 html javascript에서 아래와 같이 작성하면 해당 페이지에 접근 시 백엔드와의 웹소켓 통신을 시도합니다. 특히 socket.io의 경우 웹소켓을 우선적으로 시도하지만 웹소켓이 지원되지 않는 환경이라면 폴링과 같은 다른 전송 방법을 시도합니다.

const getUserData = async () => {
    try {
        const res = await fetch('/api/users');
        if (!res.ok) {
            alert(`유저데이터를 가져올 수 없습니다. 로그인 페이지로 이동합니다.`);
            window.location.href = '/login'
        }
        return await res.json();
    }
}

// 웹주소에서 라이브채널id를 가져옵니다.
const roomId = window.location.pathname.split('/')[2];
const user = getUserData()

// views에서 소켓io를 생성합니다. 이 때 roomId를 헤더에 담습니다.
const socket = io('/', {
  query: { roomId: roomId, user: JSON.stringify(user) }
});

특히 socket.io를 쓰는 이유 중 하나인 룸(Room) 기능은 같은 채널에 속한 유저 간 채팅을 구현하기 위한 필수적인 기능입니다.
위 코드에서는 io의 네임스페이스를 루트('/')로 두고 있지만 특정 이름으로 설정한다면 웹소켓 통신을 여러 그룹으로 나뉘어 동작할 수 있도록 하는 '네임스페이스' 기능도 socket.io의 대표적인 특징입니다.

import { Injectable } from '@nestjs/common';
import {
  ConnectedSocket,
  MessageBody,
  OnGatewayConnection,
  OnGatewayDisconnect,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import {
  IEventsGatewayHandleChat,
  IEventsGatewayHandleDonation,
} from './interfaces/events-gateway.interface';
import { ChatsService } from '../chats/chats.service';
import { CreateChatDto } from '../chats/dto/create-chat.dto';
import { User } from 'src/apis/users/entities/user.entity';

@WebSocketGateway()
@Injectable()
export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
  constructor(private readonly chatsService: ChatsService) {}

  @WebSocketServer()
  server: Server;

  // OnGatewayConnection에 내장된 함수로 웹소켓 연결 시 수행할 동작을 지정할 수 있다.
  handleConnection(@ConnectedSocket() socket: Socket): Promise<void> {
      const roomId: string = socket.handshake.query.roomId

    socket.join(roomId);
    const userCount: number = this.server.of('/').adapter.rooms.get(roomId).size;
    this.server.of('/').to(roomId).emit('userCount', { userCount });

  }

각 유저가 채널에 접속했을 때 같은 Room에 소속될 수 있도록 위와 같이 코드를 작성했습니다.
handleConnection함수는 socket.io로 접속 시 쿼리에 담긴 roomId로 socket.io의 Room을 생성하거나 접속합니다.
그리고 현재 채팅 참여자 수를 프론트에 표기하기 위해 Room에 접속한 소켓의 개수를 카운팅하여 'userCount'라는 이벤트로 발행합니다.

// 프론트에서 연결 끊길 시
  handleDisconnect(@ConnectedSocket() socket: Socket): void {
    const roomId: string = socket.handshake.query.roomId;

    console.log(`${socket.id} 소켓 연결 해제`);

    const userCount = this.server.of('/').adapter.rooms.get(roomId)?.size;
    this.server.of('/').to(roomId).emit('userCount', { userCount });
  }

접속을 해제했을 때는 위와 같이 작성할 수 있습니다. 특히 해당 소켓과의 커넥션이 종료되면 자동으로 해당 Room에서 Out처리 되며, Room에 소켓이 하나도 없을 경우 해당 Room 또한 자동으로 삭제됩니다.
그래서 특정 소켓의 퇴장 후 해당 Room에 남은 소켓의 개수만 이벤트 발행해서 채팅방에 남은 유저 수를 보여주도록 합니다.

    @SubscribeMessage('chat')
      handleChat(
        @ConnectedSocket() socket: Socket,
        @MessageBody() data: IEventsGatewayHandleChat,
      ): void {
        const chat: string = data.chat;
          const user: User = JSON.parse(socket.handshake.query.user);
            const roomId = socket.handshake.query.roomId;

            this.server.of('/').to(roomId).emit('chat', {
            user,
            chat,
            });

            // mongoDB에 채팅 저장을 위한 서비스 호출
            const createChatDto: CreateChatDto = {
            liveId: roomId,
            userId: user.id,
            email: user.email,
            nickname: user.nickname,
            content: data.chat,
            };
            this.chatsService.createChat(createChatDto);
            }

이제 프론트에서 'chat'이라는 이름의 이벤트를 발행했을 때 백엔드에서 받아 처리해야할 로직을 작성합니다.
먼저 프론트에서 보내준 채팅내용을 받으면 사전에 소켓 쿼리에 담아둔 유저 정보와 함께 다시 프론트에 보내줍니다.
이 때 같은 룸에 있는 소켓에게만 전달합니다.

이후 프론트에서 유저 정보에 포함된 유저 프로필 사진이나, 닉네임 등의 정보를 가지고 채팅창에 내용을 띄웁니다. (프론트 부분은 생략)

이전 코드와 달라진 점, 그리고 회고

기존 코드에서는 프론트에서 socket.io로 백엔드와 처음 연결할 때 유저 정보를 넘겨주지 않았습니다. 그리고 실제로 'chat' 이벤트가 발행되었을 때 user정보가 함께 전달되도록 구현되어 있었습니다.
해당 코드에서 문제가 있다고 본 점은 매번 채팅 입력 시 유저 정보가 socket.emit으로 노출된다는 점이었습니다. 그렇다면 차라리 프론트와 백엔드가 최초로 연결되는 과정에서 유저 정보를 socket에 저장하고, 이후에는 socket에 내장된 유저 정보를 꺼내어 쓸수 있도록 하면 좋겠다는 생각에 코드를 수정했습니다.
하지만 보안상 완벽한 방법은 아니라고 생각합니다. 어쨌든 유저 정보가 한 번은 가로채어질 가능성이 있는 것이니까요. 이 부분을 해결하기 위해서는 유저 정보를 유저 id만 전달해서 내부적으로 유저 정보 조회를 하는 방법이 있지만 채팅 생성이 매우 자주 발생하는 환경에서 매번 유저 조회 쿼리를 날리는 것이 성능 측면에서 올바른 선택인가를 고려했을 때 선뜻 선택하지 않게 되었습니다.

728x90
728x90