diff --git a/packages/frontend/src/components/gamePage/leftSection/GamePhases/Voting.tsx b/packages/frontend/src/components/gamePage/leftSection/GamePhases/Voting.tsx
index a87bd61..f610d9d 100644
--- a/packages/frontend/src/components/gamePage/leftSection/GamePhases/Voting.tsx
+++ b/packages/frontend/src/components/gamePage/leftSection/GamePhases/Voting.tsx
@@ -1,4 +1,5 @@
import Button from '@/components/common/Button';
+import { memo } from 'react';
interface IVotingPhaseProps {
userId: string;
@@ -9,7 +10,33 @@ interface IVotingPhaseProps {
handleVote: () => void;
}
-export default function Voting({
+const VoteButton = memo(
+ ({
+ userId,
+ isSelected,
+ isVoteSubmitted,
+ onClick,
+ }: {
+ userId: string;
+ isSelected: boolean;
+ isVoteSubmitted: boolean;
+ onClick: () => void;
+ }) => (
+
+ ),
+);
+
+const Voting = memo(function Voting({
userId,
allUsers,
selectedVote,
@@ -30,19 +57,14 @@ export default function Voting({
피노코를 지목해주세요!
- {Array.from(allUsers).map((userId: string) => (
-
+ {Array.from(allUsers).map((voterId: string) => (
+ !isVoteSubmitted && setSelectedVote(voterId)}
+ />
))}
{isVoteSubmitted ? (
@@ -64,4 +86,6 @@ export default function Voting({
)}
);
-}
+});
+
+export default Voting;
diff --git a/packages/frontend/src/components/gamePage/leftSection/GuessInput.tsx b/packages/frontend/src/components/gamePage/leftSection/GuessInput.tsx
index 91b8a83..678c711 100644
--- a/packages/frontend/src/components/gamePage/leftSection/GuessInput.tsx
+++ b/packages/frontend/src/components/gamePage/leftSection/GuessInput.tsx
@@ -1,23 +1,30 @@
-import { useState } from 'react';
+import { useState, useCallback, memo } from 'react';
interface IGuessInputProps {
onSubmitGuess: (word: string) => void;
}
-export default function GuessInput({ onSubmitGuess }: IGuessInputProps) {
+const GuessInput = memo(function GuessInput({ onSubmitGuess }: IGuessInputProps) {
const [inputValue, setInputValue] = useState('');
- const handleSubmit = () => {
+ const handleSubmit = useCallback(() => {
if (!inputValue.trim()) return;
onSubmitGuess(inputValue);
setInputValue('');
- };
+ }, [inputValue, onSubmitGuess]);
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- handleSubmit();
- }
- };
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleSubmit();
+ }
+ },
+ [handleSubmit],
+ );
+
+ const handleChange = useCallback((e: React.ChangeEvent) => {
+ setInputValue(e.target.value);
+ }, []);
return (
@@ -26,7 +33,7 @@ export default function GuessInput({ onSubmitGuess }: IGuessInputProps) {
type="text"
placeholder="제시어 입력"
value={inputValue}
- onChange={(e) => setInputValue(e.target.value)}
+ onChange={handleChange}
onKeyDown={handleKeyDown}
className="p-2 text-lg rounded-md bg-gray-800 text-white-default outline-none"
/>
@@ -38,4 +45,6 @@ export default function GuessInput({ onSubmitGuess }: IGuessInputProps) {
);
-}
+});
+
+export default GuessInput;
diff --git a/packages/frontend/src/components/gamePage/leftSection/MainDisplay.tsx b/packages/frontend/src/components/gamePage/leftSection/MainDisplay.tsx
index 891cb04..a7b817e 100644
--- a/packages/frontend/src/components/gamePage/leftSection/MainDisplay.tsx
+++ b/packages/frontend/src/components/gamePage/leftSection/MainDisplay.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useCallback, useMemo, memo } from 'react';
import { useRoomStore } from '@/store/roomStore';
import StartButton from './GameButtons/StartButton';
import ReadyButton from './GameButtons/ReadyButton';
@@ -20,7 +20,7 @@ import VideoStream from '@/components/gamePage/stream/VideoStream';
import { useLocalStreamStore } from '@/store/localStreamStore';
import { useSpeakingControl } from '@/hooks/useSpeakingControl';
-export default function MainDisplay() {
+const MainDisplay = memo(function MainDisplay() {
const { userId } = useAuthStore();
const { isHost, isPinoco, allUsers } = useRoomStore();
const [gamePhase, setGamePhase] = useState(GAME_PHASE.WAITING);
@@ -38,13 +38,21 @@ export default function MainDisplay() {
setGamePhase,
setSelectedVote,
);
- function getCurrentStream() {
- const localStream = useLocalStreamStore.getState().localStream;
- const remoteStreams = usePeerConnectionStore.getState().remoteStreams;
- if (currentSpeaker === userId) return localStream;
- const currentStream = remoteStreams.get(currentSpeaker || '');
- return currentStream;
- }
+
+ const handleCountdownEnd = useCallback(() => {
+ setGamePhase(GAME_PHASE.WORD_REVEAL);
+ const timer = setTimeout(() => {
+ setGamePhase(GAME_PHASE.SPEAKING);
+ }, 3000);
+ return () => clearTimeout(timer);
+ }, []);
+
+ const handleVote = useCallback(() => {
+ if (!isVoteSubmitted) {
+ votePinoco(selectedVote ?? '');
+ setIsVoteSubmitted(true);
+ }
+ }, [isVoteSubmitted, selectedVote, votePinoco]);
useEffect(() => {
if (gameStartData) {
@@ -54,23 +62,76 @@ export default function MainDisplay() {
}
}, [gameStartData]);
- const handleCountdownEnd = () => {
- setGamePhase(GAME_PHASE.WORD_REVEAL);
- setTimeout(() => {
- setGamePhase(GAME_PHASE.SPEAKING);
- }, 3000);
- };
+ const currentStreamData = useMemo(() => {
+ if (currentSpeaker === userId) {
+ return useLocalStreamStore.getState().localStream;
+ }
+ return usePeerConnectionStore.getState().remoteStreams.get(currentSpeaker || '');
+ }, [currentSpeaker, userId]);
- const handleVote = () => {
- if (!isVoteSubmitted) {
- if (selectedVote === null) {
- votePinoco('');
- } else {
- votePinoco(selectedVote);
- }
- setIsVoteSubmitted(true);
+ const renderGamePhaseContent = useMemo(() => {
+ switch (gamePhase) {
+ case GAME_PHASE.WAITING:
+ return (
+
+ {isHost ? : }
+
+ );
+ case GAME_PHASE.COUNTDOWN:
+ return ;
+ case GAME_PHASE.VOTING:
+ return (
+
+ );
+ case GAME_PHASE.VOTING_RESULT:
+ return (
+
+ );
+ case GAME_PHASE.GUESSING:
+ return (
+
+ {isPinoco ? (
+
+ ) : (
+
+ 피노코가 제시어를 추측 중입니다 🤔
+
+ )}
+
+ );
+ case GAME_PHASE.ENDING:
+ return ;
+ default:
+ return null;
}
- };
+ }, [
+ gamePhase,
+ isHost,
+ userId,
+ allUsers,
+ selectedVote,
+ isVoteSubmitted,
+ handleVote,
+ handleCountdownEnd,
+ endingResult,
+ deadPerson,
+ voteResult,
+ isDeadPersonPinoco,
+ isPinoco,
+ submitGuess,
+ ]);
+
return (
-
- {gamePhase === GAME_PHASE.WAITING && (
-
- {isHost ? : }
-
- )}
- {gamePhase === GAME_PHASE.COUNTDOWN &&
}
- {gamePhase === GAME_PHASE.VOTING && (
-
- )}
- {gamePhase === GAME_PHASE.VOTING_RESULT && (
-
- )}
- {gamePhase === GAME_PHASE.GUESSING && (
-
- {isPinoco ? (
-
- ) : (
-
- 피노코가 제시어를 추측 중입니다 🤔
-
- )}
-
- )}
- {gamePhase === GAME_PHASE.ENDING &&
}
-
+ {renderGamePhaseContent}
{gamePhase === GAME_PHASE.SPEAKING && currentSpeaker === userId && (
@@ -170,4 +195,6 @@ export default function MainDisplay() {
);
-}
+});
+
+export default MainDisplay;
diff --git a/packages/frontend/src/components/gamePage/leftSection/VideoFeed.tsx b/packages/frontend/src/components/gamePage/leftSection/VideoFeed.tsx
index 541a533..453e89b 100644
--- a/packages/frontend/src/components/gamePage/leftSection/VideoFeed.tsx
+++ b/packages/frontend/src/components/gamePage/leftSection/VideoFeed.tsx
@@ -1,11 +1,17 @@
-import { useEffect } from 'react';
+import { memo } from 'react';
import VideoStream from '@/components/gamePage/stream/VideoStream';
import { useAuthStore } from '@/store/authStore';
import { useLocalStreamStore } from '@/store/localStreamStore';
import { usePeerConnectionStore } from '@/store/peerConnectionStore';
import { useRoomStore } from '@/store/roomStore';
-export default function VideoFeed() {
+const MemoizedVideoStream = memo(VideoStream);
+
+const EmptySlot = memo(({ idx, height }: { idx: number; height: string }) => (
+
+));
+
+const VideoFeed = memo(function VideoFeed() {
const localStream = useLocalStreamStore((state) => state.localStream);
const remoteStreams = usePeerConnectionStore((state) => state.remoteStreams);
const { userId } = useAuthStore();
@@ -21,10 +27,15 @@ export default function VideoFeed() {
boxShadow: isUserReady(userId || '') ? '0 0 0 4px white' : '',
}}
>
-
+
{isUserReady(userId || '') && (
- READY
+ READY
)}
@@ -35,7 +46,7 @@ export default function VideoFeed() {
isUserReady(remoteUserId) ? 'border-4 border-white' : ''
} rounded-lg`}
>
-
{isUserReady(remoteUserId) && (
-
+
READY
@@ -51,13 +62,10 @@ export default function VideoFeed() {
))}
{[...Array(Math.max(0, 5 - remoteStreams.size))].map((_, idx) => (
-
+
))}
>
);
-}
+});
+
+export default VideoFeed;
diff --git a/packages/frontend/src/hooks/useGameSocket.ts b/packages/frontend/src/hooks/useGameSocket.ts
index 241dba8..cefaed1 100644
--- a/packages/frontend/src/hooks/useGameSocket.ts
+++ b/packages/frontend/src/hooks/useGameSocket.ts
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useCallback, useRef } from 'react';
import { useSocketStore } from '@/store/socketStore';
import { useRoomStore } from '@/store/roomStore';
import { GAME_PHASE, GamePhase } from '@/constants';
@@ -30,74 +30,83 @@ export const useGameSocket = (onPhaseChange?: (phase: GamePhase) => void) => {
const [gameStartData, setGameStartData] = useState(null);
const [currentSpeaker, setCurrentSpeaker] = useState(null);
- useEffect(() => {
- if (!socket) return;
-
- const handleUpdateReady = (data: IReadyUsers) => {
+ const handleUpdateReady = useCallback(
+ (data: IReadyUsers) => {
setReadyUsers(data.readyUsers);
- };
+ },
+ [setReadyUsers],
+ );
- const handleStartSpeaking = (data: ISpeakingStart) => {
+ const handleStartSpeaking = useCallback(
+ (data: ISpeakingStart) => {
setCurrentSpeaker(data.speakerId);
- if (onPhaseChange) {
- onPhaseChange(GAME_PHASE.SPEAKING);
- }
- };
+ onPhaseChange?.(GAME_PHASE.SPEAKING);
+ },
+ [onPhaseChange],
+ );
- const handleStartGame = (data: IGameStart) => {
+ const handleStartGame = useCallback(
+ (data: IGameStart) => {
setAllUsers(data.allUserIds);
setGameStartData(data);
setCurrentSpeaker(data.speakerId);
setIsPinoco(data.isPinoco);
setReadyUsers([]);
- };
+ },
+ [setAllUsers, setIsPinoco, setReadyUsers],
+ );
- const handleStartVote = () => {
- if (onPhaseChange) {
- onPhaseChange(GAME_PHASE.VOTING);
- }
- };
+ const handleStartVote = useCallback(() => {
+ onPhaseChange?.(GAME_PHASE.VOTING);
+ }, [onPhaseChange]);
+
+ useEffect(() => {
+ if (!socket) return;
socket.on('update_ready', handleUpdateReady);
+ socket.on('start_game_success', handleStartGame);
+ socket.on('start_speaking', handleStartSpeaking);
+ socket.on('start_vote', handleStartVote);
socket.on('error', (data: IGameErrorMessage) => {
setError(data.errorMessage);
setTimeout(() => setError(null), 3000);
});
- socket.on('start_game_success', handleStartGame);
- socket.on('start_speaking', handleStartSpeaking);
- socket.on('start_vote', handleStartVote);
return () => {
socket.off('update_ready', handleUpdateReady);
- socket.off('error');
socket.off('start_game_success', handleStartGame);
socket.off('start_speaking', handleStartSpeaking);
socket.off('start_vote', handleStartVote);
+ socket.off('error');
};
- }, [socket, setIsPinoco, onPhaseChange, setCurrentSpeaker, setAllUsers, setReadyUsers]);
-
- const sendReady = (isReady: boolean) => {
- if (!socket) return;
- socket.emit('send_ready', { isReady });
- };
-
- const startGame = () => {
- if (!socket) return;
- socket.emit('start_game');
- };
-
- const endSpeaking = (userId: string) => {
- if (!socket) return;
- if (userId === currentSpeaker) {
- console.log('endSpeaking 호출');
- socket.emit('end_speaking');
- }
- };
-
- const votePinoco = (voteUserId: string) => {
- if (!socket) return;
- socket.emit('vote_pinoco', { voteUserId });
- };
+ }, [socket, handleUpdateReady, handleStartGame, handleStartSpeaking, handleStartVote]);
+
+ const sendReady = useCallback(
+ (isReady: boolean) => {
+ socket?.emit('send_ready', { isReady });
+ },
+ [socket],
+ );
+
+ const startGame = useCallback(() => {
+ socket?.emit('start_game');
+ }, [socket]);
+
+ const endSpeaking = useCallback(
+ (userId: string) => {
+ if (userId === currentSpeaker) {
+ socket?.emit('end_speaking');
+ }
+ },
+ [socket, currentSpeaker],
+ );
+
+ const votePinoco = useCallback(
+ (voteUserId: string) => {
+ socket?.emit('vote_pinoco', { voteUserId });
+ },
+ [socket],
+ );
return {
sendReady,
diff --git a/packages/frontend/src/store/roomStore.ts b/packages/frontend/src/store/roomStore.ts
index 75c6825..12eef71 100644
--- a/packages/frontend/src/store/roomStore.ts
+++ b/packages/frontend/src/store/roomStore.ts
@@ -17,37 +17,84 @@ interface IRoomState {
addReadyUser: (userId: string) => void;
removeReadyUser: (userId: string) => void;
}
+
export const useRoomStore = create()(
persist(
- (set) => ({
+ (set, get) => ({
isHost: false,
gsid: null,
isPinoco: false,
allUsers: new Set(),
readyUsers: [],
- setRoomData: (gsid, isHost, isPinoco) => set({ gsid, isHost, isPinoco }),
- setIsPinoco: (isPinoco) => set({ isPinoco }),
- setAllUsers: (allUsers) => set({ allUsers: new Set(allUsers) }),
+ setRoomData: (gsid, isHost, isPinoco) =>
+ set((state) => {
+ if (state.gsid === gsid && state.isHost === isHost && state.isPinoco === isPinoco) {
+ return state;
+ }
+ return { gsid, isHost, isPinoco };
+ }),
+
+ setIsPinoco: (isPinoco) =>
+ set((state) => {
+ if (state.isPinoco === isPinoco) return state;
+ return { isPinoco };
+ }),
+
+ setAllUsers: (allUsers) =>
+ set((state) => {
+ const newUsers = new Set(allUsers);
+ if (JSON.stringify([...state.allUsers]) === JSON.stringify([...newUsers])) {
+ return state;
+ }
+ return { allUsers: newUsers };
+ }),
+
addUser: (userId) =>
- set((state) => ({
- allUsers: new Set([...state.allUsers, userId]),
- })),
+ set((state) => {
+ if (state.allUsers.has(userId)) return state;
+ return {
+ allUsers: new Set([...state.allUsers, userId]),
+ };
+ }),
+
removeUser: (userId) =>
- set((state) => ({
- allUsers: new Set([...state.allUsers].filter((id) => id !== userId)),
- })),
- setIsHost: (isHost) => set({ isHost }),
+ set((state) => {
+ if (!state.allUsers.has(userId)) return state;
+ const newUsers = new Set([...state.allUsers]);
+ newUsers.delete(userId);
+ return { allUsers: newUsers };
+ }),
+
+ setIsHost: (isHost) =>
+ set((state) => {
+ if (state.isHost === isHost) return state;
+ return { isHost };
+ }),
+
+ setReadyUsers: (readyUsers) =>
+ set((state) => {
+ if (JSON.stringify(state.readyUsers) === JSON.stringify(readyUsers)) {
+ return state;
+ }
+ return { readyUsers };
+ }),
- setReadyUsers: (readyUsers) => set({ readyUsers }),
addReadyUser: (userId) =>
- set((state) => ({
- readyUsers: [...state.readyUsers, userId],
- })),
+ set((state) => {
+ if (state.readyUsers.includes(userId)) return state;
+ return {
+ readyUsers: [...state.readyUsers, userId],
+ };
+ }),
+
removeReadyUser: (userId) =>
- set((state) => ({
- readyUsers: state.readyUsers.filter((id) => id !== userId),
- })),
+ set((state) => {
+ if (!state.readyUsers.includes(userId)) return state;
+ return {
+ readyUsers: state.readyUsers.filter((id) => id !== userId),
+ };
+ }),
}),
{ name: 'room-storage' },
),