diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx
index b173e2c8..c25df66d 100644
--- a/packages/frontend/src/App.tsx
+++ b/packages/frontend/src/App.tsx
@@ -1,6 +1,5 @@
-import { RouterProvider } from 'react-router-dom';
-import { router } from './routes/router';
import { useEffect } from 'react';
+import AppRouter from './routes/router';
function App() {
useEffect(() => {
@@ -10,17 +9,12 @@ function App() {
};
window.addEventListener('beforeunload', handleUnload);
-
return () => {
window.removeEventListener('beforeunload', handleUnload);
};
}, []);
- return (
- <>
-
- >
- );
+ return ;
}
export default App;
diff --git a/packages/frontend/src/assets/fonts/Pretendard-Bold.woff b/packages/frontend/src/assets/fonts/Pretendard-Bold.woff
deleted file mode 100644
index 7837ae52..00000000
Binary files a/packages/frontend/src/assets/fonts/Pretendard-Bold.woff and /dev/null differ
diff --git a/packages/frontend/src/assets/fonts/Pretendard-Medium.woff b/packages/frontend/src/assets/fonts/Pretendard-Medium.woff
deleted file mode 100644
index 53704091..00000000
Binary files a/packages/frontend/src/assets/fonts/Pretendard-Medium.woff and /dev/null differ
diff --git a/packages/frontend/src/assets/fonts/Pretendard-Regular.woff b/packages/frontend/src/assets/fonts/Pretendard-Regular.woff
deleted file mode 100644
index e3b3a358..00000000
Binary files a/packages/frontend/src/assets/fonts/Pretendard-Regular.woff and /dev/null differ
diff --git a/packages/frontend/src/assets/fonts/Pretendard-SemiBold.woff b/packages/frontend/src/assets/fonts/Pretendard-SemiBold.woff
deleted file mode 100644
index 682e7a45..00000000
Binary files a/packages/frontend/src/assets/fonts/Pretendard-SemiBold.woff and /dev/null differ
diff --git a/packages/frontend/src/components/common/LoadingSpinner.tsx b/packages/frontend/src/components/common/LoadingSpinner.tsx
new file mode 100644
index 00000000..bbf08bc1
--- /dev/null
+++ b/packages/frontend/src/components/common/LoadingSpinner.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+
+const LoadingSpinner: React.FC = () => {
+ return (
+
+ );
+};
+
+export default LoadingSpinner;
diff --git a/packages/frontend/src/components/gamePage/leftSection/GamePhases/Voting.tsx b/packages/frontend/src/components/gamePage/leftSection/GamePhases/Voting.tsx
index a87bd61a..f610d9d1 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 91b8a83f..678c711f 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 891cb048..a7b817e0 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 f61ec3e4..e6e1e469 100644
--- a/packages/frontend/src/components/gamePage/leftSection/VideoFeed.tsx
+++ b/packages/frontend/src/components/gamePage/leftSection/VideoFeed.tsx
@@ -1,10 +1,17 @@
+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();
@@ -25,15 +32,20 @@ export default function VideoFeed() {
: ''
}`}
>
-
+
{isUserReady(userId || '') && (
- READY
+ READY
)}
{isHost(userId || '') && (
- HOST
+ HOST
)}
@@ -49,7 +61,7 @@ export default function VideoFeed() {
: ''
}`}
>
-
{isUserReady(remoteUserId) && (
-
+
READY
)}
{isHost(remoteUserId) && (
- HOST
+ HOST
)}
))}
{[...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 241dba82..cefaed17 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/index.css b/packages/frontend/src/index.css
index 77aae49c..745e5beb 100644
--- a/packages/frontend/src/index.css
+++ b/packages/frontend/src/index.css
@@ -7,7 +7,6 @@
font-weight: 400;
src:
local('Pretendard Regular'),
- url('./assets/fonts/Pretendard-Regular.woff') format('woff'),
url('./assets/fonts/Pretendard-Regular.woff2') format('woff2');
}
@@ -16,7 +15,6 @@
font-weight: 500;
src:
local('Pretendard Medium'),
- url('./assets/fonts/Pretendard-Medium.woff') format('woff'),
url('./assets/fonts/Pretendard-Medium.woff2') format('woff2');
}
@@ -25,6 +23,5 @@
font-weight: 700;
src:
local('Pretendard Bold'),
- url('./assets/fonts/Pretendard-Bold.woff') format('woff'),
url('./assets/fonts/Pretendard-Bold.woff2') format('woff2');
}
diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx
index 2239905c..6a0a2d0f 100644
--- a/packages/frontend/src/main.tsx
+++ b/packages/frontend/src/main.tsx
@@ -1,10 +1,5 @@
-import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
-createRoot(document.getElementById('root')!).render(
-
-
- ,
-);
+createRoot(document.getElementById('root')!).render();
diff --git a/packages/frontend/src/routes/router.tsx b/packages/frontend/src/routes/router.tsx
index a6f61dfb..2b007930 100644
--- a/packages/frontend/src/routes/router.tsx
+++ b/packages/frontend/src/routes/router.tsx
@@ -1,40 +1,54 @@
-import { createBrowserRouter } from 'react-router-dom';
+import LoadingSpinner from '@/components/common/LoadingSpinner';
import Layout from '@/components/layout/Layout';
-import LandingPage from '@/pages/landingPage/index';
-import LobbyPage from '@/pages/lobbyPage/index';
-import GamePage from '@/pages/gamePage/index';
-import PublicRoute from '@/components/PublicRoute';
+import { lazy } from 'react';
import PrivateRoute from '@/components/PrivateRoute';
+import PublicRoute from '@/components/PublicRoute';
+import { Suspense } from 'react';
+import { createBrowserRouter, RouterProvider } from 'react-router-dom';
+
+const LandingPage = lazy(() => import('@/pages/landingPage/index'));
+const LobbyPage = lazy(() => import('@/pages/lobbyPage/index'));
+const GamePage = lazy(() => import('@/pages/gamePage/index'));
+
+export default function AppRouter() {
+ const router = createBrowserRouter([
+ {
+ path: '/',
+ element: ,
+ children: [
+ {
+ index: true,
+ element: (
+
+ }>
+
+
+
+ ),
+ },
+ {
+ path: 'lobby',
+ element: (
+
+ }>
+
+
+
+ ),
+ },
+ {
+ path: 'game/:gsid',
+ element: (
+
+ }>
+
+
+
+ ),
+ },
+ ],
+ },
+ ]);
-export const router = createBrowserRouter([
- {
- path: '/',
- element: ,
- children: [
- {
- index: true,
- element: (
-
-
-
- ),
- },
- {
- path: 'lobby',
- element: (
-
-
-
- ),
- },
- {
- path: 'game/:gsid',
- element: (
-
-
-
- ),
- },
- ],
- },
-]);
+ return ;
+}
diff --git a/packages/frontend/src/store/roomStore.ts b/packages/frontend/src/store/roomStore.ts
index 21c67bb8..04a823f7 100644
--- a/packages/frontend/src/store/roomStore.ts
+++ b/packages/frontend/src/store/roomStore.ts
@@ -24,9 +24,10 @@ interface IRoomState {
removeReadyUser: (userId: string) => void;
setHostUserId: (hostUserId: string) => void;
}
+
export const useRoomStore = create()(
persist(
- (set) => ({
+ (set, get) => ({
isHost: false,
gsid: null,
isPinoco: false,
@@ -35,27 +36,72 @@ export const useRoomStore = create()(
hostUserId: null,
setRoomData: (gsid, isHost, isPinoco, hostUserId) =>
- set({ gsid, isHost, isPinoco, hostUserId }),
- setIsPinoco: (isPinoco) => set({ isPinoco }),
- setAllUsers: (allUsers) => set({ allUsers: new Set(allUsers) }),
+ set((state) => {
+ if (state.gsid === gsid && state.isHost === isHost && state.isPinoco === isPinoco) {
+ return state;
+ }
+ return { gsid, isHost, isPinoco, hostUserId };
+ }),
+
+ 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 }),
- setReadyUsers: (readyUsers) => set({ readyUsers }),
+ 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 };
+ }),
+
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),
+ };
+ }),
setHostUserId: (hostUserId) => set({ hostUserId }),
}),
{ name: 'room-storage' },