STOMP+WebSocket
서론
프로젝트를 진행하면서 STOMP 기반 웹 소켓 연결을 쓸 일이 생기게 되었다. 당연하게도 우리 프로젝트는 Web 게임 기능을 구현하는 것이었기에 소켓은 반드시 필요했었다.
하지만 이전 프로젝트에서 STOMP 기반 웹 소켓을 써 본적도, 없었고 웹 소켓 연결이라 해봤자 API 통신(비트코인 차트 시세 보기 등등) 뿐 이었기에 서버와 통신 하는 웹 소켓은 처음이었다.
그래서 먼저 STOMP 와 웹 소켓에 대한 이해가 필요했기에 먼저 그 부분들을 학습을 해보았다.
STOMP
STOMP(Simple/Streaming Text Oriented Messaging Protocol) 의 약자로 이름에서 볼 수 있듯이 메시지 기반 프로토콜로 다양한 메시지 시스템 간의 상호 운용성을 제공하기 위해 개발 되었다.
특징
- 텍스트 기반 프로토콜:
- STOMP는 텍스트 기반의 프로토콜로, 메시지 구조가 간단하고 이해하기 쉽다. ⇒ 디버깅과 개발을 용이하게 함
- 메시지 프레임 구조
- STOMP 메시지는 프레임으로 구성되며, 각 프레임은 명령(command), 헤더(headers), 본문(body)로 이루어져 있다. 이러한 구조는 메시지의 유연성을 높인다.
- 다양한 메시징 패턴 지원
- STOMP 는 Pub/Sub(발행/구독), P2P(점대점) 등 다양한 메시징 패턴을 지원하여, 다양한 애플리케이션 요구 사항 충족 가능
- 브로커 기반 아키텍쳐
- STOMP 는 메시지 브로커를 통해 메시지를 전달함. 이는 메시지의 라우팅과 전달을 효율적으로 관리할 수 있게 해줌
- 플랫폼 독립성
- STOMP는 다양한 프로그래밍 언어와 플랫폼에서 지원되므로, 다른 시스템 간의 통합이 용이함. 이는 다양한 환경에서의 사용을 가능하게 함
- 확장성
- STOMP는 메시지 브로커를 통해 메시지를 전달하므로, 시스템의 확장성을 높일 수 있다. 이는 많은 수의 클라이언트가 동시에 연결되어 있는 환경에서 특히 유리함
- 보안
- STOMP는 메시지 인증 및 권한 부여를 통해 보안을 강화할 수 있다. 이는 민감한 데이터 전송 시 중요한 요소임
💡 STOMP의 간략한 구조
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 연결 및 함수 정의
typescriptimport { 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 생성
typescriptimport { 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. 컴포넌트에서 활용
우리 프로젝트에서의 활용 중 가장 눈에 띄는 것은 방과 게임인데 방 로직에 대해 설명하고 코드를 보여주겠다.
- WebSocket 연결 객체 생성
- API 호출로 방에 대한 상태 값 서버에게 알림
- 서버에서 해당 요청에 따라 redis 를 통해 STOMP 메시지 발행
- 클라이언트에서 발행된 메시지에 따라 처리
인터페이스 정의
typescriptimport 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. 컴포넌트 설정 및 메시지 타입에 따라 처리
typescriptconst 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 )