[WebSocket] Socket.io의 Room < 5 >

exceed_96·2024년 8월 12일
0

WebSocket

목록 보기
4/5
post-thumbnail

이번 포스팅에서는 Socket.io의 Room개념에 대해 알아보고 간단한 예제를 통해서 Room을 어떻게 사용하는지 알아보자.



1. Room이란?

RoomNamespace하위에서 함께 결합 된 소켓 그룹이다.

여기서 "함께 결합 된 소켓"그룹은 어떤 뜻일까?


카카오톡을 예로 들면 그룹 채팅을 예로 들 수 있다.

그룹 채팅에서 전송한 메세지는 해당 채팅방에 속한 다른 사용자한테만 메세지를 보내고 해당 방에 있지 않은 사용자한테는 메세지를 전송하지 않는다.

즉, 여기서 Room은 연결된 모든 소켓에게 송신하는 대신 특정 클라이언트 그룹에게만 메세지를 송,수신하는 것이다.

또한, 카카오톡도 하나의 그룹 채팅이 아닌 여러 그룹 채팅에 참여할 수 있듯, 소켓도 하나의 Room이 아닌 여러 Room에 들어갈 수 있다.


카카오톡에서 그룹 채팅을 들어가거나 나올 수 있듯 Room에도 해당 소켓그룹에 들어가거나 나올 수 있는 join, leave메서드와 특정 Room에만 메세지를 보낼 수 있는 to메서드가 존재한다.

여기서 주의해야 할 점은 `Room`은 특정 `Namespace`내에서만 유효하다. 즉 서로 다른 `Namespace`에서 같은 이름을 가진 `Room`은 동일한 `Room`이 아닌것이다.


2. join, leave, to

2.1 join

join메서드는 특정 Room에 들어갈 수 있는 메서드이다.

join메서드를 통해 추가된 소켓은 해당 Room에 속한 사용자들에게만 메세지를 보낼 수 있고, 해당 Room에 있는 사용자들에게만 이벤트를 받을 수 있다.

socket.join("room1");

join메서드의 인자로 들어가려는 Room의 이름을 정의하면 된다.



2.2 leave

leave메서드는 특정 Room에서 나올 수 있는 메서드이다.

leave메서드를 통해 소켓은 특정 Room에서 나올 수 있고 해당 Room에서 나온다면 더 이상 해당 방에서만 유효한 이벤트를 받을 수 없다.

socket.leave("room1");

leave메서드의 인자로 나오려는 Room의 이름을 정의하면 된다.



2.3 to

to메서드는 특정 Room에만 이벤트를 보낼 수 있는 메서드이다.

to메서드를 통해 소켓은 특정 Room에만 이벤트를 보낼 수 있고 해당 이벤트는 해당 Room에만 전달되게 된다.

socket.to("room1").emit("message",{message : "send message"});

to메서드의 인자로 이벤트를 전송하려는 Room의 이름을 정의하고 to메서드 뒤에 emit메서드를 통해서 이벤트를 전송하면 된다.

여기까지 Room을 위한 메서드들을 알아봤고 간단한 예제 코드를 통해서 사용법을 알아보자.



3. Room 예제 코드

3.1 서버 (Express)

설치 패키지

npm i socket.io, @types/node, express, cors


백엔드 디렉토리 구조


src/server.ts

import express, { Application } from "express";
import cors from "cors";
import http from "http";
import { Server, Socket } from "socket.io";

const app: Application = express();
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

const server = http.createServer(app);

const io = new Server(server, {
  cors: {
    origin: "*",
  },
});

const rooms = [
  { title: "테스트방1", id: "1" },
  { title: "테스트방2", id: "2" },
  { title: "테스트방3", id: "3" },
  { title: "테스트방4", id: "4" },
  { title: "테스트방5", id: "5" },
  { title: "테스트방6", id: "6" },
  { title: "테스트방7", id: "7" },
  { title: "테스트방8", id: "8" },
  { title: "테스트방9", id: "9" },
  { title: "테스트방10", id: "10" },
  { title: "테스트방11", id: "11" },
  { title: "테스트방12", id: "12" },
  { title: "테스트방13", id: "13" },
  { title: "테스트방14", id: "14" },
  { title: "테스트방15", id: "15" },
  { title: "테스트방16", id: "16" },
  { title: "테스트방17", id: "17" },
  { title: "테스트방18", id: "18" },
];

const secondNamespace = io.of("/secondNamespace");

secondNamespace.on("connection", (socket: Socket) => {
  socket.on("joinRoom", (data: { room: string }) => {
    const joinRoom = rooms.find((room) => room.id === data.room);
    const joinMessage = { message: "새로운 유저가 방에 참가했어요!" };
    socket.join(joinRoom.id);
    socket.to(joinRoom.id).emit("message", joinMessage);
  });

  socket.on("leaveRoom", (data: { room: string }) => {						
    const leaveRoom = rooms.find((room) => room.id === data.room);
    const leaveMessage = { message: "유저가 방을 나갔어요!" };
    socket.leave(leaveRoom.id);
    socket.to(leaveRoom.id).emit("message", leaveMessage);
  });

  socket.on("message", (data: { message: string; id: string }) => {
    secondNamespace.to(data.id).emit("message", { message: data.message });	
  });
});

app.get("/chatlist", (req, res) => {
  res.status(200).json({ rooms: rooms });
});

server.listen(5000, () => console.log("Server running on port 5000"));


3.2 클라이언트 (NextJS)

설치 패키지

npm i socket.io-client


프론트 디렉토리 구조


src/app/layout.tsx

import "./globals.css";

export const metadata = {
  title: "Socket",
  description: "Socket study",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="h-svh flex justify-center items-center w-full">
        {children}
      </body>
    </html>
  );
}

src/app/page.tsx

import ChatRoom from "@/components/ChatRoom/ChatRoom";

export default function page() {
  return (
    <div className="flex w-full h-full justify-center items-center gap-10">
      <ChatRoom />
    </div>
  );
}

src/app/utils/SocketConfig.ts

import { io, Socket } from "socket.io-client";

export const secondNamespace: Socket = io(
  "ws://localhost:5000/secondNamespace"
);

src/app/components/ChatRoom/ChatRoom.tsx

"use client";

import { useState, useRef, useEffect, FormEvent } from "react";
import { secondNamespace } from "@/util/SocketConfig";
import RoomList from "./RoomList";

type TChatMessage = {
  message: string;
};

export default function ChatRoom() {
  const [chatLog, setChatLog] = useState<TChatMessage[]>([]);
  const [currentRoom, setCurrentRoom] = useState<string | null>(null);
  const secondMessageRef = useRef<HTMLInputElement>(null);
  const messageEndRef = useRef<HTMLLIElement>(null);

  const submitSecondMessageHandler = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (secondMessageRef.current?.value) {
      secondNamespace.emit("message", {
        message: secondMessageRef.current.value,
        id: currentRoom,
      });
      secondMessageRef.current.value = "";
    }
  };

  const getSecondMessagesSocketHandler = (data: TChatMessage) => {
    setChatLog((prevChatLog) => [...prevChatLog, data]);
  };

  const leaveRoom = (roomNumber: string) => {
    secondNamespace.emit("leaveRoom", { room: roomNumber });
  };

  useEffect(() => {
    secondNamespace.on("message", getSecondMessagesSocketHandler);
    messageEndRef.current?.scrollIntoView({ behavior: "smooth" });
    return () => {
      secondNamespace.off("message", getSecondMessagesSocketHandler);
    };
  }, [chatLog]);

  return (
    <div className="w-3/5 sm:w-2/5 h-3/5 border-[2px] border-black flex flex-col justify-between items-center bg-yellow-200">
      <div className="text-center py-2 text-2xl text-black bg-green-600 w-full flex justify-center items-center gap-2">
        <span>{currentRoom ? `테스트방 ${currentRoom}` : "Chatting"}</span>
        {currentRoom && (
          <button
            className="bg-[#fa7e0b] p-1 text-black text-xl hover:scale-105 duration-300 rounded-xl"
            onClick={() => {
              leaveRoom(currentRoom);
              setCurrentRoom(null);
              setChatLog([]);
            }}
          >
            나가기
          </button>
        )}
      </div>
      <ul className="w-full h-full flex flex-col gap-2 px-4 py-2 overflow-y-auto">
        {!currentRoom && (
          <RoomList setCurrentRoom={setCurrentRoom} currentRoom={currentRoom} />
        )}
        {currentRoom &&
          chatLog.map((message, index) => (
            <li
              className="bg-white p-2 break-words whitespace-pre-line w-fit max-w-[60%] rounded-xl shadow-md"
              key={index}
            >
              {message.message}
            </li>
          ))}
        <li ref={messageEndRef} />
      </ul>
      <form
        className="w-full flex border-t-[1px] border-t-black"
        onSubmit={submitSecondMessageHandler}
      >
        <input
          type="text"
          className="w-5/6 outline-none py-1 px-2 bg-green-900 text-xl text-white"
          ref={secondMessageRef}
          disabled={!currentRoom}
        />
        <button
          type="submit"
          className="bg-white w-1/6"
          disabled={!currentRoom}
        >
          전송
        </button>
      </form>
    </div>
  );
}

src/app/components/ChatRoom/RoomList.tsx

"use client";

import { useState, useEffect, SetStateAction, Dispatch } from "react";
import { secondNamespace } from "@/util/SocketConfig";

type TRoomInfo = {
  title: string;
  id: string;
};

type TRoomListProps = {
  currentRoom: string | null;
  setCurrentRoom: Dispatch<SetStateAction<string | null>>;
};

export default function RoomList(props: TRoomListProps) {
  const [roomList, setRoomList] = useState<TRoomInfo[]>([]);

  const getChatListApiHandler = async () => {
    try {
      const result = await fetch("http://localhost:5000/chatlist");
      if (result.ok) {
        const data = await result.json();
        setRoomList(data.rooms);
      }
    } catch (error) {
      console.log(error);
    }
  };

  useEffect(() => {
    getChatListApiHandler();
  }, []);

  const joinRoom = (roomNumber: string) => {
    secondNamespace.emit("joinRoom", { room: roomNumber });
    props.setCurrentRoom(roomNumber);
  };

  return (
    <ul className="flex flex-col gap-2">
      {roomList &&
        roomList.map((room) => (
          <li
            key={room.id}
            className="text-black w-full text-center bg-[#fa7e0b] cursor-pointer hover:scale-105 duration-300 py-2 rounded-xl"
            onClick={() => {
              joinRoom(room.id);
            }}
          >
            {room.title}
          </li>
        ))}
    </ul>
  );
}


4. 예제 코드 실행 결과

위 실행결과를 보면 두 클라이언트가 같은 채팅방(Room)에 있어야 이벤트를 송,수신 하는걸 확인할 수 있다.

서로 다른 채팅방(Room)에 있을 경우 이벤트를 서로 송,수신 하지 못하는걸 확인할 수 있다.



5. 예제코드 뜯어보기

서버

src/server.ts

const rooms = [
  { title: "테스트방1", id: "1" },
  { title: "테스트방2", id: "2" },
  { title: "테스트방3", id: "3" },
  { title: "테스트방4", id: "4" },
  { title: "테스트방5", id: "5" },
  { title: "테스트방6", id: "6" },
  { title: "테스트방7", id: "7" },
  { title: "테스트방8", id: "8" },
  { title: "테스트방9", id: "9" },
  .....
];

const secondNamespace = io.of("/secondNamespace");

secondNamespace.on("connection", (socket: Socket) => {
  socket.on("joinRoom", (data: { room: string }) => {						//Room에 입장
    const joinRoom = rooms.find((room) => room.id === data.room);			//입장하려는 `Room`을 만들어진 방 배열을 순회하여 어떤 방인지 찾는다.
    const joinMessage = { message: "새로운 유저가 방에 참가했어요!" };
    socket.join(joinRoom.id);												//join메서드를 이용해 해당 Room에 입장
    socket.to(joinRoom.id).emit("message", joinMessage);					//기존에 있던 유저들한테 `to`메서드를 통해서 새로운 유저가 들어왔음을 알린다.
  });

  socket.on("leaveRoom", (data: { room: string }) => {						//Room 퇴장
    const leaveRoom = rooms.find((room) => room.id === data.room);			//퇴장하려는 Room을 만들어진 방 배열을 순회하여 어떤 방인지 찾는다.
    const leaveMessage = { message: "유저가 방을 나갔어요!" };					
    socket.leave(leaveRoom.id);												//leave메서드를 이용해 해당 Room에서 퇴장
    socket.to(leaveRoom.id).emit("message", leaveMessage);					//아직 Room에 남아있는 유저들한테 `to`메서드를 통해서 기존 유저가 퇴장했음을 알린다.
  });

  socket.on("message", (data: { message: string; id: string }) => {
    secondNamespace.to(data.id).emit("message", { message: data.message });	//to메서드를 통해서 "secondNamespace"네임스페이스에 있는 "data.id"를 가진 Room에 속해 있는 유저들한테만 이벤트를 전달
  });
});

app.get("/chatlist", (req, res) => {										//전체 채팅방 목록 조회 API
  res.status(200).json({ rooms: rooms });
});

위 코드를 보면 on(joinRoom, leaveRoom)메서드를 통해서 클라이언트가 Room에 들어가거나 나가는 경우의 이벤트를 먼저 받는걸 확인할 수 있다.

그 후, Room을 나가는지 혹은 들어오는지에 따라 join, leave메서드를 실행하여 해당 유저를 입,퇴장 시킨다.



클라이언트

src/app/components/ChatRoom/RoomList.tsx

const joinRoom = (roomNumber: string) => {
    secondNamespace.emit("joinRoom", { room: roomNumber });			//서버로 "나 이방에 들어갈거야~"라는 이벤트를 보내준다. 여기서 데이터로 보내는 값은 들어가려는 방의 id값을 보내준다.
    props.setCurrentRoom(roomNumber);								//현재 유저가 어떤방에 들어가 있는지 관리하기 위한 상태변수 set함수
  };

  return (
    <ul className="flex flex-col gap-2">
      {roomList &&
        roomList.map((room) => (
          <li
            key={room.id}
            className="text-black w-full text-center bg-[#fa7e0b] cursor-pointer hover:scale-105 duration-300 py-2 rounded-xl"
            onClick={() => {
              joinRoom(room.id);
            }}
          >
            {room.title}
          </li>
        ))}
    </ul>
  );

joinRoom함수를 통해서 특정 채팅방에 입장할 경우 서버로 joinRoom이벤트를 전달한다.



src/app/components/ChatRoom/ChatRoom.tsx

const leaveRoom = (roomNumber: string) => {
    secondNamespace.emit("leaveRoom", { room: roomNumber }); 				//채팅방을 나갈경우 "leaveRoom"이벤트를 전달하여 서버에서 `leave`메서드를 통해 해당 채팅방에서 클라이언트를 퇴장하게 한다.
  };

  useEffect(() => {
    secondNamespace.on("message", getSecondMessagesSocketHandler);
    messageEndRef.current?.scrollIntoView({ behavior: "smooth" });			//새로운 채팅내용이 채팅영역의 제일 아래에 생성될 때 현재 채팅영역 뷰를 제일 아래로 내려준다.
    return () => {
      secondNamespace.off("message", getSecondMessagesSocketHandler);
    };
  }, [chatLog]);

  return (
    <div className="w-3/5 sm:w-2/5 h-3/5 border-[2px] border-black flex flex-col justify-between items-center bg-yellow-200">
      <div className="text-center py-2 text-2xl text-black bg-green-600 w-full flex justify-center items-center gap-2">
        <span>{currentRoom ? `테스트방 ${currentRoom}` : "Chatting"}</span>
        {currentRoom && (
          <button
            className="bg-[#fa7e0b] p-1 text-black text-xl hover:scale-105 duration-300 rounded-xl"
            onClick={() => {
              leaveRoom(currentRoom);									//"leaveRoom"함수를 실행하여 서버에게 "나 현재 있는 채팅방에서 나갈거야~"라는 이벤트를 전달한다.
              setCurrentRoom(null);										//채팅방을 나갈 경우 현재 유저가 들어가 있는 방을 관리하는 상태변수도 null로 초기화
              setChatLog([]);											//현재 상태변수에 정의된 채팅방 메세지들 초기화
            }}
          >
            나가기
          </button>
        )}
      </div>
      <ul className="w-full h-full flex flex-col gap-2 px-4 py-2 overflow-y-auto">
        {!currentRoom && (
          <RoomList setCurrentRoom={setCurrentRoom} currentRoom={currentRoom} />
        )}
        {currentRoom &&
          chatLog.map((message, index) => (
            <li
              className="bg-white p-2 break-words whitespace-pre-line w-fit max-w-[60%] rounded-xl shadow-md"
              key={index}
            >
              {message.message}
            </li>
          ))}
        <li ref={messageEndRef} />										//채팅영역 뷰를 제일 아래로 내리기 위한 ref
      </ul>
  
 	  코드생략.......

위 "ChatRoom"컴포넌트에서는 leaveRoom함수를 통해서 채팅방에서 나가는 사용자를 해당 Room에서 퇴장시키도록 한다.



6. 마치며

Room개념은 Namespace에서 소켓을 그룹화 하여 관리하기 위한 개념이다. 즉, 특정 그룹에 있는 소켓들에게만 이벤트를 보내기 위한 하나의 그룹인 것이다.

Room에 입장시키거나 퇴장시키기 위한 join, leave메서드, 해당 Room에만 이벤트를 보내기 위한 to메서드를 사용하여 특정 Room에만 있는 유저들에게만 소켓 이벤트를 송,수신 할 수 있다.



7. Reference

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

profile
개발진행형

0개의 댓글