웹소켓 - 실시간 채팅(2) (Socket.io, React, NestJS)

iskkiri2022년 10월 02일
React
NestJS
Web Socket
웹소켓
Socket.io
실시간 채팅
웹소켓 - 실시간 채팅(2) (Socket.io, React, NestJS)

이번 포스팅에서는 room을 추가하여 간단한 실시간 채팅을 만들어보겠습니다. 이전 글과 이어지므로 참고하길 바랍니다.

socket.io에서 room이란 소켓이 들어가고 나갈 수 있는 일종의 '방'이며, 방은 하나의 통신 채널이라고 볼 수 있습니다.

소켓은 여러 개의 room에 들어갈 수 있고(여러 개의 통신 채널을 가질 수 있고), 자신이 속한 room을 제외한 어떤 room에도 들어가지 않을 수도 있습니다.

그러나 room의 상위 개념인 namespace에는 반드시 속해있기 때문에, 기본적으로 namespace라는 통신 채널은 갖게 됩니다.

또 이전 글에서 설명했듯이 각 소켓은 자신의 소켓 아이디에 해당하는 room에 들어가 있어서, dm과 같이 개인에게 메시지를 보낼 수도 있습니다.

 

이번 예제의 결과물입니다.

 

 

이전 예제에서 확장할 것이므로 마찬가지로 프론트는 react를 이용하고, 백엔드는 nest를 이용하겠습니다.

 

Frontend (React)

 

이번 예제에 필요한 라이브러리를 설치해줍니다.

 

npm i react-router-dom

 

채팅방으로 입장하는 것을 페이지의 이동으로 처리하기 위해 react-router-dom을 설치했습니다.

라우터를 설정해줍니다.

 

// src/index.tsx

import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';

import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

 

루트 경로를 대기실로, 채팅방의 이름에 따라 다이나믹 라우팅을 하도록 설정했습니다.

 

// src/App.tsx

import { Route, Routes } from 'react-router-dom';
import Chatroom from './pages/chatroom/chatroom';
import WaitingRoom from './pages/waiting-room/waiting-room';
import { io } from 'socket.io-client';

// 웹소켓 연결 및 소켓 인스턴스 생성
export const socket = io('http://localhost:4000/chat');

const App = () => {
  return (
    <Routes>
      <Route path="/" element={<WaitingRoom />} />
      <Route path="/room/:roomName" element={<Chatroom />} />
    </Routes>
  );
};


export default App;

 

소켓과 관련된 부분만을 나눠서 보겠습니다.

 

   useEffect(() => {
    const roomListHandler = (rooms: string[]) => {
      setRooms(rooms);
    };
    socket.emit('room-list', roomListHandler);

    return () => {
      socket.off('room-list', roomListHandler);

    };
  }, []);

 

대기실 페이지에 접속하면 현재 생성된 채팅방의 목록을 표시해주기 위한 부분입니다.

컴포넌트가 마운트되면 유저에 의해서 생성된 채팅방이 있는지 확인하기 위해 서버에 room-list 이벤트를 보냅니다.

서버 측에서는 room-list 이벤트에 대해서 생성된 채팅방의 목록을 응답할 것입니다.

 

  useEffect(() => {
    const createRoomHandler = (newRoom: string) => {
      setRooms((prevRooms) => [...prevRooms, newRoom]);
    };
    socket.on('create-room', createRoomHandler);

    return () => {
      socket.off('create-room', createRoomHandler);
    };
  }, []);

 

누군가가 채팅방을 생성하면 대기실의 방 목록에 생성된 채팅방이 추가되어야 합니다.

채팅방이 생성되면 서버 측에서 create-room 이벤트를 발생시키고, 생성해야 할 채팅방의 이름을 전달해줄 것입니다.

 

  useEffect(() => {
    const deleteRoomHandler = (roomName: string) => {
      setRooms((prevRooms) => prevRooms.filter((room) => room !== roomName));
    };

    socket.on('delete-room', deleteRoomHandler);

    return () => {
      socket.off('delete-room', deleteRoomHandler);
    };
  }, []);

 

생성됐던 채팅방에 속한 유저가 한 명도 존재하지 않으면 대기실에서 해당 방을 제거해야 합니다.

서버 측에서 유저가 명도 존재하지 않는 것을 감지하여 delete-room 이벤트를 발생시키고, 제거해야 채팅방의 이름을 전달해줄 것입니다.

 

  const onCreateRoom = useCallback(() => {
    const roomName = prompt('방 이름을 입력해 주세요.');
    if (!roomName) return alert('방 이름은 반드시 입력해야 합니다.');

    socket.emit('create-room', roomName, (response: CreateRoomResponse) => {
      if (!response.success) return alert(response.payload);

      navigate(`/room/${response.payload}`);
    });
  }, [navigate]);

 

채팅방을 생성하기 위해서 서버 측에 create-room 이벤트를 발생시키고, 서버 측에서는 입력한 이름으로 채팅방을 생성시킬 것입니다.

서버에서 정상적으로 채팅방을 생성시켰다면 해당 방으로 이동합니다.

 

  const onJoinRoom = useCallback(
    (roomName: string) => () => {
      socket.emit('join-room', roomName, () => {
        navigate(`/room/${roomName}`);
      });
    },
    [navigate]
  );

 

생성된 채팅방에 입장하기 위해서 서버 측에 join-room 이벤트를 발생시킵니다. 서버 측에서는 유저가 들어가고자 하는 방으로 소켓을 입장시킬 것입니다.

 

src/waiting-room/waiting-room.tsx 전체 코드입니다.

jsx css 중요한 것이 아니므로 설명없이 넘어가겠습니다.

 

// src/pages/waiting-room/waiting-room.tsx 

import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Head, Table } from './waiting-room.styles';
import { socket } from '../../App';


interface CreateRoomResponse {
  success: boolean;
  payload: string;
}


const WaitingRoom = () => {
  const [rooms, setRooms] = useState<string[]>([]);
  const navigate = useNavigate();


  useEffect(() => {
    const roomListHandler = (rooms: string[]) => {
      setRooms(rooms);
    };
    const createRoomHandler = (newRoom: string) => {
      setRooms((prevRooms) => [...prevRooms, newRoom]);
    };
    const deleteRoomHandler = (roomName: string) => {
      setRooms((prevRooms) => prevRooms.filter((room) => room !== roomName));
    };


    socket.emit('room-list', roomListHandler);
    socket.on('create-room', createRoomHandler);
    socket.on('delete-room', deleteRoomHandler);


    return () => {
      socket.off('room-list', roomListHandler);
      socket.off('create-room', createRoomHandler);
      socket.off('delete-room', deleteRoomHandler);
    };
  }, []);


  const onCreateRoom = useCallback(() => {
    const roomName = prompt('방 이름을 입력해 주세요.');
    if (!roomName) return alert('방 이름은 반드시 입력해야 합니다.');


    socket.emit('create-room', roomName, (response: CreateRoomResponse) => {
      if (!response.success) return alert(response.payload);


      navigate(`/room/${response.payload}`);
    });
  }, [navigate]);


  const onJoinRoom = useCallback(
    (roomName: string) => () => {
      socket.emit('join-room', roomName, () => {
        navigate(`/room/${roomName}`);
      });
    },
    [navigate]
  );


  return (
    <>
      <Head>
        <div>채팅방 목록</div>
        <button onClick={onCreateRoom}>채팅방 생성</button>
      </Head>


      <Table>
        <thead>
          <tr>
            <th>방번호</th>
            <th>방이름</th>
            <th>입장</th>
          </tr>
        </thead>
        <tbody>
          {rooms.map((room, index) => (
            <tr key={room}>
              <td>{index + 1}</td>
              <td>{room}</td>
              <td>
                <button onClick={onJoinRoom(room)}>입장하기</button>
              </td>
            </tr>
          ))}
        </tbody>
      </Table>
    </>
  );
};


export default WaitingRoom;

 

// src/pages/waiting-room/waiting-room.styles.ts 

import styled from '@emotion/styled';

const Head = styled.div`
  margin-top: 1rem;
  display: flex;
  justify-content: space-between;
  align-items: center;

  button {
    padding: 8px 12px;
  }
`;

const Table = styled.table`
  width: 100%;
  border: 1px solid #000;
  border-collapse: collapse;
  margin-top: 12px;

  thead {
    white-space: pre-wrap;
    th {
      padding: 8px 0;
    }
  }

  tbody {
    text-align: center;
  }

  th,
  td {
    border: 1px solid #000;
  }
`;


export { Head, Table };

 

이번에는 chatroom 관련된 코드입니다. 이전 글에서 작성했던 App.tsx 해당하는 코드이며, room 추가되면서 조금 수정되었습니다.

 

    useEffect(() => {
    const messageHandler = (chat: IChat) =>
      setChats((prevChats) => [...prevChats, chat]);

    socket.on('message', messageHandler);

    return () => {
      socket.off('message', messageHandler);
    };
  }, []);

 

채팅방에 입장하면 같은 채팅방에 있는 누군가가 보내는 메시지를 볼 수 있어야 합니다.

서버 측에서는 누군가가 전송한 메시지를 message 이벤트를 발생시켜 채팅 전체로 뿌려줍니다.

 

  const onSendMessage = useCallback(
    (e: FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      if (!message) return alert('메시지를 입력해 주세요.');

      socket.emit('message', { roomName, message }, (chat: IChat) => {
        setChats((prevChats) => [...prevChats, chat]);
        setMessage('');
      });
    },
    [message, roomName]
  );

 

메시지를 서버로 보내는 부분입니다. 이전과 달라진 점은 어느 채팅방에서 메시지를 보내고 있는지 알리기 위해 채팅방의 이름(roomName) 추가해서 전송하도록 했습니다.

 

  const onLeaveRoom = useCallback(() => {
    socket.emit('leave-room', roomName, () => {
      navigate('/');
    });
  }, [navigate, roomName]);

 

채팅방을 나갈 때, leave-room 이벤트를 발생시킵니다. 서버 측에서는 leave-room 이벤트가 발생하면 해당 소켓을 room에서 나가도록 처리할 것입니다.

 

chatroom 전체 코드입니다. jsx css 중요하지 않으므로 설명없이 넘어가겠습니다.

 

// src/pages/chatroom/chatroom.tsx

import classNames from 'classnames';
import {
  ChangeEvent,
  FormEvent,
  useCallback,
  useEffect,
  useRef,
  useState,
} from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { socket } from '../../App';
import {
  ChatContainer,
  LeaveButton,
  Message,
  MessageBox,
  MessageForm,
} from './chatroom.styles';


interface IChat {
  username: string;
  message: string;
}


const ChatRoom = () => {
  const [chats, setChats] = useState<IChat[]>([]);
  const [message, setMessage] = useState<string>('');
  const chatContainerEl = useRef<HTMLDivElement>(null);


  const { roomName } = useParams<'roomName'>();
  const navigate = useNavigate();


  // 채팅이 길어지면(chats.length) 스크롤이 생성되므로, 스크롤의 위치를 최근 메시지에 위치시키기 위함
  useEffect(() => {
    if (!chatContainerEl.current) return;


    const chatContainer = chatContainerEl.current;
    const { scrollHeight, clientHeight } = chatContainer;


    if (scrollHeight > clientHeight) {
      chatContainer.scrollTop = scrollHeight - clientHeight;
    }
  }, [chats.length]);


  // message event listener
  useEffect(() => {
    const messageHandler = (chat: IChat) =>
      setChats((prevChats) => [...prevChats, chat]);


    socket.on('message', messageHandler);


    return () => {
      socket.off('message', messageHandler);
    };
  }, []);


  const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    setMessage(e.target.value);
  }, []);


  const onSendMessage = useCallback(
    (e: FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      if (!message) return alert('메시지를 입력해 주세요.');


      socket.emit('message', { roomName, message }, (chat: IChat) => {
        setChats((prevChats) => [...prevChats, chat]);
        setMessage('');
      });
    },
    [message, roomName]
  );


  const onLeaveRoom = useCallback(() => {
    socket.emit('leave-room', roomName, () => {
      navigate('/');
    });
  }, [navigate, roomName]);


  return (
    <>
      <h1>Chat Room: {roomName}</h1>
      <LeaveButton onClick={onLeaveRoom}>방 나가기</LeaveButton>
      <ChatContainer ref={chatContainerEl}>
        {chats.map((chat, index) => (
          <MessageBox
            key={index}
            className={classNames({
              my_message: socket.id === chat.username,
              alarm: !chat.username,
            })}
          >
            <span>
              {chat.username
                ? socket.id === chat.username
                  ? ''
                  : chat.username
                : ''}
            </span>
            <Message className="message">{chat.message}</Message>
          </MessageBox>
        ))}
      </ChatContainer>
      <MessageForm onSubmit={onSendMessage}>
        <input type="text" onChange={onChange} value={message} />
        <button>보내기</button>
      </MessageForm>
    </>
  );
};


export default ChatRoom;

 

// src/pages/chatroom/chatroom.styles.ts 

import styled from '@emotion/styled';

const LeaveButton = styled.button`
  margin-bottom: 0.5rem;
  display: block;
  margin-left: auto;
`;

const ChatContainer = styled.div`
  display: flex;
  flex-direction: column;
  border: 1px solid #000;
  padding: 1rem;

  min-height: 360px;
  max-height: 600px;
  overflow: auto;

  background: #b2c7d9;
`;

const MessageBox = styled.div`
  display: flex;
  flex-direction: column;

  &.my_message {
    align-self: flex-end;

    .message {
      background: yellow;
      align-self: flex-end;
    }
  }

  &.alarm {
    align-self: center;
  }
`;

const Message = styled.span`
  margin-bottom: 0.5rem;
  background: #fff;
  width: fit-content;
  padding: 12px;
  border-radius: 0.5rem;
`;

const MessageForm = styled.form`
  display: flex;
  margin-top: 24px;

  input {
    flex-grow: 1;
    margin-right: 1rem;
  }
`;

export { LeaveButton, ChatContainer, MessageBox, Message, MessageForm };

 

 

Backend (NestJS)

 

이번에는 nest를 살펴보겠습니다. 이전 예제에서 더 추가되는 라이브러리는 없습니다.

이전 글에서 달라진 부분만 중점적으로 설명하겠습니다.

 

let createdRooms: string[] = [];

 

유저가 생성한 방을 서버 측에서 저장해두기 위해서 만든 변수입니다.

socket의 room이 map객체와 set객체로 이루어져 있어서 별도로 변수를 만들지 않고, map객체와 set객체의 prototype 메서드만으로도 구현할 수 있습니다.

그러나 실제로 구현해보니 복잡해지고, 불필요한 연산이 발생하는듯 하여 위와 같은 방식을 택했습니다.

 

afterInit() {
    this.nsp.adapter.on('delete-room', (room) => {
      const deletedRoom = createdRooms.find(
        (createdRoom) => createdRoom === room,
      );
      if (!deletedRoom) return;


      this.nsp.emit('delete-room', deletedRoom);
      createdRooms = createdRooms.filter(
        (createdRoom) => createdRoom !== deletedRoom,
      ); // 유저가 생성한 room 목록 중에 삭제되는 room 있으면 제거
    });


    this.logger.log('웹소켓 서버 초기화 ✅');
  }

 

설명을 위해서 작성했었던 불필요한 이벤트 핸들러들은 모두 제거했습니다.

room에 어떤 소켓도 존재하지 않으면 자동으로 room이 삭제됩니다.

socket 자신의 id 일치하는 room 갖고, 연결이 끊기면 socket id 이름을 가진 room 삭제가 되기 때문에, 삭제된 room 중에서 유저가 생성한 room(createdRooms) 있는지 체크하고, 존재한다면 delete-room 이벤트를 발생시켜서 대기실에서 채팅방을 삭제하도록 합니다.

 

  @SubscribeMessage('message')
  handleMessage(
    @ConnectedSocket() socket: Socket,
    @MessageBody() { roomName, message }: MessagePayload,
  ) {
    socket.broadcast
      .to(roomName)
      .emit('message', { username: socket.id, message });


    return { username: socket.id, message };
  }

 

메시지를 받는 부분으로 이전과 달리 메시지를 보낸 유저의 채팅방 이름을 받도록 했습니다.

broadcast 이용하여 메시지를 보낸 소켓을 제외하였고, to(roomName) 이용하여 room 있는 모든 소켓에게 message 이벤트를 발생시켜 채팅방에 메시지를 뿌리도록 했습니다. to 메서드는 특정 room에만 메시지를 보내거나, 개인에게 메시지를 보낼 활용될 있습니다.

 

  @SubscribeMessage('room-list')
  handleRoomList() {
    return createdRooms;
  }

 

대기실에서 생성된 방의 목록을 제공하기 위한 부분입니다. 프론트 측에서 대기실 페이지에 입장하면 room-list 이벤트를 발생시키도록 하였습니다.

응답으로 서버 측에서 관리하고 있는 생성된 방의 목록을 제공합니다.

 

  @SubscribeMessage('create-room')
  handleCreateRoom(
    @ConnectedSocket() socket: Socket,
    @MessageBody() roomName: string,
  ) {
    const exists = createdRooms.find((createdRoom) => createdRoom === roomName);
    if (exists) {
      return { success: false, payload: `${roomName} 방이 이미 존재합니다.` };
    }

    socket.join(roomName); // 기존에 없던 room으로 join하면 room이 생성됨
    createdRooms.push(roomName); // 유저가 생성한 room 목록에 추가
    this.nsp.emit('create-room', roomName); // 대기실 방 생성

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

 

같은 이름의 채팅방이 존재하는지 확인하고, 이미 방이 존재하면 room을 생성하지 않습니다. 같은 이름의 방이 존재하지 않으면 채팅방을 생성합니다.

소켓을 기존에 없던 room 입장시키면 자동으로 room 생성되면서 입장됩니다. 그리고 방을 생성했으면 대기실에서 생성된 방을 목록에 표시해야 하므로 create-room 이벤트를 발생시켜줍니다.

 

  @SubscribeMessage('join-room')
  handleJoinRoom(
    @ConnectedSocket() socket: Socket,
    @MessageBody() roomName: string,
  ) {
    socket.join(roomName); // join room
    socket.broadcast
      .to(roomName)
      .emit('message', { message: `${socket.id}가 들어왔습니다.` });


    return { success: true };
  }

 

이미 생성된 방에 다른 누군가가 입장하면, 입장시켜주고 누가 들어왔는지 알립니다.

broadcast 이용하여 입장한 소켓을 제외하였고, to(roomName) 이용하여 입장한 방에만 메시지를 보내도록 하였습니다.

 

  @SubscribeMessage('leave-room')
  handleLeaveRoom(
    @ConnectedSocket() socket: Socket,
    @MessageBody() roomName: string,
  ) {
    socket.leave(roomName); // leave room
    socket.broadcast
      .to(roomName)
      .emit('message', { message: `${socket.id}가 나갔습니다.` });

    return { success: true };
  }

 

방에서 나가면 leave 메서드를 이용하여 room에서 나가도록 합니다. 마찬가지로 broadcast와 to를 이용하여 알림 메시지를 보냅니다.

 

이벤트 게이트웨이의 전체 코드는 다음과 같습니다.

 

// src/events/events.gateway.ts

import { Logger } from '@nestjs/common';
import {
  ConnectedSocket,
  MessageBody,
  OnGatewayConnection,
  OnGatewayDisconnect,
  OnGatewayInit,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
} from '@nestjs/websockets';
import { Namespace, Socket } from 'socket.io';


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


let createdRooms: string[] = [];


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


  @WebSocketServer() nsp: Namespace;


  afterInit() {
    this.nsp.adapter.on('delete-room', (room) => {
      const deletedRoom = createdRooms.find(
        (createdRoom) => createdRoom === room,
      );
      if (!deletedRoom) return;


      this.nsp.emit('delete-room', deletedRoom);
      createdRooms = createdRooms.filter(
        (createdRoom) => createdRoom !== deletedRoom,
      ); // 유저가 생성한 room 목록 중에 삭제되는 room 있으면 제거
    });


    this.logger.log('웹소켓 서버 초기화 ✅');
  }


  handleConnection(@ConnectedSocket() socket: Socket) {
    this.logger.log(`${socket.id} 소켓 연결`);
  }


  handleDisconnect(@ConnectedSocket() socket: Socket) {
    this.logger.log(`${socket.id} 소켓 연결 해제 ❌`);
  }


  @SubscribeMessage('message')
  handleMessage(
    @ConnectedSocket() socket: Socket,
    @MessageBody() { roomName, message }: MessagePayload,
  ) {
    socket.broadcast
      .to(roomName)
      .emit('message', { username: socket.id, message });


    return { username: socket.id, message };
  }


  @SubscribeMessage('room-list')
  handleRoomList() {
    return createdRooms;
  }


  @SubscribeMessage('create-room')
  handleCreateRoom(
    @ConnectedSocket() socket: Socket,
    @MessageBody() roomName: string,
  ) {
    const exists = createdRooms.find((createdRoom) => createdRoom === roomName);
    if (exists) {
      return { success: false, payload: `${roomName} 방이 이미 존재합니다.` };
    }


    socket.join(roomName); // 기존에 없던 room으로 join하면 room이 생성됨
    createdRooms.push(roomName); // 유저가 생성한 room 목록에 추가
    this.nsp.emit('create-room', roomName); // 대기실 방 생성


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


  @SubscribeMessage('join-room')
  handleJoinRoom(
    @ConnectedSocket() socket: Socket,
    @MessageBody() roomName: string,
  ) {
    socket.join(roomName); // join room
    socket.broadcast
      .to(roomName)
      .emit('message', { message: `${socket.id}가 들어왔습니다.` });


    return { success: true };
  }


  @SubscribeMessage('leave-room')
  handleLeaveRoom(
    @ConnectedSocket() socket: Socket,
    @MessageBody() roomName: string,
  ) {
    socket.leave(roomName); // leave room
    socket.broadcast
      .to(roomName)
      .emit('message', { message: `${socket.id}가 나갔습니다.` });


    return { success: true };
  }
}

 

이전 글에서는 다루지 않았었던 room을 다루면서 조금 더 채팅방스러운 예제를 만들었습니다. 그러나 실제로 적용하기에는 무리가 있습니다.

포스팅을 하기 전에 항상 예제를 만들어보는데, 만드는 중에 내용이 너무나 방대해지는 것을 느꼈습니다.

socket.io에 초점을 맞춰서 내용을 전달하고자 했기 때문에 최소한의 기능만을 담아서 예제를 구성하였습니다.

이로써 웹소켓을 이용한 실시간 채팅 만들기 예제를 마치겠습니다.

 

잘못된 내용이나 궁금한 내용이 있으면 댓글을 남겨주세요.

웹소켓 - 실시간 채팅(2) (Socket.io, React, NestJS)