background
WebSocekt7분

STOMP+WebSocket

2025년 3월 20일

서론

프로젝트를 진행하면서 STOMP 기반 웹 소켓 연결을 쓸 일이 생기게 되었다. 당연하게도 우리 프로젝트는 Web 게임 기능을 구현하는 것이었기에 소켓은 반드시 필요했었다.

하지만 이전 프로젝트에서 STOMP 기반 웹 소켓을 써 본적도, 없었고 웹 소켓 연결이라 해봤자 API 통신(비트코인 차트 시세 보기 등등) 뿐 이었기에 서버와 통신 하는 웹 소켓은 처음이었다.

그래서 먼저 STOMP 와 웹 소켓에 대한 이해가 필요했기에 먼저 그 부분들을 학습을 해보았다.


STOMP

STOMP(Simple/Streaming Text Oriented Messaging Protocol) 의 약자로 이름에서 볼 수 있듯이 메시지 기반 프로토콜로 다양한 메시지 시스템 간의 상호 운용성을 제공하기 위해 개발 되었다.

특징

  1. 텍스트 기반 프로토콜:
    • STOMP는 텍스트 기반의 프로토콜로, 메시지 구조가 간단하고 이해하기 쉽다. ⇒ 디버깅과 개발을 용이하게 함
  2. 메시지 프레임 구조
    • STOMP 메시지는 프레임으로 구성되며, 각 프레임은 명령(command), 헤더(headers), 본문(body)로 이루어져 있다. 이러한 구조는 메시지의 유연성을 높인다.
  3. 다양한 메시징 패턴 지원
    • STOMP 는 Pub/Sub(발행/구독), P2P(점대점) 등 다양한 메시징 패턴을 지원하여, 다양한 애플리케이션 요구 사항 충족 가능
  4. 브로커 기반 아키텍쳐
    • STOMP 는 메시지 브로커를 통해 메시지를 전달함. 이는 메시지의 라우팅과 전달을 효율적으로 관리할 수 있게 해줌
  5. 플랫폼 독립성
    • STOMP는 다양한 프로그래밍 언어와 플랫폼에서 지원되므로, 다른 시스템 간의 통합이 용이함. 이는 다양한 환경에서의 사용을 가능하게 함
  6. 확장성
    • STOMP는 메시지 브로커를 통해 메시지를 전달하므로, 시스템의 확장성을 높일 수 있다. 이는 많은 수의 클라이언트가 동시에 연결되어 있는 환경에서 특히 유리함
  7. 보안
    • STOMP는 메시지 인증 및 권한 부여를 통해 보안을 강화할 수 있다. 이는 민감한 데이터 전송 시 중요한 요소임

💡 STOMP의 간략한 구조

image.png

image.png

동작 흐름

image.png

image.png


WebSocket

WebSocket은 웹 앱과 서버 간의 지속적인 연결을 제공하는 프로토콜임. 이를 통해 서버와 클라이언트 간에 양방향 통신이 가능해짐. HTTP 와는 달린, WebSocket 연결은 한 번 열린 후 계속 유지되므로, 서버나 클라이언트에서 언제든지 데이터를 전송할 수 있다는 것이 특징임 → 실시간 통신에 반드시 필요함


WebSocket의 등장 배경

초기의 인터넷 통신 방식은 HTTP 를 이용한 클라이언트(요청) - 서버(응답) 모델을 통해 진행 되었음

즉, 클라이언트에서 서버에 Request, 서버에서 클라이언트에 Response 하는 통신 방식을 따름

물론 HTTP 통신 만으로 대부분의 작업에서는 큰 문제가 없다. 실제로 웹 기술 중 가장 많이 쓰이는 기술이기도 하고 하지만, 실시간으로 데이터를 주고 받는 데는 한계점이 발생하게 된다.

요청과 응답이 있다는 의미는 클라이언트가 서버에게 요청하지 않는 이상 서버는 클라이언트에게 먼저 데이터를 보낼 수 없기 때문에 클라이언트에서 새로운 데이터가 있는 지 확인을 하기 위해서는 서버에 지속적으로 요청을 보낼 수 밖에 없다.

이는 서버에 트래픽을 불필요하게 증가시키고, 서버의 비용이 증가될 뿐 만 아니라, 요청 ↔ 응답 간의 지연시간으로 인해, 실시간 통신의 효율성을 저하시킬 수 있다.

그래서 등장한 게 웹 소켓


웹 소켓 이란?

웹 소켓은 TCP(Transmission Control Protocol)를 기반으로 한다.

TCP를 기반으로 한 웹 소켓은 신뢰성 있는 데이텅 전송을 보장하며, 메시지 경계를 존중하고, 순서가 보장된 양방향 통신을 제공할 수 있다.

HTTP와 다르게 클라이언트와 서버 간에 최초 연결이 이뤄지면, 이 연결을 통해 양방향 통신을 지속적으로 할 수 있다.

이때 데이터는 패킷(Packet) 형태로 전달되며, 전송은 연결 중단과 추가 HTTP 요청 없이 양방향으로 이뤄진다.


WebSocket + STOMP

그렇다면 우리가 해야 할 게임,채팅 로직에는 어떤 걸 쓰는게 옳을까? 정답은 둘 다 쓰는 것이었다.

장점:

  • WebSocket의 실시간 양방향 통신 기능과 STOMP의 메시징 프로토콜 기능을 결합하여, 실시간 메시징 애플리케이션을 구축할 수 있다.
  • STOMP는 WebSocket 위에서 동작하여, 메시지의 라우팅과 전달을 효율적으로 관리할 수 있다.
  • 다양한 플랫폼과 언어에서 지원되므로, 다양한 시스템 간의 통합이 용이하다.

프로젝트에 도입

이제 프로젝트에 어떻게 도입됐는 지 코드를 통해 살펴 보겠다.

💡 React 기반 코드이며, 서버는 이미 되어 있다고 가정

1. STOMP, WebSocket 연결 및 함수 정의

typescript
import { Client, IMessage, StompHeaders, StompSubscription } from "@stomp/stompjs";

/**
 * WebSocket 서버 연결 URL
 * 개발 환경에서는 로컬 서버 주소를 사용
 */
const SOCKET_URL = import.meta.env.VITE_APP_SOCKET_BASE_URL;

/**
 * 재연결 시도 간격 (밀리초)
 * 연결이 끊어졌을 때 5초 후에 재연결 시도
 */
const RECONNECT_DELAY = 5000;

/**
 * STOMP 클라이언트 생성 함수
 *
 * WebSocket 연결을 위한 STOMP 클라이언트를 초기화하고 설정합니다.
 * 이벤트 리스너와 연결 옵션을 설정하고 준비된 클라이언트를 반환합니다.
 *
 * @returns {Client} 설정이 완료된 STOMP 클라이언트 인스턴스
 */
export const createStompClient = (): Client => {
  // 로컬 스토리지에서 토큰 가져오기
  const token = localStorage.getItem("accessToken");

  // 클라이언트 인스턴스 생성 및 기본 설정
  const client = new Client({
    // WebSocket 팩토리 함수: 실제 WebSocket 연결 생성
    webSocketFactory: () => {
      console.log("WebSocket 연결 생성:", SOCKET_URL);
      return new WebSocket(SOCKET_URL);
    },
    // 연결 헤더 설정: 인증 토큰 포함
    connectHeaders: {
      Authorization: token ? `Bearer ${token}` : "",
    },
    // 디버그 메시지 처리 함수
    debug: (str) => {
      console.log("STOMP 디버그:", str);
    },
    // 재연결 설정: 연결 끊어진 후 재시도 간격
    reconnectDelay: RECONNECT_DELAY,
    // 하트비트 설정: 연결 유지를 위한 ping/pong 간격
    heartbeatIncoming: 10000, // 서버로부터 10초마다 하트비트 확인
    heartbeatOutgoing: 10000, // 서버로 10초마다 하트비트 전송
    // 연결 타임아웃: 30초 동안 연결 안되면 실패로 간주
    connectionTimeout: 30000,
  });

  // 연결 시작 전 호출되는 이벤트 핸들러
  client.beforeConnect = () => {
    console.log("STOMP 연결 시도 중...");
    // 연결 시도 직전에 토큰 갱신
    const currentToken = localStorage.getItem("accessToken");
    if (currentToken && client.connectHeaders) {
      client.connectHeaders.Authorization = `Bearer ${currentToken}`;
      console.log("STOMP 연결 헤더에 토큰 설정:", currentToken.substring(0, 15) + "...");
    }
  };

  // WebSocket 연결 종료 이벤트 핸들러
  client.onWebSocketClose = (e) => {
    console.log("WebSocket 연결 종료:", e);
  };

  // WebSocket 오류 발생 이벤트 핸들러
  client.onWebSocketError = (e) => {
    console.log("WebSocket 오류 : ", e);
  };

  // 설정 완료된 클라이언트 반환
  return client;
};

/**
 * STOMP 메시지 전송 함수
 *
 * 지정된 대상(destination)으로 메시지를 전송합니다.
 * 클라이언트가 연결된 상태일 때만 메시지를 전송합니다.
 *
 * @param {Client} client - STOMP 클라이언트 인스턴스
 * @param {string} destination - 메시지를 전송할 대상 경로
 * @param {unknown} body - 전송할 메시지 내용 (JSON으로 직렬화됨)
 * @param {Record<string, string>} headers - 메시지에 포함할 추가 헤더 (선택적)
 */
export const sendMessage = (client: Client, destination: string, body: unknown, headers: Record<string, string> = {}): void => {
  // 클라이언트 연결 상태 확인
  if (client.connected) {
    try {
      const token = localStorage.getItem("accessToken");
      // 메시지 발행(publish)
      client.publish({
        destination, // 메시지 대상 경로
        body: JSON.stringify(body), // 메시지 내용을 JSON 문자열로 변환
        headers: {
          ...headers,
          Authorization: token ? `Bearer ${token}` : "",
        }, // 추가 헤더
      });

      console.log("STOMP 메시지 전송 완료!");
    } catch (e) {
      console.error("STOMP 메시지 전송 오류ㅠ", e);
    }
  } else {
    console.error("STOMP 클라이언트가 연결되어 있지 않아용. 연결 상태 :", client.connected);
  }
};

/**
 * STOMP 토픽 구독 함수
 *
 * 지정된 토픽을 구독하고 메시지 수신 시 콜백 함수를 실행합니다.
 * 클라이언트가 연결된 상태일 때만 구독을 수행합니다.
 *
 * @param {Client} client - STOMP 클라이언트 인스턴스
 * @param {string} topic - 구독할 토픽 경로
 * @param {Function} callback - 메시지 수신 시 실행할 콜백 함수
 * @param {Record<string, string>} headers - 구독에 사용할 추가 헤더 (선택적)
 * @returns {StompSubscription | null} 구독 객체 또는 실패 시 null
 */
export const subscribeToTopic = (client: Client, topic: string, callback: (message: IMessage) => void, headers: Record<string, string> = {}): StompSubscription | null => {
  // 클라이언트 연결 상태 확인
  if (client.connected) {
    try {
      console.log(`토픽 구독 시작: ${topic}`);

      // 토픽 구독 수행
      const subscription = client.subscribe(
        topic, // 구독할 토픽 경로
        (message) => {
          // 메시지 수신 시 처리 로직
          console.log(`토픽 ${topic}에서 메시지 수신:`, message);
          callback(message); // 사용자 정의 콜백 실행
        },
        headers // 추가 헤더
      );

      console.log(`토픽 구독 성공: ${topic}, ID: ${subscription.id}`);
      return subscription; // 구독 객체 반환
    } catch (e) {
      console.error(`구독 오류 토픽 : (${topic}):`, e);
      return null; // 오류 발생 시 null 반환
    }
  } else {
    console.error(`STOMP 클라이언트 연결 상태 오류로 구독 실패 토픽 : (${topic}). 연결 상태: `, client.connected);
    return null; // 연결되지 않은 경우 null 반환
  }
};

2. WebSocket을 전역적으로 사용하기 위한 Custom Hook 생성

typescript
import { Client, IMessage, StompSubscription } from "@stomp/stompjs";
import { useCallback, useEffect, useState } from "react";
import { createStompClient, sendMessage, subscribeToTopic } from "../service/stompService";

interface ChatMessage {
  content: string;
  roomId: string;
}

/**
 * WebSocket 토픽 정의
 * 각 도메인별(ROOM, CHAT, GAME)로 토픽을 구분하고 roomId를 파라미터로 받아 동적 토픽 생성
 */
export const SOCKET_TOPICS = {
  ROOM: (roomId: string) => `/topic/room/${roomId}`,
  CHAT: (roomId: string) => `/topic/chat/${roomId}`,
  GAME: (roomId: string) => `/topic/game/${roomId}`,
};

/**
 * WebSocket 메시지 타입 정의
 * 각 도메인별 가능한 액션 타입을 상수로 정의
 * 백엔드와 협의 후 변경될 수 있음
 */
export const MESSAGE_TYPES = {
  ROOM: {
    CREATE: "CREATE",
    KICK: "KICK", // 방에서 유저 강퇴
    JOIN: "JOIN", // 방 입장
    LEAVE: "LEAVE", // 방 퇴장
    UPDATE: "UPDATE", // 방 정보 업데이트
    READY: "READY", // 준비 상태 변경
    UNREADY: "UNREADY", // 준비 상태 해제
    START: "START", // 게임 시작
    INFO: "INFO", // 방 정보 조회
  },
  GAME: {
    STATUS: {
      ATTACK: "ATTACK", // 공격 액션
      DAMAGE: "DAMAGE", // 데미지 받음
      DEAD: "DEAD", // 캐릭터 사망
      END: "END", // 게임 종료
    },
    PROBLEM: "PROBLEM", // 문제 전송/수신
    ANSWER: "ANSWER", // 답변 제출/결과
  },
  CHAT: {
    SEND: "SEND", // 메시지 전송
    RECEIVE: "RECEIVE", // 메시지 수신
  },
};

// StompSubscription 타입 정의
type StompSubscriptionType = StompSubscription | null;

/**
 * WebSocket 연결 및 관리를 위한 React 훅
 *
 * 주요 기능:
 * - WebSocket 클라이언트 생성 및 연결 관리
 * - 토픽 구독 및 구독 해제
 * - 메시지 전송
 *
 * @returns WebSocket 관련 상태 및 함수들
 */
export const useWebSocket = () => {
  // WebSocket 클라이언트 상태
  const [client, setClient] = useState<Client | null>(null);
  // 연결 상태
  const [connected, setConnected] = useState(false);
  // 활성 구독 목록 (토픽별로 관리)
  const [subscriptions, setSubscripitons] = useState<Record<string, StompSubscriptionType>>({});

  // 컴포넌트 마운트 시 WebSocket 클라이언트 초기화
  useEffect(() => {
    // stompService에서 STOMP 클라이언트 생성
    const stompClient = createStompClient();

    // 연결 성공 이벤트 핸들러
    stompClient.onConnect = () => {
      setConnected(true);
    };

    // 연결 해제 이벤트 핸들러
    stompClient.onDisconnect = () => {
      setConnected(false);
    };

    // 클라이언트 활성화 (연결 시작)
    stompClient.activate();
    setClient(stompClient);

    // 컴포넌트 언마운트 시 정리 함수
    return () => {
      if (stompClient.active) {
        stompClient.deactivate();
      }
    };
  }, []);

  /**
   * 토픽 구독 함수
   *
   * @param topic 구독할 토픽 경로
   * @param callback 메시지 수신 시 실행할 콜백 함수
   * @returns 구독 객체 또는 실패 시 null
   */
  const subscribe = useCallback(
    (topic: string, callback: (message: IMessage) => void) => {
      // 클라이언트가 없거나 연결되지 않은 경우
      if (!client || !connected) return null;

      // 이미 구독 중인 토픽이면 기존 구독 반환
      if (subscriptions[topic]) {
        return subscriptions[topic];
      }

      // 토픽 구독 수행
      const subscription = subscribeToTopic(client, topic, callback);

      // 구독 성공 시 상태에 추가
      if (subscription) {
        setSubscripitons((prev) => {
          // 이미 같은 토픽에 대한 구독이 있으면 업데이트하지 않음
          if (prev[topic]) return prev;
          return {
            ...prev,
            [topic]: subscription,
          };
        });
      }

      return subscription;
    },
    [client, connected, subscriptions]
  );

  /**
   * 토픽 구독 해제 함수
   *
   * @param topic 구독 해제할 토픽 경로
   */
  const unsubscribe = useCallback(
    (topic: string) => {
      if (subscriptions[topic]) {
        // 구독 객체 해제
        subscriptions[topic]?.unsubscribe();
        // 상태에서 제거
        setSubscripitons((prev) => {
          const newSubs = { ...prev };
          delete newSubs[topic];
          return newSubs;
        });
      }
    },
    [subscriptions]
  );

  /**
   * 메시지 전송 함수
   *
   * @param destination 메시지를 전송할 대상 경로
   * @param body 전송할 메시지 본문 (객체)
   * @param headers 추가 헤더 (선택적)
   * @returns 전송 성공 여부
   */
  const send = useCallback(
    (destination: string, body: ChatMessage, headers = {}) => {
      // 클라이언트가 없거나 연결되지 않은 경우
      if (!client || !connected) return false;

      // 메시지 전송
      sendMessage(client, destination, body, headers);
      return true;
    },
    [client, connected]
  );

  // 훅에서 제공하는 기능과 상태 반환
  return {
    client, // STOMP 클라이언트 객체
    connected, // 연결 상태
    subscribe, // 구독 함수
    unsubscribe, // 구독 해제 함수
    send, // 메시지 전송 함수
    topics: SOCKET_TOPICS, // 토픽 정의 객체
    messageTypes: MESSAGE_TYPES, // 메시지 타입 정의 객체
  };
};

3. 컴포넌트에서 활용

우리 프로젝트에서의 활용 중 가장 눈에 띄는 것은 방과 게임인데 방 로직에 대해 설명하고 코드를 보여주겠다.

  1. WebSocket 연결 객체 생성
  2. API 호출로 방에 대한 상태 값 서버에게 알림
  3. 서버에서 해당 요청에 따라 redis 를 통해 STOMP 메시지 발행
  4. 클라이언트에서 발행된 메시지에 따라 처리

인터페이스 정의

typescript
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import Background from "../components/layout/Background";
import oneVsOneImg from "../assets/one_vs_one.png";
import mainBg from "../assets/main.gif";
import { CustomAlert } from "../components/layout/CustomAlert";
import { useWebSocket } from "../hooks/useWebSocket";
import { IMessage } from "@stomp/stompjs";
import { useApi } from "../hooks/useApi";
import { useUserInfo } from "../hooks/useUserInfo";

// 방 상태 타입 정의
type RoomStatus = "OPEN" | "IN_PROGRESS" | "CLOSED";

// 방 유형 타입 정의
type RoomType = "ONE_ON_ONE" | "GENERAL" | "PRIVATE";

// 주제 유형 타입 정의
type SubjectType = "FIN_KNOWLEDGE" | "FIN_INVESTMENT" | "FIN_POLICY" | "FIN_PRODUCT" | "FIN_CRIME";

// 방 인터페이스 정의
interface Room {
  roomId: number;
  roomTitle: string;
  status: RoomStatus;
  roomType: RoomType;
  subjectType: SubjectType;
  maxPlayer: number;
  memberId: number;
  createdAt: string;
}

// 플레이어 인터페이스 정의
interface RedisRoomMember {
  memberId: number;
  nickname: string;
  mainCat: string;
  status: "READY" | "UNREADY";
}

// 레디스 방 인터페이스
interface RedisRoom {
  roomId: number;
  maxPeople: number;
  status: RoomStatus;
  host: RedisRoomMember;
  members: RedisRoomMember[];
}

// 이벤트 메시지 인터페이스
interface EventMessage<T> {
  event: string;
  roomId: number;
  data: T;
}

// 채팅 메시지 인터페이스
interface ChatMessage {
  nickname: string;
  content: string;
  roomId: number;
}

// API 응답 인터페이스
interface ApiResponse<T = unknown> {
  isSuccess: boolean;
  message?: string;
  result: T;
}

2. 컴포넌트 설정 및 메시지 타입에 따라 처리

typescript
const RoomPreparePage: React.FC = () => {
  const navigate = useNavigate();
  const { roomId } = useParams<{ roomId: string }>();
  const [room, setRoom] = useState<Room | null>(null);
  const [redisRoom, setRedisRoom] = useState<RedisRoom | null>(null);
  const [isReady, setIsReady] = useState(false);
  const [chatInput, setChatInput] = useState("");
  const [chatMessages, setChatMessages] = useState<{ content: string; roomId: string; sender: string }[]>([]);
  const [showAlert, setShowAlert] = useState(false);
  const [alertMessage, setAlertMessage] = useState("");
  const [memberId, setMemberId] = useState<number | null>(null);

  // room 값을 참조할 ref 추가
  const roomRef = React.useRef<Room | null>(null);

  // room 상태가 업데이트될 때마다 ref 업데이트
  useEffect(() => {
    roomRef.current = room;
  }, [room]);

  // API 훅 사용
  const { loading: roomLoading, error: roomError, execute: fetchRoom } = useApi<Room>("");
  const { loading: infoLoading, error: infoError, execute: fetchRoomInfo } = useApi<ApiResponse>("", "POST");
  const { loading: readyLoading, error: readyError, execute: setReady } = useApi<ApiResponse>("", "POST");
  const { loading: unreadyLoading, error: unreadyError, execute: setUnready } = useApi<ApiResponse>("", "POST");
  const { loading: startLoading, error: startError, execute: startGame } = useApi<ApiResponse>("", "PUT");
  const { loading: leaveLoading, error: leaveError, execute: leaveRoomApi } = useApi<ApiResponse>("", "POST");
  const { loading: kickLoading, error: kickError, execute: kickPlayer } = useApi<ApiResponse>(`/api/room/room/${roomId}/kick`, "POST");

  // WebSocket 훅 사용
  const { client, connected, subscribe, unsubscribe, send, topics, messageTypes } = useWebSocket();
  const { user } = useUserInfo();
  // 사용자 정보 로드 (실제로는 로그인 정보에서 가져옴)
  // useEffect(() => {
  //   // 임시 사용자 ID (실제로는 로그인한 사용자 정보)
  //   // 예시를 위해 로컬 스토리지에서 가져오는 방식 사용
  //   const userIdFromStorage = parseInt(localStorage.getItem("userId") || "1");
  //   setMemberId(userIdFromStorage);
  // }, []);

  // 방 정보 로드
  useEffect(() => {
    if (!roomId || !connected) return;

    const loadRoomInfo = async () => {
      try {
        // 연결 1초 대기
        await new Promise((resolve) => setTimeout(resolve, 1000));

        // 방 기본 정보 가져오기
        const roomResponse = await fetchRoom(undefined, {
          url: `/api/room/${roomId}`,
        });

        console.log("방 정보 : ", roomResponse);
        if (roomResponse?.isSuccess && roomResponse?.result) {
          setRoom(roomResponse.result);
        } else {
          showCustomAlert("방 정보를 불러오는데 실패했습니다.");
          navigate("/main");
          return;
        }

        // 실시간 방 정보 요청
        const infoResponse = await fetchRoomInfo(undefined, {
          url: `/api/room/room/${roomId}/info`,
        });
        console.log("실시간 방 정보 : ", infoResponse);
        if (!infoResponse?.isSuccess) {
          showCustomAlert("실시간 방 정보를 불러오는데 실패했습니다.");
        }
      } catch (error) {
        console.error("방 정보를 불러오는 중 오류 발생:", error);
        showCustomAlert("방 정보를 불러오는데 실패했습니다.");
        navigate("/main");
      }
    };

    loadRoomInfo();
  }, [roomId, navigate, fetchRoom, fetchRoomInfo, connected]);

  // 에러 처리
  useEffect(() => {
    const errors = [roomError, infoError, readyError, unreadyError, startError, leaveError];
    const errorMsg = errors.find((err) => err !== null);

    if (errorMsg) {
      showCustomAlert(errorMsg);
    }
  }, [roomError, infoError, readyError, unreadyError, startError, leaveError]);

  // WebSocket 구독
  useEffect(() => {
    if (!connected || !client || !roomId) return;

    // 방 토픽 구독
    const roomTopic = topics.ROOM(roomId);

    // 메시지 핸들러
    const handleRoomMessage = (message: IMessage) => {
      try {
        console.log("원본 메시지 본문:", message.body);

        // 이중 인코딩된 메시지 처리 - 메시지 본문이 JSON 문자열을 포함한 문자열인 경우
        let parsedData: EventMessage<RedisRoom | number | null>;

        // 첫 번째 파싱 시도
        const firstParse = JSON.parse(message.body);

        // 결과가 문자열인지 확인 (이중 인코딩된 경우)
        if (typeof firstParse === "string") {
          // 두 번째 파싱 시도
          parsedData = JSON.parse(firstParse);
          console.log("이중 인코딩된 메시지 감지, 두 번째 파싱 결과:", parsedData);
        } else {
          // 일반적인 경우 (한 번만 인코딩됨)
          parsedData = firstParse;
        }

        console.log("최종 파싱 결과:", parsedData);
        console.log("이벤트 타입:", parsedData.event);

        // 이벤트 타입 확인
        if (!parsedData || typeof parsedData !== "object") {
          console.error("유효하지 않은 메시지 구조:", parsedData);
          return;
        }

        // 이벤트 처리
        const event = parsedData.event;

        if (event === "CREATE" || event === "INFO" || event === "JOIN" || event === "READY" || event === "UPDATE" || event === "UNREADY") {
          // 방 정보 업데이트
          if (parsedData.data && typeof parsedData.data === "object") {
            console.log("방 정보 업데이트:", parsedData.data);
            setRedisRoom(parsedData.data as RedisRoom);
          }
        } else if (event === "KICK") {
          console.log("🔵 KICK 이벤트 수신:", parsedData);

          if (typeof parsedData.data === "number") {
            const kickedMemberId = parsedData.data;
            console.log("🔵 강퇴될 memberId:", kickedMemberId);
            console.log("🔵 현재 redisRoom 상태:", redisRoom);
            console.log("🔵 현재 로그인한 사용자:", user);

            const kickedMember = redisRoom?.members.find((member) => member.memberId === kickedMemberId);
            console.log("🔵 강퇴될 멤버 정보:", kickedMember);

            const isCurrentUserKicked = user?.nickname === kickedMember?.nickname;
            console.log("🔵 현재 사용자가 강퇴당했는지:", isCurrentUserKicked);
            console.log("🔵 비교 값들:", {
              userNickname: user?.nickname,
              kickedMemberNickname: kickedMember?.nickname,
            });

            if (isCurrentUserKicked) {
              console.log("🔵 강퇴 처리 시작 - 메인으로 이동");
              showCustomAlert("방에서 강퇴되었습니다.");
              navigate("/main");
              return;
            }

            console.log("🔵 다른 사용자 강퇴 처리 시작");
            setRedisRoom((prevRoom) => {
              if (!prevRoom) {
                console.log("🔵 이전 방 정보 없음");
                return null;
              }

              const updatedMembers = prevRoom.members.filter((member) => member.memberId !== kickedMemberId);
              console.log("🔵 업데이트된 멤버 목록:", updatedMembers);

              return {
                ...prevRoom,
                members: updatedMembers,
              };
            });
          }
        } else if (event === "LEAVE") {
          if (typeof parsedData.data === "number") {
            const leavingMemberId = parsedData.data;
            setRedisRoom((prevRoom) => {
              if (!prevRoom) return null;

              const updatedMembers = prevRoom.members.filter((member) => member.memberId !== leavingMemberId);

              let updatedHost = prevRoom.host;
              if (prevRoom.host.memberId === leavingMemberId && updatedMembers.length > 0) {
                updatedHost = updatedMembers[0];
              }

              return {
                ...prevRoom,
                members: updatedMembers,
                host: updatedHost,
              };
            });
          }
        } else if (event === "START") {
          // 기존 방 구독 해제
          unsubscribe(roomTopic);
          unsubscribe(chatTopic);
          // 게임 시작
          if (roomRef.current?.subjectType) {
            navigate(`/one-to-one/${roomRef.current.subjectType.toLowerCase()}`);
          }
        } else {
          console.log("알 수 없는 이벤트:", event, "서버 데이터:", parsedData);
        }
      } catch (error) {
        console.error("메시지 처리 중 오류 발생:", error, "원본 메시지:", message.body);
      }
    };

    // 구독
    const subscription = subscribe(roomTopic, handleRoomMessage);

    // 채팅 토픽 구독 (채팅 기능이 구현된 경우)
    const chatTopic = topics.CHAT(roomId);
    const handleChatMessage = (message: IMessage) => {
      try {
        console.log("원본 메시지:", message.body);
        // 메시지가 이중으로 JSON 문자열로 되어있을 수 있으므로 두 번 파싱
        let chatData;
        try {
          const firstParse = JSON.parse(message.body);
          if (typeof firstParse === "string") {
            chatData = JSON.parse(firstParse);
          } else {
            chatData = firstParse;
          }
        } catch {
          chatData = JSON.parse(message.body);
        }

        console.log("파싱된 데이터:", chatData);

        // redisRoom에서 발신자의 닉네임 찾기
        const senderMember = redisRoom?.members.find((member) => member.memberId === chatData.sender);
        const senderNickname = senderMember?.nickname || "알 수 없음";

        setChatMessages((prev) => [
          ...prev,
          {
            content: chatData.content,
            roomId: chatData.roomId,
            sender: chatData.sender,
          },
        ]);
      } catch (error) {
        console.error("채팅 메시지 처리 중 오류 발생:", error, "원본 메시지:", message.body);
      }
    };

    const chatSubscription = subscribe(chatTopic, handleChatMessage);

    // 정리 함수
    return () => {
      if (subscription) {
        unsubscribe(roomTopic);
      }
      if (chatSubscription) {
        unsubscribe(chatTopic);
      }
    };
  }, [connected, client, roomId, topics, subscribe, unsubscribe, messageTypes, memberId, navigate]);
typescript
// 준비 상태 토글
  const handleToggleReady = async () => {
    if (!roomId) return;

    try {
      if (isReady) {
        // 준비 해제
        await setUnready(undefined, {
          url: `/api/room/room/${roomId}/unready`,
        });
      } else {
        // 준비 완료
        await setReady(undefined, {
          url: `/api/room/room/${roomId}/ready`,
        });
      }
      setIsReady(!isReady);
    } catch (error) {
      console.error("준비 상태 변경 중 오류 발생:", error);
      showCustomAlert("준비 상태 변경에 실패했습니다.");
    }
  };

  // RedisRoom의 members 배열에서 강퇴할 멤버의 ID를 가져와서 사용
  const handleKickPlayer = async (targetMemberId: number) => {
    if (!roomId || !redisRoom) return;

    // 이미 UI에서 방장만 버튼이 보이도록 했으니, 여기서는 추가 체크가 필요 없음
    try {
      const response = await kickPlayer(undefined, {
        url: `/api/room/room/${roomId}/kick/${targetMemberId}`,
      });

      if (!response?.isSuccess) {
        showCustomAlert("강퇴에 실패했습니다.");
      }
    } catch (error) {
      console.error("강퇴 처리 중 오류 발생:", error);
      showCustomAlert("강퇴 처리 중 오류가 발생했습니다.");
    }
  };

  // 게임 시작
  const handleStartGame = async () => {
    if (!roomId || !redisRoom) return;

    // 모든 플레이어가 준비 상태인지 확인
    const allReady = redisRoom.members.every((member) => member.memberId === redisRoom.host.memberId || member.status === "READY");

    if (!allReady) {
      showCustomAlert("모든 플레이어가 준비 상태여야 게임을 시작할 수 있습니다.");
      return;
    }

    // 게임 시작 API 호출
    await startGame(undefined, {
      url: `/api/room/start/${roomId}`,
    });
  };

  // 방 나가기
  const handleLeaveRoom = async () => {
    if (!roomId) return;

    // 방 나가기 API 호출
    const response = await leaveRoomApi(undefined, {
      url: `/api/room/room/${roomId}/leave`,
    });

    if (response?.isSuccess) {
      navigate("/main");
    }
  };

  // 채팅 메시지 전송
  const sendChatMessage = (e: React.FormEvent) => {
    e.preventDefault();
    if (!chatInput.trim() || !roomId || !client || !connected || !redisRoom) return;

    // redisRoom에서 현재 사용자의 정보 찾기
    const currentMember = redisRoom.members.find((member) => member.nickname === user?.nickname);

    if (!currentMember) {
      showCustomAlert("사용자 정보를 찾을 수 없습니다.");
      return;
    }

    const chatTopic = `/app/chat/${roomId}`;
    const chatData = {
      roomId: roomId.toString(),
      content: chatInput,
      sender: currentMember.memberId,
    };

    send(chatTopic, chatData);
    setChatInput("");
  };

  // 주제 이름 가져오기
  const getSubjectName = (subjectType?: SubjectType) => {
    if (!subjectType) return "";

    const subjects = [
      { id: "FIN_KNOWLEDGE", name: "금융 지식" },
      { id: "FIN_INVESTMENT", name: "투자" },
      { id: "FIN_POLICY", name: "정책" },
      { id: "FIN_PRODUCT", name: "상품" },
      { id: "FIN_CRIME", name: "범죄" },
    ];

    return subjects.find((s) => s.id === subjectType)?.name || subjectType;
  };

  const showCustomAlert = (message: string) => {
    setAlertMessage(message);
    setShowAlert(true);
  };

  if (!room || !redisRoom) {
    return (
      <Background backgroundImage={mainBg}>
        <div className="w-full h-full flex items-center justify-center">
          <div className="text-white text-2xl">{roomLoading || infoLoading ? "로딩 중..." : "방 정보를 불러올 수 없습니다."}</div>
        </div>
      </Background>
    );
  }

  // 현재 사용자의 준비 상태 확인
  const currentMember = redisRoom.members.find((member) => member.nickname === user?.nickname);
  const currentIsReady = currentMember?.status === "READY";

  const isLoading = readyLoading || unreadyLoading || startLoading || leaveLoading;
	
	return ( ... 기타 UI )

태그

#STOMP#WebSocket