[Socket.io] 클라이언트의 실시간 채팅 구현기

https://socket.io/docs/v4/tutorial/introduction

왜 Socket.io인가

기본적으로 WS 모듈과 비교를 해보았을때, room이라는 기능으로 채팅방을 분리하거나, broadcast로 연결된 전체 클라이언트에게 데이터를 보내는 기능들을 더 제공해준다.

또한 WS의 경우 오랜된 브라우저의 경우 지원하지 않는 경우가 있다고 한다. Socket.io는 WS를 기반으로 만들어졌지만 지원이 되지 않는 경우 폴백 처리가 이뤄져 호환성이 더 좋다.

Socket.io는 채팅방에 특화된 라이브러리라는 점에서 각 스트리밍 채널의 다양한 타입의 채팅을 처리해주기에 적절하다고 생각되었다.

소켓 통신과 관련된 node:net과 WS는 이전에 사용해보기도 했기에 Socket.io를 잘 활용해보는 것이 생산성이나 학습 측면에서 좋은 경험이 될 것이라 기대된다.

클라이언트 - 백 연결과정

https://socket.io/how-to/use-with-react

bash
yarn add socket.io-client

각 채팅방 별로 메세지를 분리해서 받는 기능 구현

서버

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

const app = express();
const server = http.createServer(app);
const io = new Server(server, {
  cors: {
    origin: "http://localhost:5173",
    methods: ["GET", "POST"],
  },
});

io.on("connection", (socket) => {
  console.log("A user connected:", socket.id);

  socket.on("joinRoom", (room) => {
    socket.join(room);
    console.log(`User ${socket.id} joined room ${room}`);
    socket
      .to(room)
      .emit("receiveMessage", `User ${socket.id} has joined room ${room}`);
  });

  // 클라이언트가 방을 떠날 때
  socket.on("leaveRoom", (room) => {
    socket.leave(room);
    console.log(`User ${socket.id} left room ${room}`);
  });

  socket.on("sendMessage", ({ room, message }) => {
    console.log(`Message received in room ${room}: ${message}`);
    io.to(room).emit("receiveMessage", message);
  });

  socket.on("disconnect", () => {
    console.log("User disconnected:", socket.id);
  });
});

server.listen(8080, () => {
  console.log("Server is running on http://localhost:8080");
});


클라이언트 (리액트)

javascript
import { useEffect, useState } from "react";
import "./App.css";
import io from "socket.io-client";

const socket = io("http://localhost:8080");

function App() {
  const [message, setMessage] = useState("");
  const [messages, setMessages] = useState<string[]>([]);
  const [room, setRoom] = useState("");
  const [currentRoom, setCurrentRoom] = useState(""); // 현재 참여 중인 방을 추적

  useEffect(() => {
    socket.on("receiveMessage", (newMessage) => {
      setMessages((prevMessages) => [...prevMessages, newMessage]);
    });

    return () => {
      socket.off("receiveMessage");
    };
  }, []);

  const joinRoom = () => {
    if (room.trim()) {
      // 기존 방이 있으면 나가기
      if (currentRoom) {
        socket.emit("leaveRoom", currentRoom);
      }

      // 새로운 방에 참여
      socket.emit("joinRoom", room);
      setCurrentRoom(room); // 현재 참여 중인 방 업데이트
      setMessages([]); // 방이 바뀔 때 메시지 초기화
    }
  };

  const sendMessage = () => {
    if (message.trim() && room) {
      socket.emit("sendMessage", { room, message });
      setMessage("");
    }
  };

  return (
    <div>
      <h1>Chat Room</h1>
      <div>
        <label>Select a room: </label>
        <select onChange={(e) => setRoom(e.target.value)}>
          <option value="">Choose...</option>
          <option value="1">Room 1</option>
          <option value="2">Room 2</option>
          <option value="3">Room 3</option>
        </select>
        <button onClick={joinRoom}>Join Room</button>
      </div>

      <div>
        {messages.map((msg, index) => (
          <p key={index}>{msg}</p>
        ))}
      </div>

      <input
        type="text"
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        placeholder="Type a message..."
      />
      <button onClick={sendMessage}>Send</button>
    </div>
  );
}

export default App;


치지직의 채팅 데이터

채팅 보내기

json
{
    "ver": "3", // 프로토콜 버전 (버전 정보)
    "cmd": 3101, // 명령 코드 (이벤트의 종류를 나타냄)
    "svcid": "game", // 서비스 ID (어떤 서비스에서 발생한 이벤트인지 식별)
    "cid": "N1UlG6", // 클라이언트 ID (이벤트가 발생한 클라이언트의 식별자)
    "sid": "VSZR_D50TQJXWM6J1XzLCGHsQikfVFjakLc6LxC5huenPdGXja4pb9Y0CxvLZwZ17wJkDGqcLsWxoGudv0L0Mg--", // 세션 ID (사용자 세션을 고유하게 식별하는 ID)
    "retry": false, // 재시도 여부 (해당 이벤트가 재시도된 것인지 여부)
    "bdy": { // 본문 데이터 (이벤트의 주요 내용)
        "msg": "ㅋㅋㅋㅋ", // 메시지 내용 (사용자가 보낸 텍스트)
        "msgTypeCode": 1, // 메시지 타입 코드 (메시지 유형을 나타내는 코드)
        "extras": "{\"chatType\":\"STREAMING\",\"osType\":\"PC\",\"extraToken\":\"Cc+YfCOlE4/0odC1UwFeEAfs++s/GzveFyfayz8pWzlbf1d1TKyF9t7bdHSrBwPjnRXlXfCxHlXsjD1TWN3jbQ==\",\"streamingChannelId\":\"19e3b97ca1bca954d1ac84cf6862e0dc\",\"emojis\":{}}", // 추가 정보 (JSON 형식으로 포함된 추가 데이터)
        "msgTime": 1731568963648 // 메시지 타임스탬프 (메시지가 발생한 시간, 밀리초 단위)
    },
    "tid": 3 // 트랜잭션 ID (이벤트를 구별하기 위한 고유 식별자)
}

채팅 받기

json
{
  "svcid": "game",  // 서비스 ID (예: game, 채팅 서비스 등)
  "ver": "1",  // 버전 (서비스의 버전 정보)
  "bdy": [  // 메시지 본문(body) 배열
    {
      "svcid": "game",  // 서비스 ID (예: game)
      "cid": "N1V4Pr",  // 채팅 세션 ID 또는 메시지의 고유 ID
      "mbrCnt": 2109,  // 현재 채팅방에 참여한 사용자 수
      "uid": "07c7a5dbffe7fb901cceb68a9a731cd1",  // 사용자 고유 ID
      "profile": "{  // 사용자 프로필 정보 (JSON 문자열로 저장됨)
        \"userIdHash\": \"07c7a5dbffe7fb901cceb68a9a731cd1\",  // 사용자 ID 해시 값
        \"nickname\": \"USTARD\",  // 사용자 닉네임
        \"profileImageUrl\": \"\",  // 사용자 프로필 이미지 URL (없을 경우 빈 문자열)
        \"userRoleCode\": \"common_user\",  // 사용자 역할 코드 (예: common_user, 관리자 등)
        \"badge\": null,  // 사용자 배지 정보 (없을 경우 null)
        \"title\": null,  // 사용자 제목 (없을 경우 null)
        \"verifiedMark\": false,  // 사용자 인증 여부 (false: 인증되지 않음)
        \"activityBadges\": [],  // 활동 배지 정보 (없을 경우 빈 배열)
        \"streamingProperty\": {  // 스트리밍 관련 사용자 정보
          \"nicknameColor\": {  // 닉네임 색상 정보
            \"colorCode\": \"CC000\"  // 색상 코드
          },
          \"activatedAchievementBadgeIds\": []  // 활성화된 업적 배지 ID들
        }
      }",
      "msg": "잘생겼네",  // 채팅 메시지 내용
      "msgTypeCode": 1,  // 메시지 유형 코드 (예: 1은 일반 텍스트 메시지, 다른 값은 다른 유형)
      "msgStatusType": "NORMAL",  // 메시지 상태 (예: NORMAL은 일반 메시지, 삭제된 메시지 등)
      "extras": "{  // 메시지 관련 추가 정보 (JSON 문자열로 저장됨)
        \"chatType\": \"STREAMING\",  // 채팅 유형 (예: 스트리밍 채팅)
        \"osType\": \"PC\",  // 운영체제 유형 (예: PC, 모바일 등)
        \"extraToken\": \"HK+d/vpahmIA2m5YJnqtMS/kqPaXti0yEE17mLZGXxyaIy6MzZbzzaf5GJUaavoqq2RSjwfP0d5dp4WgLeAjxg==\",  // 추가 인증 토큰 또는 데이터
        \"streamingChannelId\": \"8a59b34b46271960c1bf172bb0fac758\",  // 스트리밍 채널 ID
        \"emojis\": {}  // 이모지 정보 (없을 경우 빈 객체)
      }",
      "ctime": 1731568455198,  // 메시지 생성 시간 (타임스탬프, 밀리초 단위)
      "utime": 1731568455198,  // 메시지 마지막 수정 시간 (타임스탬프, 밀리초 단위)
      "msgTid": null,  // 메시지의 고유 트랜잭션 ID (없을 경우 null)
      "msgTime": 1731568455198  // 메시지 전송 시간 (타임스탬프, 밀리초 단위)
    }
  ],
  "cmd": 93101,  // 명령 코드 (예: 메시지 전송 명령)
  "tid": "38",  // 트랜잭션 ID (이 메시지가 속한 트랜잭션의 고유 ID)
  "cid": "N1V4Pr"  // 채팅 세션 ID 또는 메시지의 고유 ID
}


클라이언트가 필요한 데이터

  • cid : 채팅 세션 ID
  • streamingChannelId: 스트리밍 채널 ID
  • bdy : 메세지 본문
    • profile
      • uid(userIdHash): 사용자 고유 ID
      • userRoleCode : 어떻게 호스트랑 클라이언트의 채팅을 분별할지 논의 필요
      • nickname : 사용자 닉네임
      • nicknameColor : 사용자 닉네임 색상
    • msgTypeCode: 메세지 타입 (우리의 경우에는 일반, 질문, 공지로?)
    • msgStatusType: 메세지 상태 (질문 채팅의 경우에는 미답변, 답변완료 이렇게?)
    • ctime   msgTime: 메세지 생성 시간 (만약 다시보기에서 채팅 보여주려면 필요)
  • cmd: 명령코드
    • 지피티 왈. 채팅 시스템에서 흔히 사용되는 다른 cmd 값들:
      • 1001: 채팅방 입장
      • 1002: 채팅방 퇴장
      • 2001: 메시지 전송
      • 2002: 메시지 삭제

클라이언트 설계

채팅 컴포넌트(ChatRoom)에서 소켓 연결을 설정하는 것이 적절하다고 판단.

link_preview

활용방식 참고

  1. 연결될 소켓 url을 구하고 (엔드포인트 + 채널 번호)
  2. createSocket으로 클라이언트 소켓 생성
  3. Socket.connect
  4. emit으로 지정된 cmd 전송 (처음 진입시 JOIN_ROOM과 같은 명령어)
  5. 컴포넌트 state로 socket set
  6. input 컴포넌트에는 socket 객체도 전달

전역상태로 socket을 가지게 하면 안되나?

없거나 에러가 날때만 socket 재생성 및 conect 되도록..

고려해야할 부분

  1. 채팅창 닫을시에 chatroom 언마운트가 되면 안됨. 연결은 유지되어야한다.
  2. 공지가 오면 바로 보여줘야함
  3. 질문 탭은 어떻게 분리? 걍 클라이언트에서만??
    1. 가져온 메세지 리스트에서 질문 채팅만 필터링하고 거기서 답변미완료 질문을 또 필터링? 과정이 너무 길다
    2. 차라리 이벤트 분리를 해서 채팅과 질문탭이 따로 관리되도록하면 더 좋을 것 같기도…………..
  4. 결론 : 이벤트 설계만 잘해두면 생각보다는 덜 어려운듯

메인 페이지 스트리밍 띄워주기는 SSE가 가장 낫지않을까?

0image.png

암튼 데모는 완료~