From ea360486112d0bc90b143b47a98da259996d22fd Mon Sep 17 00:00:00 2001 From: PARK NA HYUN <116629752+studioOwol@users.noreply.github.com> Date: Thu, 28 Nov 2024 14:09:25 +0900 Subject: [PATCH] =?UTF-8?q?[FE]=20=EB=B0=A9=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: useEffect 하나로 동작하도록 변경 * feat: Pagination을 위한 데이터 구조, 상태, API 변경 * feat: Pagination 버튼 컴포넌트 데이터 사용 * feat: 페이지네이션 기능 추가 SSE, RoomList, RoomListPage 로직 변경 및 상수 추가 * docs: README.md 업데이트 --- fe/README.md | 25 +++++++++ fe/src/components/common/SearchBar.tsx | 16 ++---- fe/src/constants/rules.ts | 1 + fe/src/hooks/usePagination.ts | 31 ----------- fe/src/hooks/useRoomsSSE.ts | 54 ++++++++++--------- .../RoomListPage/RoomList/Pagination.tsx | 38 +++++++------ .../pages/RoomListPage/RoomList/RoomList.tsx | 47 ++++++++-------- fe/src/pages/RoomListPage/index.tsx | 22 ++++++-- fe/src/stores/queries/getRoomsQuery.ts | 11 ++-- fe/src/stores/zustand/useRoomStore.ts | 23 ++++++-- fe/src/types/roomTypes.ts | 13 +++++ 11 files changed, 163 insertions(+), 118 deletions(-) delete mode 100644 fe/src/hooks/usePagination.ts diff --git a/fe/README.md b/fe/README.md index 3209eed..3221f1b 100644 --- a/fe/README.md +++ b/fe/README.md @@ -86,3 +86,28 @@ npm install react@latest react-dom@latest - Player 컴포넌트 내부에 isMuted 초기 상태를 정해주고, muteStatus 데이터 상태가 변경되었을 때 isMuted를 변경해 주는 방식으로 바꿨다. - 이렇게 해서 Player 컴포넌트와 GameScreen 컴포넌트를 독립적으로 리렌더링 해줄 수 있게 됐다. - 문제: muteStatus의 initial state를 null로 설정하니까 처음에 가져올 때 에러 발생해서 빈 객체로 초기화 + +### 검색과 실시간성은 분리하자 + +- 한 사용자가 검색 중일 때 새로운 방이 생성되거나 삭제된 경우 이를 실시간으로 반영해서 필터링해야 하나 고민을 했다. +- 사용자 입장에서 필터링된 방이 갑자기 새로 생기거나 없어지는 게 이상할 것 같다는 생각이 들었다. +- 그래서 검색어를 지우면 1페이지의 방 목록을 보여주도록 함 + - 대신 검색으로 필터링된 방이 삭제된 경우에는 `삭제된 방입니다.`와 같은 알림을 띄우는 게 어떨까 + - 검색 중 필터링된 방이 삭제되면 에러 발생 -> 이 경우는 나중에 해결하기로 +- 아.. 근데 1페이지가 꽉 차지 않은 경우에는 방 생성, 방 삭제 시 방 목록이 업데이트돼서 SSE로 리렌더링 될 텐데.. 하 모르겠다 + +### 실시간 통신 페이지네이션 이렇게 어려울 일이야? + +- 페이지네이션 하려면 서버에서 전체 방 개수, 혹은 페이지 개수 등의 정보를 내려줘야 한다. + - Taskify 할 때 엄청 고민했던 부분이었다. DB에 count 컬럼을 두고 관리했던.. +- 그래야 페이지를 이동시킬 수 있고, 페이지를 이동할 수 있어야 해당 페이지 번호로 요청을 보내서, 해당 페이지의 방 목록을 받아와 렌더링 해줄 수 있기 때문이다. +- 그래서 데이터 구조도 바뀌었고, SSE 부분도 다 바꿔줘야 했다. 머리가 터질 것 같다. + - SSE는 해결된 걸 확인하긴 했는데, REST API 쪽이 문제인 것 같다. 초기 데이터가 null로 오는 건가..? 나는 뭔지 모르겠다. +- 서버 쪽도 갑자기 많은 걸 바꿔서 그런지 자꾸 서버 에러가 나서, 나는 아무래도 기다려야 할 것 같다.🫠 +- 원인을 알아냈다. + - 지금 페이지네이션 버튼 생성은 REST API에 의존하고 있다. 위에서 말한 것처럼 전체 방 개수나, 총 페이지 개수를 받아와야 얘로 버튼을 만들 수 있고, 띄워줄 수 있다. + - 초기 페이지 번호를 0으로 Store에서 가지고 있고(프론트에서 먼저 페이지네이션을 구현할 때 인덱스로 사용한 부분이 있어서 0으로 했음), 초기 데이터 가져올 때 이 상태 값을 가지고 REST API 요청하고 받아 온 데이터를 렌더링 해준다. + - 지금 REST API 응답은 data: {rooms: [], pagination: {}} 구조인데, SSE 응답은 rooms 배열뿐이다. + - 메인 페이지에 입장해서 방 생성, 삭제를 하지 않은 사용자에게 실시간으로 페이지네이션 버튼이 뜨도록 하려면 SSE 응답에도 pagination 정보, 적어도 현재 페이지 정보를 같이 받아와서 상태를 변경시켜 줄 수 있어야 한다. 그렇지 않으면 초기에 설정된 0(1페이지)으로만 계속 SSE 요청을 보내게 된다. + - REST API를 또 요청하면 되지 않나? 싶어서 refetch도 시켜줬지만 무의미한 일이었다. 버튼이 생겨야 현재 페이지 상태를 변경시켜 줄 수 있기 때문이다. + - 현재 상황에서 새로고침을 하지 않는 한 실시간으로 페이지네이션 버튼을 띄울 수 없는 것 같다는 게 결론이다..! -> 서버에 데이터 구조 맞춰서 내려달라고 요청함 diff --git a/fe/src/components/common/SearchBar.tsx b/fe/src/components/common/SearchBar.tsx index 9c4d8f0..b6ca7cd 100644 --- a/fe/src/components/common/SearchBar.tsx +++ b/fe/src/components/common/SearchBar.tsx @@ -9,14 +9,15 @@ import { getRoomsQuery } from '@/stores/queries/getRoomsQuery'; const SearchBar = () => { const [searchTerm, setSearchTerm] = useState(''); const debouncedSearch = useDebounce(searchTerm, 200); // 200ms 디바운스 - const { setRooms } = useRoomStore(); + const { setRooms, userPage } = useRoomStore(); const { data: searchResults } = searchRoomsQuery(debouncedSearch); - const { data: allRooms, refetch: refetchAllRooms } = getRoomsQuery(); + const { data: roomsData, refetch: refetchAllRooms } = getRoomsQuery(userPage); // 검색 결과 또는 전체 방 목록으로 업데이트 useEffect(() => { - if (!debouncedSearch.trim()) { + if (!debouncedSearch.trim() && roomsData?.rooms) { refetchAllRooms(); + setRooms(roomsData.rooms); return; } @@ -24,14 +25,7 @@ const SearchBar = () => { if (searchResults) { setRooms(searchResults); } - }, [debouncedSearch, searchResults, setRooms, refetchAllRooms]); - - // allRooms가 업데이트되면 방 목록 갱신 - useEffect(() => { - if (!debouncedSearch.trim() && allRooms) { - setRooms(allRooms); - } - }, [allRooms, debouncedSearch, setRooms]); + }, [debouncedSearch, searchResults, roomsData, setRooms, refetchAllRooms]); return (
diff --git a/fe/src/constants/rules.ts b/fe/src/constants/rules.ts index f17a3b9..de09f9c 100644 --- a/fe/src/constants/rules.ts +++ b/fe/src/constants/rules.ts @@ -1,4 +1,5 @@ export const RULES = Object.freeze({ maxPage: 9, maxPlayer: 4, + pageLimit: 9, }); diff --git a/fe/src/hooks/usePagination.ts b/fe/src/hooks/usePagination.ts deleted file mode 100644 index b6269cd..0000000 --- a/fe/src/hooks/usePagination.ts +++ /dev/null @@ -1,31 +0,0 @@ -import useRoomStore from '@/stores/zustand/useRoomStore'; -import { useState, useEffect } from 'react'; - -const ROOMS_PER_PAGE = 9; - -export const usePagination = () => { - const rooms = useRoomStore((state) => state.rooms); - const [currentPage, setCurrentPage] = useState(0); - - const totalPages = Math.ceil(rooms.length / ROOMS_PER_PAGE); - - const currentRooms = rooms.slice( - currentPage * ROOMS_PER_PAGE, - (currentPage + 1) * ROOMS_PER_PAGE - ); - - useEffect(() => { - if (rooms.length > ROOMS_PER_PAGE * (currentPage + 1)) { - setCurrentPage(currentPage + 1); - } - }, [rooms.length, currentPage]); - - return { - currentPage, - setCurrentPage, - totalPages, - currentRooms, - isEmpty: rooms.length === 0, - showPagination: rooms.length > ROOMS_PER_PAGE, - }; -}; diff --git a/fe/src/hooks/useRoomsSSE.ts b/fe/src/hooks/useRoomsSSE.ts index 797bb54..00ebcec 100644 --- a/fe/src/hooks/useRoomsSSE.ts +++ b/fe/src/hooks/useRoomsSSE.ts @@ -1,47 +1,53 @@ import useRoomStore from '@/stores/zustand/useRoomStore'; import { useEffect } from 'react'; -import { Room } from '@/types/roomTypes'; import { ENV } from '@/config/env'; import { getRoomsQuery } from '@/stores/queries/getRoomsQuery'; -export const useRoomsSSE = () => { - const { data: initialRooms } = getRoomsQuery(); - const { setRooms } = useRoomStore(); +let eventSource: EventSource | null = null; - useEffect(() => { - // 초기 데이터 설정 - if (initialRooms) { - setRooms(initialRooms); - } +export const useRoomsSSE = () => { + const { setRooms, setPagination, setUserPage } = useRoomStore(); + const userPage = useRoomStore((state) => state.userPage); + const { data } = getRoomsQuery(userPage); - // SSE 연결 - const eventSource = new EventSource(ENV.SSE_URL); + const connectSSE = (userPage: number) => { + eventSource = new EventSource(`${ENV.SSE_URL}?page=${userPage}`); - // rooms 데이터 수신 처리 eventSource.onmessage = (event) => { try { - const rooms = JSON.parse(event.data) as Room[]; - setRooms(rooms); + const sseData = JSON.parse(event.data); + setRooms(sseData.rooms); + setPagination(sseData.pagination); + + if (!sseData.rooms.length && userPage > 0) { + setUserPage(sseData.pagination.currentPage - 1); + return; + } + + setUserPage(sseData.pagination.currentPage); } catch (error) { console.error('Failed to parse rooms data:', error); } }; - // 연결 시작 - eventSource.onopen = () => { - console.log('SSE Connection opened'); - }; - - // 에러 처리 eventSource.onerror = (error) => { console.error('SSE Error:', error); eventSource.close(); }; + }; + + useEffect(() => { + if (data) { + setRooms(data.rooms); + setPagination(data.pagination); + connectSSE(userPage); + } - // 컴포넌트 언마운트 시 연결 정리 (메모리 누수 예방) return () => { - console.log('Closing SSE connection'); - eventSource.close(); + if (eventSource) { + eventSource.close(); + eventSource = null; + } }; - }, [initialRooms, setRooms]); + }, [data?.pagination, data?.rooms, userPage]); }; diff --git a/fe/src/pages/RoomListPage/RoomList/Pagination.tsx b/fe/src/pages/RoomListPage/RoomList/Pagination.tsx index 1fa9032..6d2639c 100644 --- a/fe/src/pages/RoomListPage/RoomList/Pagination.tsx +++ b/fe/src/pages/RoomListPage/RoomList/Pagination.tsx @@ -1,24 +1,30 @@ import { Button } from '@/components/ui/button'; +import { getRoomsQuery } from '@/stores/queries/getRoomsQuery'; +import useRoomStore from '@/stores/zustand/useRoomStore'; +import { useEffect } from 'react'; import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6'; -interface PaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; -} +const Pagination = () => { + const { pagination, setUserPage } = useRoomStore(); + const userPage = useRoomStore((state) => state.userPage); + const { totalPages } = pagination; + const { refetch } = getRoomsQuery(userPage); + + useEffect(() => { + refetch(); + }, [userPage, refetch]); + + const handlePageChange = async (newPage: number) => { + setUserPage(newPage); + }; -const Pagination = ({ - currentPage, - totalPages, - onPageChange, -}: PaginationProps) => { return (
@@ -26,9 +32,9 @@ const Pagination = ({ {Array.from({ length: totalPages }, (_, i) => ( diff --git a/fe/src/pages/RoomListPage/RoomList/RoomList.tsx b/fe/src/pages/RoomListPage/RoomList/RoomList.tsx index 3a6886a..8434d0e 100644 --- a/fe/src/pages/RoomListPage/RoomList/RoomList.tsx +++ b/fe/src/pages/RoomListPage/RoomList/RoomList.tsx @@ -1,22 +1,27 @@ import GameRoom from './GameRoom'; import Pagination from './Pagination'; -import { usePagination } from '@/hooks/usePagination'; import { RULES } from '@/constants/rules'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import JoinDialog from '../RoomDialog/JoinDialog'; +import useRoomStore from '@/stores/zustand/useRoomStore'; const RoomList = () => { - const { - currentPage, - setCurrentPage, - totalPages, - currentRooms, - isEmpty, - showPagination, - } = usePagination(); - + const rooms = useRoomStore((state) => state.rooms); + const pagination = useRoomStore((state) => state.pagination); const [isJoinDialogOpen, setIsJoinDialogOpen] = useState(false); const [selectedRoomId, setSelectedRoomId] = useState(null); + const [showPagination, setShowPagination] = useState(false); + const isEmpty = rooms.length === 0; + + useEffect(() => { + if (pagination?.totalPages > 1) { + setShowPagination(true); + } + + if (pagination?.totalPages === 1) { + setShowPagination(false); + } + }, [pagination]); const onJoinRoom = (roomId: string) => { setSelectedRoomId(roomId); @@ -26,23 +31,17 @@ const RoomList = () => { return (
- {currentRooms.map((room) => ( + {rooms.map((room) => ( ))} - {currentRooms.length > 0 && - currentRooms.length < RULES.maxPage && - Array.from({ length: RULES.maxPage - currentRooms.length }).map( - (_, i) =>
- )} + {rooms.length > 0 && + rooms.length < RULES.maxPage && + Array.from({ length: RULES.maxPage - rooms.length }).map((_, i) => ( +
+ ))}
- {showPagination && ( - - )} + {showPagination && } {isEmpty && (
diff --git a/fe/src/pages/RoomListPage/index.tsx b/fe/src/pages/RoomListPage/index.tsx index ad7637f..84d52a2 100644 --- a/fe/src/pages/RoomListPage/index.tsx +++ b/fe/src/pages/RoomListPage/index.tsx @@ -1,13 +1,16 @@ import SearchBar from '@/components/common/SearchBar'; import RoomHeader from './RoomHeader/RoomHeader'; import RoomList from './RoomList/RoomList'; -import { useRoomsSSE } from '@/hooks/useRoomsSSE'; import { useEffect, useState } from 'react'; import CustomAlertDialog from '@/components/common/CustomAlertDialog'; +import useRoomStore from '@/stores/zustand/useRoomStore'; +import { useRoomsSSE } from '@/hooks/useRoomsSSE'; const RoomListPage = () => { const [showAlert, setShowAlert] = useState(false); const [kickedRoomName, setKickedRoomName] = useState(''); + const { rooms } = useRoomStore(); + const isEmpty = rooms.length === 0; useRoomsSSE(); @@ -22,10 +25,19 @@ const RoomListPage = () => { }, []); return ( -
- - - +
+
+ + + {isEmpty ? ( +
+ 생성된 방이 없습니다. +
+ ) : ( + + )} +
+ { +export const getRoomsQuery = (currentPage: number) => { return useQuery({ queryKey: ['rooms'], queryFn: async () => { - const response = await gameAPI.get('/api/rooms'); - return response.data; + const { data } = await gameAPI.get>( + `/api/rooms?page=${currentPage}` + ); + + return data; }, }); }; diff --git a/fe/src/stores/zustand/useRoomStore.ts b/fe/src/stores/zustand/useRoomStore.ts index ba4cbf4..36c82aa 100644 --- a/fe/src/stores/zustand/useRoomStore.ts +++ b/fe/src/stores/zustand/useRoomStore.ts @@ -1,4 +1,4 @@ -import { Room } from '@/types/roomTypes'; +import { PaginationData, Room } from '@/types/roomTypes'; import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; @@ -7,6 +7,8 @@ interface RoomStore { currentRoom: Room | null; currentPlayer: string | null; kickedPlayer: string | null; + pagination: PaginationData | null; + userPage: number; } interface RoomActions { @@ -14,13 +16,17 @@ interface RoomActions { setCurrentRoom: (room: Room) => void; setCurrentPlayer: (nickname: string) => void; setKickedPlayer: (nickname: string) => void; + setPagination: (pagination: PaginationData) => void; + setUserPage: (userPage: number) => void; } const initialState: RoomStore = { rooms: [], currentRoom: null, - currentPlayer: null, - kickedPlayer: null, + currentPlayer: '', + kickedPlayer: '', + pagination: null, + userPage: 0, }; const useRoomStore = create()( @@ -46,6 +52,17 @@ const useRoomStore = create()( set(() => ({ kickedPlayer: nickname, })), + + setPagination: (pagination) => + set(() => ({ + pagination, + })), + + setUserPage: (userPage) => { + set(() => ({ + userPage, + })); + }, })) ); diff --git a/fe/src/types/roomTypes.ts b/fe/src/types/roomTypes.ts index 8f7e9c6..af205da 100644 --- a/fe/src/types/roomTypes.ts +++ b/fe/src/types/roomTypes.ts @@ -15,3 +15,16 @@ export interface RoomDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } + +export interface PaginationData { + currentPage: number; + totalPages: number; + totalItems: number; + hasNextPage: boolean; + hasPreviousPage: boolean; +} + +export interface PaginatedResponse { + rooms: T[]; + pagination: PaginationData; +}