이번 포스팅에서는 Socket.io의
Room
개념에 대해 알아보고 간단한 예제를 통해서Room
을 어떻게 사용하는지 알아보자.
Room
은 Namespace
하위에서 함께 결합 된 소켓 그룹이다.
여기서 "함께 결합 된 소켓"그룹은 어떤 뜻일까?
카카오톡을 예로 들면 그룹 채팅을 예로 들 수 있다.
그룹 채팅에서 전송한 메세지는 해당 채팅방에 속한 다른 사용자한테만 메세지를 보내고 해당 방에 있지 않은 사용자한테는 메세지를 전송하지 않는다.
즉, 여기서 Room
은 연결된 모든 소켓에게 송신하는 대신 특정 클라이언트 그룹에게만 메세지를 송,수신하는 것이다.
또한, 카카오톡도 하나의 그룹 채팅이 아닌 여러 그룹 채팅에 참여할 수 있듯, 소켓도 하나의 Room
이 아닌 여러 Room
에 들어갈 수 있다.
카카오톡에서 그룹 채팅을 들어가거나 나올 수 있듯 Room
에도 해당 소켓그룹에 들어가거나 나올 수 있는 join
, leave
메서드와 특정 Room
에만 메세지를 보낼 수 있는 to
메서드가 존재한다.
여기서 주의해야 할 점은 `Room`은 특정 `Namespace`내에서만 유효하다. 즉 서로 다른 `Namespace`에서 같은 이름을 가진 `Room`은 동일한 `Room`이 아닌것이다.
join
메서드는 특정 Room
에 들어갈 수 있는 메서드이다.
join
메서드를 통해 추가된 소켓은 해당 Room
에 속한 사용자들에게만 메세지를 보낼 수 있고, 해당 Room
에 있는 사용자들에게만 이벤트를 받을 수 있다.
socket.join("room1");
join
메서드의 인자로 들어가려는 Room
의 이름을 정의하면 된다.
leave
메서드는 특정 Room
에서 나올 수 있는 메서드이다.
leave
메서드를 통해 소켓은 특정 Room
에서 나올 수 있고 해당 Room
에서 나온다면 더 이상 해당 방에서만 유효한 이벤트를 받을 수 없다.
socket.leave("room1");
leave
메서드의 인자로 나오려는 Room
의 이름을 정의하면 된다.
to
메서드는 특정 Room
에만 이벤트를 보낼 수 있는 메서드이다.
to
메서드를 통해 소켓은 특정 Room
에만 이벤트를 보낼 수 있고 해당 이벤트는 해당 Room
에만 전달되게 된다.
socket.to("room1").emit("message",{message : "send message"});
to
메서드의 인자로 이벤트를 전송하려는 Room
의 이름을 정의하고 to
메서드 뒤에 emit
메서드를 통해서 이벤트를 전송하면 된다.
여기까지 Room
을 위한 메서드들을 알아봤고 간단한 예제 코드를 통해서 사용법을 알아보자.
npm i socket.io
, @types/node
, express
, cors
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"));
npm i socket.io-client
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>
);
}
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>
);
}
import { io, Socket } from "socket.io-client";
export const secondNamespace: Socket = io(
"ws://localhost:5000/secondNamespace"
);
"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>
);
}
"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>
);
}
위 실행결과를 보면 두 클라이언트가 같은 채팅방(Room
)에 있어야 이벤트를 송,수신 하는걸 확인할 수 있다.
서로 다른 채팅방(Room
)에 있을 경우 이벤트를 서로 송,수신 하지 못하는걸 확인할 수 있다.
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
메서드를 실행하여 해당 유저를 입,퇴장 시킨다.
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이벤트를 전달한다.
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
에서 퇴장시키도록 한다.
Room
개념은 Namespace
에서 소켓을 그룹화 하여 관리하기 위한 개념이다. 즉, 특정 그룹에 있는 소켓들에게만 이벤트를 보내기 위한 하나의 그룹인 것이다.
Room
에 입장시키거나 퇴장시키기 위한 join
, leave
메서드, 해당 Room
에만 이벤트를 보내기 위한 to
메서드를 사용하여 특정 Room
에만 있는 유저들에게만 소켓 이벤트를 송,수신 할 수 있다.