Skip to content

Commit 523617b

Browse files
committed
feat: 퀴즈 조회,검색,생성,삭제 구현 및 stomp connect 한번한 하도록 싱글톤 구조로 stom훅 변경
1 parent e1cae40 commit 523617b

16 files changed

Lines changed: 658 additions & 249 deletions

src/hooks/useStompClient.js

Lines changed: 87 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,105 @@
1-
import { useEffect, useRef } from "react";
2-
import { Client } from "@stomp/stompjs";
1+
// useStompClient.js
2+
import { useEffect, useRef, useMemo, useState } from 'react';
3+
import { Client } from '@stomp/stompjs';
4+
5+
// 싱글톤 관리 변수
6+
let stompClient = null;
7+
let isConnected = false;
8+
let isConnecting = false;
39

410
export default function useStompClient(roomId, onMessage) {
5-
const stompClientRef = useRef(null);
6-
const hasSubscribedRef = useRef(false);
11+
const [ready, setReady] = useState(false);
12+
const [activeSendMessage, setActiveSendMessage] = useState(null); // 연결 완료 후 세팅될 함수
13+
const onMessageRef = useRef(onMessage);
14+
const hasInitializedRoomRef = useRef(false); // ✅ 방 초기화 메시지 중복 방지용
15+
16+
// 최신 onMessage 핸들러 유지
17+
useEffect(() => {
18+
onMessageRef.current = onMessage;
19+
}, [onMessage]);
20+
21+
// 최초 1번만 커넥션 시도
722
useEffect(() => {
8-
const stompClient = new Client({
9-
brokerURL: `wss://brainrace.duckdns.org:7080/ws/game-room`,
23+
if (isConnected || isConnecting) return;
24+
25+
isConnecting = true;
26+
27+
stompClient = new Client({
28+
brokerURL: 'wss://brainrace.duckdns.org:7080/ws/game-room',
1029
reconnectDelay: 5000,
30+
debug: (msg) => console.log('[STOMP]', msg),
1131
onConnect: () => {
12-
console.log("🔌 STOMP connected");
13-
// 구독
14-
if (!hasSubscribedRef.current) {
15-
hasSubscribedRef.current = true;
16-
stompClient.subscribe(`/sub/room/${roomId}`, (message) => {
17-
const payload = JSON.parse(message.body);
18-
console.log("📥 메시지 수신:", payload);
19-
20-
// 👉 외부에서 전달한 콜백 실행
21-
if (onMessage) onMessage(payload);
22-
});
23-
24-
// ✅ 자동으로 방 입장 메시지 퍼블리시
25-
stompClient.publish({
26-
destination: `/pub/room/initializeRoomSocket/${roomId}`,
27-
body: "", // 빈 body 명시적 전달
28-
});
29-
} //if (!hasSubscribedRef.current) { end
30-
}, //onConnect end
32+
console.log('✅ STOMP 연결됨');
33+
isConnected = true;
34+
isConnecting = false;
35+
setReady(true);
36+
},
3137
onStompError: (frame) => {
32-
console.error("❌ STOMP error", frame);
38+
console.error('❌ STOMP 에러:', frame.headers['message']);
39+
},
40+
onDisconnect: () => {
41+
console.log('🔌 STOMP 연결 종료');
42+
isConnected = false;
43+
isConnecting = false;
44+
setReady(false);
45+
setActiveSendMessage(null);
46+
hasInitializedRoomRef.current = false;
3347
},
3448
});
3549

3650
stompClient.activate();
37-
stompClientRef.current = stompClient;
51+
}, []);
3852

39-
return () => {
40-
if (stompClientRef.current?.connected) {
41-
stompClientRef.current.deactivate();
42-
console.log("🔌 STOMP disconnected");
53+
// roomId 변경 시 구독 (연결 완료 후)
54+
useEffect(() => {
55+
if (!stompClient || !isConnected || !roomId) return;
56+
57+
console.log(`📥 구독 시작: /sub/room/${roomId}`);
58+
59+
const subscription = stompClient.subscribe(`/sub/room/${roomId}`, (message) => {
60+
try {
61+
const payload = JSON.parse(message.body);
62+
onMessageRef.current?.(payload);
63+
} catch (err) {
64+
console.error('❌ 메시지 파싱 실패:', err);
4365
}
44-
hasSubscribedRef.current = false;
66+
});
67+
68+
return () => {
69+
subscription.unsubscribe();
70+
console.log(`📤 구독 해제: /sub/room/${roomId}`);
4571
};
46-
}, [roomId, onMessage]);
72+
}, [roomId, ready]);
73+
74+
// 연결이 완료되면 sendMessage 생성
75+
useEffect(() => {
76+
if (!isConnected || !stompClient) return;
4777

48-
// ✅ 메시지 전송 함수 반환
49-
const sendMessage = (destination, body) => {
50-
console.log("sendMessage: ", destination, body, stompClientRef.current?.connected)
51-
if (stompClientRef.current?.connected) {
52-
stompClientRef.current.publish({
78+
const sendFn = (destination, body) => {
79+
stompClient.publish({
5380
destination,
54-
body,
81+
body: typeof body === 'string' ? body : JSON.stringify(body),
5582
});
56-
} else {
57-
console.warn("STOMP 연결되지 않음");
58-
}
59-
};
83+
};
84+
85+
setActiveSendMessage(() => sendFn);
86+
}, [ready]);
6087

61-
return {sendMessage};
88+
// ✅ 최초 1회 initializeRoomSocket 퍼블리시
89+
useEffect(() => {
90+
if (!isConnected || !stompClient || !roomId || hasInitializedRoomRef.current === true) return;
91+
92+
stompClient.publish({
93+
destination: `/pub/room/initializeRoomSocket/${roomId}`,
94+
body: '',
95+
});
96+
97+
hasInitializedRoomRef.current = true;
98+
console.log(`🚀 초기화 메시지 전송됨: /pub/room/initializeRoomSocket/${roomId}`);
99+
}, [roomId, ready]);
100+
101+
return {
102+
sendMessage: activeSendMessage, // 처음엔 null, 연결 완료되면 함수
103+
ready,
104+
};
62105
}

src/layout/game/GameLayout.js

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import Sidebar from "./Sidebar";
33
import {useSetRecoilState} from "recoil";
44
import {
55
chatAtom,
6-
gameSettingAtom,
6+
gameSettingAtom, loginUserAtom,
77
playerListAtom,
88
roomSettingAtom, stompSendMessageAtom, systemNoticeAtom
99
} from "../../state/atoms";
1010
import useStompClient from "../../hooks/useStompClient";
1111
import {useCallback, useEffect} from "react";
12+
import {useApiQuery} from "../../hooks/useApiQuery";
13+
import axios from "axios";
1214

1315
const PLAYER_COLORS = [
1416
"text-red-600",
@@ -21,6 +23,11 @@ const PLAYER_COLORS = [
2123
"text-pink-600",
2224
];
2325

26+
const authMeRequest = async () => {
27+
const response = await axios.get(`/auth/me`);
28+
return response.data;
29+
};
30+
2431
const GameLayout = () => {
2532
const { id: roomId } = useParams();
2633
const setPlayerList = useSetRecoilState(playerListAtom);
@@ -29,7 +36,18 @@ const GameLayout = () => {
2936
const setChat = useSetRecoilState(chatAtom);
3037
const setSystemNotice = useSetRecoilState(systemNoticeAtom);
3138
const setSendMessage = useSetRecoilState(stompSendMessageAtom);
32-
const navigate = useNavigate();
39+
const setLoginUser = useSetRecoilState(loginUserAtom);
40+
41+
const { isLoading, data } = useApiQuery(
42+
["authme"],
43+
() => authMeRequest(),
44+
);
45+
46+
useEffect(() => {
47+
if (data) {
48+
setLoginUser(data);
49+
}
50+
}, [data])
3351

3452
// 메시지를 처리하는 콜백
3553
const handleStompMessage = useCallback((payload) => {
@@ -66,18 +84,16 @@ const GameLayout = () => {
6684
default:
6785
console.warn("알 수 없는 메시지", payload);
6886
}
69-
}, [setPlayerList, setRoomSetting, setGameSetting, setChat]);
87+
}, [setPlayerList, setRoomSetting, setGameSetting, setSystemNotice, setChat]);
7088

7189
const { sendMessage } = useStompClient(roomId, handleStompMessage);
72-
useEffect(() => {
73-
setSendMessage(() => sendMessage);
74-
}, [sendMessage]);
7590

7691
useEffect(() => {
92+
console.log('📦 sendMessage:', sendMessage);
7793
if (sendMessage) {
78-
sendMessage(`/pub/room/initializeRoomSocket/${roomId}`, "");
94+
setSendMessage(() => sendMessage); // Recoil 전역 등록
7995
}
80-
}, [sendMessage, roomId]);
96+
}, [sendMessage]);
8197

8298
return (
8399
<div className="d-flex flex-column vh-100">

src/layout/game/components/ChatSection.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {useEffect, useState} from "react"
44
import { MessageCircle, Send, Car, Bell } from "lucide-react"
55
import {useRecoilState, useRecoilValue} from "recoil";
66
import {
7-
chatAtom,
7+
chatAtom, loginUserAtom,
88
playerListAtom,
99
stompSendMessageAtom, systemNoticeAtom
1010
} from "../../../state/atoms";
@@ -18,6 +18,7 @@ function ChatSection() {
1818
const [newChat, setNewChat] = useRecoilState(chatAtom); // 단일 채팅 수신
1919
const [newNotice, setNewNotice] = useRecoilState(systemNoticeAtom); // 단일 채팅 수신
2020
const playerList = useRecoilValue(playerListAtom);
21+
const loginUser = useRecoilValue(loginUserAtom);
2122

2223
// 닉네임 → color 매핑 함수
2324
const getColorByNickname = (nickname) => {
@@ -42,9 +43,9 @@ function ChatSection() {
4243
const chatPayload = {
4344
type: "CHAT",
4445
message: {
45-
nickname: "shson",
46-
message: "와 이 문제 너무 어렵다!",
47-
timestamp: "2025-07-04T20:16:10Z",
46+
nickname: loginUser.name,
47+
message: chatMessage,
48+
timestamp: new Date().toISOString().split('.')[0] + 'Z',
4849
},
4950
};
5051
sendMessage(`/pub/room/chat/${roomId}`, JSON.stringify(chatPayload));

src/layout/game/components/GameSettings.js

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,54 @@
1-
"use client"
2-
31
import { useState } from "react"
42
import { useNavigate } from "react-router-dom"
53
import { Settings } from "lucide-react"
4+
import QuizSelectModal from "../../../pages/game/QuizSelectModal";
5+
import {useRecoilValue} from "recoil";
6+
import {stompSendMessageAtom} from "../../../state/atoms";
67

78
function GameSettings({ roomId }) {
8-
const [timePerQuestion, setTimePerQuestion] = useState("30초")
9-
const [questionCount, setQuestionCount] = useState(25)
10-
const navigate = useNavigate()
9+
const [timePerQuestion, setTimePerQuestion] = useState("30초");
10+
const [questionCount, setQuestionCount] = useState(25);
11+
const [quizSelectModalOpen, setQuizSelectModalOpen] = useState(false);
12+
const [selectedQuiz, setSelectedQuiz] = useState(null);
13+
const navigate = useNavigate();
14+
const sendMessage = useRecoilValue(stompSendMessageAtom);
1115

1216
const handleStartGame = () => {
13-
navigate(`/room/${roomId}/play`)
17+
sendMessage(`/pub/room/start/${roomId}`, "");
18+
navigate(`/room/${roomId}/play`);
1419
}
1520

16-
return (
17-
<div className="bg-white rounded-2xl shadow-lg p-6 mb-6">
18-
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
19-
<Settings className="w-5 h-5 mr-2 text-red-600" />
20-
게임 설정
21-
</h3>
22-
23-
<div className="space-y-4">
24-
<div>
25-
<label className="block text-sm font-medium text-gray-700 mb-2">퀴즈</label>
26-
<button className="w-full px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium">
27-
퀴즈 선택
28-
</button>
29-
</div>
21+
const handleQuizSelect = (quiz) => {
22+
setSelectedQuiz(quiz);
23+
}
3024

31-
<div className="grid grid-cols-2 gap-4">
25+
return (
26+
<>
27+
<div className="bg-white rounded-2xl shadow-lg p-6 mb-6">
28+
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
29+
<Settings className="w-5 h-5 mr-2 text-red-600" />
30+
게임 설정
31+
</h3>
32+
<div className="space-y-4">
3233
<div>
33-
<label className="block text-sm font-medium text-gray-700 mb-2">제한 시간</label>
34+
<label className="block text-sm font-medium text-gray-700 mb-2">퀴즈</label>
35+
<button
36+
className="w-full px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
37+
onClick={() => setQuizSelectModalOpen(true)}
38+
>
39+
{selectedQuiz ? selectedQuiz.title : "퀴즈 선택"}
40+
</button>
41+
{selectedQuiz && <p className="text-sm text-gray-600 mt-2">{selectedQuiz.description}</p>}
42+
</div>
43+
<div className="grid grid-cols-2 gap-4">
44+
<div>
45+
<label className="block text-sm font-medium text-gray-700 mb-2">제한 시간</label>
3446
<select
3547
value={timePerQuestion}
3648
onChange={(e) => setTimePerQuestion(e.target.value)}
3749
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 appearance-none bg-white bg-no-repeat bg-right pr-8"
3850
style={{
39-
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
51+
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' strokeLinecap='round' strokeLinejoin='round' strokeWidth='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
4052
backgroundPosition: "right 0.5rem center",
4153
backgroundSize: "1.5em 1.5em",
4254
}}
@@ -46,9 +58,9 @@ function GameSettings({ roomId }) {
4658
<option>45초</option>
4759
<option>60초</option>
4860
</select>
49-
</div>
50-
<div>
51-
<label className="block text-sm font-medium text-gray-700 mb-2">문제 수</label>
61+
</div>
62+
<div>
63+
<label className="block text-sm font-medium text-gray-700 mb-2">문제 수</label>
5264
<input
5365
type="number"
5466
min="10"
@@ -58,19 +70,26 @@ function GameSettings({ roomId }) {
5870
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
5971
placeholder="문제 수를 입력하세요 (10-80)"
6072
/>
73+
</div>
74+
</div>
75+
<div className="pt-4">
76+
<button
77+
onClick={handleStartGame}
78+
disabled={!selectedQuiz}
79+
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-semibold disabled:bg-gray-400 disabled:cursor-not-allowed"
80+
>
81+
게임 시작
82+
</button>
6183
</div>
62-
</div>
63-
64-
<div className="pt-4">
65-
<button
66-
onClick={handleStartGame}
67-
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-semibold"
68-
>
69-
게임 시작
70-
</button>
7184
</div>
7285
</div>
73-
</div>
86+
87+
<QuizSelectModal
88+
isOpen={quizSelectModalOpen}
89+
onClose={() => setQuizSelectModalOpen(false)}
90+
onSelect={handleQuizSelect}
91+
/>
92+
</>
7493
)
7594
}
7695

0 commit comments

Comments
 (0)