본문 바로가기
react

[react, express , socket.io] 채팅방 구현해보기

by HomieKim 2023. 7. 14.

지난번에 socket.io의 개념에 대해서 알아보았습니다. 

 

2023.06.29 - [CS] - socket.io 딥다이브 - 개념 과 이해

 

socket.io 딥다이브 - 개념 과 이해

최근 이직을 위해 면접을 보러 다니는 중에 웹소켓에 관한 질문을 받은 적이 있다. socket.io를 이용해서 실시간 채팅 서비스를 만들었고 그 과정에서 웹소켓에 관한 공부를 진행한적이 있는데, 막

hoime.tistory.com

개념을 알고있으면 공식문서에 example이 워낙 잘 되어있어서 구현은 어렵지 않습니다. 

socket.io 는 채팅에 최적화된 라이브러리로 사용하는 사람들은 대부분 실시간 채팅방을 구현하기위해 사용할 것입니다.

나도 이전에 프로젝트 했을때 여러 채팅방 마다 소켓 연결을 관리해야해서 라이브러리에서 제공하는 room, 브로드캐스트 등의 추가적인 기능을 활용하기위해 socket.io를 사용했었던 것 같다. 

 

공식문서에 react를 활용한 예제가 잘나와 있지만, 아쉬운 점은 room 별로 관리하는 예제는 공식문서에 없어
간단하게 기능만 구현한 예제를 작성해 보려고 합니다 ㅎ

내 코드 보다 좋은 방법이 있을 순 있지만... socket.io 처음 보는 분들한테는 도움이 될 수 있을 겁니다 (아마도?!)

 

Demo


 

✔️ 기본 활용법

  • socket server 초기화 하기

socket.io 에서 제공하는 Server 클래스를 통해 소켓 인스턴스를 생성하여 서버를 초기화 해줍니다. 
socket.io 에서 제공하는 메서드들은 노드 이벤트 핸들러 방식으로 사용 됩니다.

생성된 인스턴스에서 on 메서드를 사용하여 connection을 생성할 수 있습니다. 

다음은 공식문서에서 제공하는 express 설정 코드입니다.

const express = require("express");
const { createServer } = require("http");
const { Server } = require("socket.io");

const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, { /* options */ });

io.on("connection", (socket) => {
  // ...
});

httpServer.listen(3000);
정리
io => Server 인스턴스로 on 메서드를 사용하여 커넥션 생성
on 메서드의 콜백으로 socket 인스턴스 활용 => 여기서 통신 이벤트를 등록하여 사용

 

  • socket instance 로 클라이언트와 통신

socket 인스턴스에서 제공하는 메서드를 사용하여 클라이언트와 통신하는 로직을 작성
공식문서 설명은 다음과 같다

A Socket is the fundamental class for interacting with the client.
It inherits all the methods of the Node.js EventEmitter, like emit, on, once or removeListener.

그러니까 쉽게 말해서 클라이언트와 상호작용하는 기능이 있는 인스턴스로 가장 핵심적인 emit. on 등의 메서드를 지니는 것

 

  • emit : 서버 또는 클라이언트로 특정 이벤트를 보내는 메서드
  • on : emit이랑 반대로 서버 또는 클라이언트로 부터 들어오는 이벤트 리스너를 등록 하는 것

이렇게 생각하면 편합니다. 

io.on("connection" (socket) => {
	// io는 Server의 인스턴스니까 해당 연결 자체에 모든 클라이언트에 보냄
	io.emit('some event', data)
    
    	// data를 보낸 클라이언트에만 data를 보냄
  	socket.emit('some event', data)
    
    	// 클라이언트로 부터 들어온 이벤트, data를 받음
   	socket.on('some event', (data) =>{})

})

그림으로 보면 더 이해가 쉬움

https://socket.io/images/bidirectional-communication-socket.png

 

 

  • room 설정

socket.io 라이브러리 자체가 실시간 채팅에 최적화 된 라이브러리 이고,

보통 채팅서비스를 구현한다고 하면 하나의 1:1 채팅이 아니라 여러 사람이 참여하는 채팅방 을 구현하고 싶을 것이다.

이를 위해 socket.io 에서 room 기능을 제공

제일 쉽게 말해서 카카오톡 단톡방 생각하면 쉽다.

한 단톡방 (room) 안에 여러 사람(socket)의 통신이 연결되어있고 단톡방 별로 각각 다른 연결이 진행됨

 

자세한 내용은 문서를 보는 것 이 빠르다

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

 

Rooms | Socket.IO

A room is an arbitrary channel that sockets can join and leave. It can be used to broadcast events to a subset of clients:

socket.io

https://socket.io/docs/v4/namespaces(namespace는 room의 상위 개념)

 

Namespaces | Socket.IO

A Namespace is a communication channel that allows you to split the logic of your application over a single shared connection (also called "multiplexing").

socket.io

주요 메서드

  • join : room에 입장하는 메서드
  • leave : room에서 나가는 메서드
  • to : 특정 room에게만 이벤트 등록
io.on("connection" (socket) => {
    const roomIdx = 'some room idx'
    
    // connection이 발생할 때 room에 입장합니다.
    socket.join(roomIdx)
    
    // 특정 room에만 메세지를 보냅니다.
    socket.to(roomIdx).emit('message', msg)

})

✔️ 서버 구축

실제 채팅 서버를 구축하기 위해선 해야할게 정말 많지만,,
클라이언트 사이드에서 메세지 주고 받는 예제를 작성하는게 목표기 때문에
간단하게 socket event만 핸들링할 수 있는 서버를 띄웠습니다. 프로젝트를 초기화 후 (npm init -y)

필요한 라이브러리 단, 두개 expresssocket.io 를 설치해 줍니다.

npm i express socket.io

 

app.js

const express = require("express");
const app = express();
const http = require("http");
const server = http.createServer(app);
const { Server } = require("socket.io");

const io = new Server(server, {
  cors: {
    origin: true,
  },
});
// chat namespace 사용
const chat = io.of("/chat");

chat.on("connection", (socket) => {
  console.log("chat namespace connected");

  const roomIdx = socket.handshake.query.roomIdx;
  socket.join(roomIdx);
  socket.to(roomIdx).emit("join", roomIdx + " chatroom join");
  socket.on("disconnect", () => {
    console.log("user disconnected");
    socket.leave(roomIdx);
  });

  // on으로 이벤트 받고 emit으로 보내줌
  socket.on("send", (msg) => {
    socket.emit("receive", msg);
    socket.to(roomIdx).emit("receive", msg);
  });
});

server.listen(3010, () => {
  console.log("server listen 3010 port...");
});

✔️ 클라이언트 구현

소켓 통신에 관련된 코어한 로직을 간단히 설명드리 겠습니다.

먼저 클라이언트 에서는 socket.io-client 패키지를 설치 해야합니다.

npm i socket.io-client

 

useSocket.ts

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

// back url 은 chat namespace 사용
const back_url = "http://localhost:3010/chat";
const sockets: { [key: string]: Socket } = {};


const useSocket = (idx: string) => {

  // socket 유지를 위해 state에 socket 인스턴스를 담아서 사용함
  const [socket, setSocket] = useState<Socket | undefined>(undefined);
  
  // useEffect로 소켓 연결시 개발 모드에서 useEffect가 2번 실행 되기 때문에 socket이 없을 경우만 연결 있는 경우 state로 업데이트
  useEffect(() => {
    if (!sockets[idx]) {
      sockets[idx] = io(back_url, {
        autoConnect: false,
        transports: ["websocket"],
        query: { roomIdx: idx },
      });
    }
    if (sockets[idx]) {
      sockets[idx].connect();
      setSocket(sockets[idx]);
    }

    return () => {
      // 언마운트 시에 socket disconnect
      if (sockets[idx]) {
        sockets[idx].disconnect();
        delete sockets[idx];
      }
    };
    /* eslint-disable-next-line */
  }, []);

  return { socket };
};

export default useSocket;
  • 소켓 연결에 관련한 로직들을 커스텀 훅으로 작성하였습니다. 
  • 사실 autoConnect를 true로 두고 사용해도 무방합니다만, 리렌더나 언마운트 시점 등 명시적으로 socket을 핸들링하는 것이 더 안정적이라고 생각하여 effect를 사용하여 소켓 연결상태를 초기화 하였습니다.
  • socket.io는 처음에 연결을 위해 polling 방식으로 연결을 생성한 후 웹소켓이 사용 가능한 환경이면 웹소켓 프로토콜로 업그레이드 하는 방식으로 동작합니다.
  • 소켓 인스턴스를 초기화 할 때 옵션값으로 transports: ["websocket"] 을 주면 처음부터 웹소켓 방식으로 동작 합니다. 웹소켓 통신이 실패하거나 호환되지 않는 환경이라면 long fallback방식으로 대체됩니다.
  • 언마운트 시 socket 연결을 disconnect 해줍니다.

 

🧐 어떻게 사용?

ChatRoom.tsx

export interface Chat {
  userIdx: string;
  message: string;
}

const ChatRoom = () => {
  const [chatList, setChatList] = useState<Chat[]>([]);
  
  const { roomIdx } = useParams();
  const { socket } = useSocket(roomIdx as string);

  const inputRef = useRef<HTMLInputElement | null>(null);

  const submitHandler = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    // 타입 가드
    if (!socket || !inputRef.current) return;
    // submit 시 서버로 메세지 전송
    socket.emit("send", { userIdx, message: inputRef.current.value });
    inputRef.current.value = "";
  };

  useEffect(() => {
    if (!socket) return;
    const socketEvent = (data: Chat) => {
      setChatList((prev) => [...prev, data]);
    };
    socket.on("receive", socketEvent);
    return () => {
      socket.off("receive", socketEvent);
    };
    // useSocket hook 에서 effect실행 되면서 socket 초기화 되는데 setState로 socket이 업데이트 되면 해당 effect가 실행되면서 receive 이벤트 리스너를 등록
  }, [socket]);

  return ...
};

export default ChatRoom;
  • useSocket 훅을 통해 소켓 인스턴스를 가져오고 useEffect를 사용해 서버와 소켓 통신을 위한 이벤트 리스너를 등록해 줍니다.
  • 커스텀 훅에서 state를 통해 소켓을 저장하고 있으므로, 커스텀 훅에서 useEffect가 실행 되고 sockets 객체에 io가 초기화 되고 setState가 실행되면 ChatRoom.tsx에 이펙트가 실행되면서 이벤트 리스너가 등록되는 로직
  • 최종적으로 메세지를 보내거나 드러오면 chatList에 데이터가 추가 되면서 return 문에서는 chatList를 map을 통해 돌면서 화면에 렌더링해 주는 방식입니다.
  • 간단하게 구현만 한 상태인데 성능에 조금 신경쓴다면 windowing을 적용해주는 것이 좋습니다.
    결국에 브라우저에서 화면을 렌더링하는데 chat이 쌓일 수록 무한대로 dom요소 가쌓이기 때문에 결국 무한스크롤을 적용한다해도 수 많은 dom을 브라우저에서 렌더링하는 건 부담이 있을 수 있습니다.
  • 같은 논리로 메모제이션도 적용해주면 좋지 않을까 생각됩니다.

리팩토링된 클라이언트 코드를 추후 업로드 하도록 하겠습니다!

✔️ 전체 코드

https://github.com/homiekim/chat-example

 

GitHub - homiekim/chat-example: react socket.io example

react socket.io example. Contribute to homiekim/chat-example development by creating an account on GitHub.

github.com

 

 


참고한 글

https://jake-seo-dev.tistory.com/251t

https://socket.io/

 

 

댓글