-
리뷰 탭에 있는 페이지네이션을 구현해 보려고 한다.
내가 생각하는 리뷰는 5개 까지 불러오고, 5개 이상이면 페이지네이션으로 다음 페이지로 이동할 수 있게 구현해 보려고했다.
이번에 TanStack Query에서 제공해주는 라이브러리로 적용해 보려고 한다😊
supabase 데이터 불러오기
이전 코드
import { supabase } from "@/lib/supabase/client"; export const getGoodsReviews = async (goodsId: string) => { try { const { data, error } = await supabase .from("goods_reviews") .select( `*, user:user_id( name ) ` ) .eq("goods_id", goodsId) .order("created_at", { ascending: false }) .limit(5); if (error) throw error; return data; } catch (error) { if (error instanceof Error) { throw new Error(`리뷰 정보를 가져오는 데 실패했습니다. ${error.message}`); } throw error; } };
변경된 코드
import { supabase } from "@/lib/supabase/client"; export const getGoodsReviews = async (goodsId: string, page: number) => { const itemsPerPage = 5; const from = (page - 1) * itemsPerPage; const to = from + itemsPerPage - 1; try { //전체 개수 가져오기 const { count } = await supabase .from("goods_reviews") .select("*", { count: "exact", head: true }) .eq("goods_id", goodsId); //페이지 데이터 가져오기 const { data, error } = await supabase .from("goods_reviews") .select( `*, user:user_id( name ) ` ) .eq("goods_id", goodsId) .order("created_at", { ascending: false }) .range(from, to); if (error) throw error; return { reviews: data || [], totalCount: count || 0, }; } catch (error) { if (error instanceof Error) { throw new Error(`리뷰 정보를 가져오는 데 실패했습니다. ${error.message}`); } throw error; } };
기존에 있던 `limit(5)`를 제거하고 전체 개수 가져오는 것과 페이지 데이터 가져오는 것을 추가했다.
const itemsPerPage = 5; //한 페이지당 보여줄 아이템 수 const from = (page - 1) * itemsPerPage; //시작 인덱스 const to = from + itemsPerPage - 1; //끝 인덱스
예시로 2페이지를 요청하면
`from` = (2-1) * 5 = 5 (5번째 아이템부터)
`to` = 5 + 5 - 1 = 9 (9번째 아이템까지)
const { count } = await supabase .from("goods_reviews") .select("*", { count: "exact", head: true }) .eq("goods_id", goodsId);
페이지네이션에서 전체 아이템 갯수가 필요해서 수파베이스에서 제공해주는 속성을 사용했다.
`count : "exact"` : 정확한 전체 개수를 가져옴
`head : true`: 실제 데이터는 필요 없고 개수만 필요하다는 의미
.range(from, to) //이전의 .limit(5) 대신 지우고 변경!
`limit(5)`는항상 처음부터 5개만 가져오기 때문에, 페이지네이션을 구현하려면 range 속성을 사용해야 했다.
`range(from, to)`는 특정 범위의 데이터를 가져올 수 있도록 변경했다.
return { reviews: data || [], // data가 null/undefined면 빈 배열([])을 사용 totalCount: count || 0, // count가 null/undefined면 0을 사용 };
return 에서는 UI에서 전체 개수가 필요하기 때문에 데이터와 전체 개수를 같이 반환하도록 변경하였다.
- `data`는 항상 배열이 됨을 보장하기위해 || [] 추가하였음.
- `count`는 항상 숫자 임을 보장하기위해 || 0 추가하였음.
Query 변경
이전 코드
export const useReviews = (id: string) => { return useQuery({ queryKey: ["reviews", id], queryFn: () => getGoodsReviews(id), }); };
추가된 코드
interface UseReviewsProps { id: string; page: number; } export const useReviews = ({ id, page }: UseReviewsProps) => { return useQuery({ queryKey: ["reviews", id, page] as const, queryFn: async (): Promise<ReviewResponse> => getGoodsReviews(id, page), placeholderData: (previousData) => previousData, }); };
페이지네이션을 위해 page매개변수가 주어져서 타입을 추가했다.
queryKey: ["reviews", id, page] as const, // 이전: ["reviews", id]
페이지가 바뀔 때 새로운 데이터를 가져와야 하므로 id와 page를 가져온다.
`as const` 는 타입스크립트가 이 배열이 이 값들만 가질 수 있다고 알려주는 역할이다.
//타입 지정 export interface ReviewResponse { reviews: ReviewItem[]; //리뷰 데이터 배열 totalCount: number; //전체 리뷰 수 }
queryFn: async (): Promise<ReviewResponse> => getGoodsReviews(id, page),
반환되는 데이터 타입을 명시적으로 지정해준다.
getGoodsReviews 가 supabase를 호출하는 비동기 함수이기 때문에 promise를 붙여주고
비동기 함수는 Promise를 반환하기 때문에 Promise를 붙여준다.
- ReviewResponse - 반환될 데이터의 구체적인 타입
- Promise<ReviewResponse> - 비동기 함수니까 Promise로 감싸줌
- async - getGoodsReviews가 비동기 함수라서 필요
placeholderData: (previousData) => previousData,
페이지 전환 시 새 데이터를 로딩하는 동안
이전 데이터를 유지해야 레이아웃이 깨지지 않기 때문에 이전 데이터를 임시로 보여준다. (시프트 현상 깜빡임 방지)
적용할 컴포넌트
//ReviewTab.tsx const ReviewTab = ({ id }: ShopMenuProps) => { const [currentPage, setCurrentPage] = useState(1); const { data, isLoading, isError } = useReviews({ id, page: currentPage }); const reviews = data?.reviews || []; const totalCount = data?.totalCount || 0; const totalPages = Math.ceil(totalCount / PAGINATION.ITEMS_PER_PAGE); // 평균 별점 계산 const averageRating = reviews.length ? reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length : 0; // 평균 별점 반올림 const roundedRating = Math.round(averageRating * 10) / 10; const handlePageChange = (page: number) => { setCurrentPage(page); }; return ( <> {totalCount > 0 ? ( <> <div className="flex justify-center items-center mb-10"> <div className="flex gap-1 mr-5"> <RenderStars rating={roundedRating} size={25} /> </div> <div className="flex items-center gap-2"> <strong className="text-2xl">{roundedRating}</strong> <p className="text-dark-gray translate-y-[1px]">/5.0</p> </div> </div> <div> <h2 className="text-xl font-bold mb-2"> 리뷰 ({totalCount}) </h2> <ul> {reviews?.map((item) => ( <ReviewItems key={item.id} item={item} /> ))} </ul> {totalPages > 1 && ( <PaginationControl currentPage={currentPage} totalPages={totalPages} onPageChange={handlePageChange} maxDisplayPages={PAGINATION.MAX_DISPLAY_PAGES} /> )} </div> </> ) : ( <> {/* 생략 */} </> )} </> ); }; export default ReviewTab;
const [currentPage, setCurrentPage] = useState(1); //현재 페이지 상태 (1) const { data, isLoading, isError } = useReviews({ id, page: currentPage });
currentPage에는 state로 현재 페이지 상태를 담는다.
그리고 useReviews에 page를 매개변수로 넘겨준다.
const reviews = data?.reviews || []; const totalCount = data?.totalCount || 0; const totalPages = Math.ceil(totalCount / PAGINATION.ITEMS_PER_PAGE);
`reviews`는 현재 리뷰 데이터를 가져오고, 없으면 빈 배열로 만든다.
`totalCount`는 전체 리뷰수를 가져오고, 없으면 0으로 가져온다.
`totalPages` 는 전체 페이지 수를 계산해서 5로 나눈다 (5개만 보여주기 위해)
const handlePageChange = (page: number) => { setCurrentPage(page); // 페이지 번호 변경! };
핸들러 함수는 현재 페이지 상태를 업데이트 해준다.
이 함수는 페이지네이션 컴포넌트가 호출이 되면, 새로운 페이지 번호로 상태를 업데이트 하고
`currentPage`가 변경되면 `useReviews`에서 새 페이지의 데이터를 가져온다.
{totalPages > 1 && ( <PaginationControl currentPage={currentPage} totalPages={totalPages} onPageChange={handlePageChange} maxDisplayPages={PAGINATION.MAX_DISPLAY_PAGES} /> )}
페이지네이션 컴포넌트에 props로 필요한 데이터를 넘겨준다.
pagination 컴포넌트
interface PaginationControlProps { currentPage: number; totalPages: number; onPageChange: (page: number) => void; maxDisplayPages: number; } const PaginationControl = ({ currentPage, totalPages, onPageChange, maxDisplayPages, }: PaginationControlProps) => { const getPageNumbers = () => { let startPage: number; let endPage: number; if (totalPages <= maxDisplayPages) { startPage = 1; endPage = totalPages; } else { const halfDisplay = Math.floor(maxDisplayPages / 2); if (currentPage <= halfDisplay) { startPage = 1; endPage = maxDisplayPages; } else if (currentPage + halfDisplay >= totalPages) { startPage = totalPages - maxDisplayPages + 1; endPage = totalPages; } else { startPage = currentPage - halfDisplay; endPage = currentPage + halfDisplay; } } return Array.from( { length: endPage - startPage + 1 }, (_, i) => startPage + i ); }; const pages = getPageNumbers(); return ( <Pagination className="mt-10"> <PaginationContent> <PaginationItem> <PaginationPrevious href="#" onClick={(e) => { e.preventDefault(); if (currentPage > 1) onPageChange(currentPage - 1); }} className={currentPage <= 1 ? "pointer-events-none opacity-50" : ""} /> </PaginationItem> {pages[0] > 1 && ( <> <PaginationItem> <PaginationLink href="#" onClick={(e) => { e.preventDefault(); onPageChange(1); }} > 1 </PaginationLink> </PaginationItem> {pages[0] > 2 && ( <PaginationItem> <PaginationEllipsis /> </PaginationItem> )} </> )} {pages.map((page) => ( <PaginationItem key={page}> <PaginationLink href="#" onClick={(e) => { e.preventDefault(); onPageChange(page); }} isActive={currentPage === page} > {page} </PaginationLink> </PaginationItem> ))} {pages[pages.length - 1] < totalPages && ( <> {pages[pages.length - 1] < totalPages - 1 && ( <PaginationItem> <PaginationEllipsis /> </PaginationItem> )} <PaginationItem> <PaginationLink href="#" onClick={(e) => { e.preventDefault(); onPageChange(totalPages); }} > {totalPages} </PaginationLink> </PaginationItem> </> )} <PaginationItem> <PaginationNext href="#" onClick={(e) => { e.preventDefault(); if (currentPage < totalPages) onPageChange(currentPage + 1); }} className={ currentPage >= totalPages ? "pointer-events-none opacity-50" : "" } /> </PaginationItem> </PaginationContent> </Pagination> ); }; export default PaginationControl;
🥲 복잡해서 하나하나씩 정리가 필요했다.
- currentPage : 지금 보고 있는 페이지
- totalPages : 전체 페이지 수
- onPageChange : 페이지 클릭했을 때 실행할 함수
- maxDisplayPages : 한 번에 보여줄 페이지 번호 개수
if (totalPages <= maxDisplayPages) { // 전체 페이지가 maxDisplayPages 이하면 모든 페이지 표시 startPage = 1; endPage = totalPages; }
전체 페이지가 `maxDisplayPages`보다 작으면
ex) 전체 3페이지, maxDisplayPages가 5면 1,2,3 모두 표시
else { // maxDisplayPages/2 만큼 현재 페이지 좌우로 표시 const halfDisplay = Math.floor(maxDisplayPages / 2);
전체 페이지가 maxDisplayPages 보다 많으면 양쪽에 몇개의 페이지를 보여줄지 계산한다.
ex) maxDisplayPages가 5라면 halfDisplay = Math.floor(5/2) = 2
즉 현재페이지 좌우로 2개씩 페이지 번호가 표시된다.
[1] ... [5] [6] [7] [8] [9] ... [15] ← 현재 → 2개 페이지 2개
if (currentPage <= halfDisplay) { // 시작 부분 startPage = 1; endPage = maxDisplayPages; } else if (currentPage + halfDisplay >= totalPages) { // 끝 부분 startPage = totalPages - maxDisplayPages + 1; endPage = totalPages; } else { // 중간 startPage = currentPage - halfDisplay; endPage = currentPage + halfDisplay; } }
시작 부분에 있을 때의 페이지이다.
ex) 현재 페이지가 2라고 가정했을 때
[1] [2] [3] [4] [5] ... [15]
startPage = totalPages - maxDisplayPages + 1;
끝 부분과 가까울 때
ex) 현재 페이지가 14라고 가정했을 때
startPage = totalPages - maxDisplayPages + 1;
[1] ... [11] [12] [13] [14] [15]
startPage = currentPage - halfDisplay; endPage = currentPage + halfDisplay;
중간부분에 있을 때
ex) 현재 페이지가 7이라고 가정했을 때
[1] ... [5] [6] [7] [8] [9] ... [15]
return Array.from( { length: endPage - startPage + 1 }, //배열의 길이 설정 (_, i) => startPage + i //각 위치 값 계산 );
리턴 부분은 시작 페이지부터 끝 페이지까지 숫자 배열을 만든다.
return ( <Pagination> {/* 이전 페이지 버튼 */} <PaginationPrevious onClick={() => currentPage > 1 && onPageChange(currentPage - 1)} className={currentPage <= 1 ? "pointer-events-none opacity-50" : ""} /> {/* 첫 페이지 표시 */} {pages[0] > 1 && ( <> <PaginationLink onClick={() => onPageChange(1)}>1</PaginationLink> {pages[0] > 2 && <PaginationEllipsis />} {/* ... 표시 */} </> )} {/* 페이지 번호들 */} {pages.map((page) => ( <PaginationLink isActive={currentPage === page} onClick={() => onPageChange(page)} > {page} </PaginationLink> ))} {/* 마지막 페이지 표시 */} {pages[pages.length - 1] < totalPages && ( <> {pages[pages.length - 1] < totalPages - 1 && <PaginationEllipsis />} <PaginationLink onClick={() => onPageChange(totalPages)}> {totalPages} </PaginationLink> </> )} {/* 다음 페이지 버튼 */} <PaginationNext onClick={() => currentPage < totalPages && onPageChange(currentPage + 1)} className={currentPage >= totalPages ? "pointer-events-none opacity-50" : ""} /> </Pagination> );
이전 페이지 버튼
<PaginationPrevious onClick={() => currentPage > 1 && onPageChange(currentPage - 1)} className={currentPage <= 1 ? "pointer-events-none opacity-50" : ""} />
현재 페이지가 1보다 클 때만 이전 페이지로 이동 가능
첫 페이지일 때는 버튼이 비활성화(opacity-50)되고 클릭 불가능(pointer-events-none)
첫 페이지 표시 부분
{pages[0] > 1 && ( <> <PaginationLink onClick={() => onPageChange(1)}>1</PaginationLink> {pages[0] > 2 && <PaginationEllipsis />} </> )}
현재 보여지는 페이지 범위가 1페이지부터 시작하지 않을 때 표시
1페이지 링크를 항상 표시
현재 범위가 2페이지보다 클 때 ... (말줄임표) 표시
예시: [1] ... [5] [6] [7] [8] [9]
페이지 번호들
{pages.map((page) => ( <PaginationLink isActive={currentPage === page} onClick={() => onPageChange(page)} > {page} </PaginationLink> ))}
`getPageNumbers()`에서 계산된 페이지 번호들을 표시
현재 페이지는 `isActive` 속성으로 강조 표시
각 번호 클릭 시 해당 페이지로 이동
마지막 페이지 표시 부분
{pages[pages.length - 1] < totalPages && ( <> {pages[pages.length - 1] < totalPages - 1 && <PaginationEllipsis />} <PaginationLink onClick={() => onPageChange(totalPages)}> {totalPages} </PaginationLink> </> )}
현재 보여지는 페이지 범위가 마지막 페이지까지 가지 않을 때 표시
마지막 페이지 링크를 항상 표시
현재 범위가 마지막 페이지보다 2페이지 이상 작을 때 ... 표시
예시: [5] [6] [7] [8] [9] ... [15]
다음 페이지 버튼
<PaginationNext onClick={() => currentPage < totalPages && onPageChange(currentPage + 1)} className={currentPage >= totalPages ? "pointer-events-none opacity-50" : ""} />
현재 페이지가 총 페이지 수보다 작을 때만 다음 페이지로 이동 가능
마지막 페이지일 때는 버튼이 비활성화되고 클릭 불가능
결과
생각보다 엄청 복잡하게 구현한 느낌인데...전부 완성하고 시간되면 다시 들여다 봐야겠다...🥲
'✨ 기억보다 기록을 > 프로젝트' 카테고리의 다른 글
20241127(수) ▶️ 장바구니 페이지 데이터 불러오기 (0) 2024.11.27 20241126(화) ▶️ 굿즈샵 상세보기 페이지 - 장바구니 버튼 클릭시 로컬스토리지에 저장하기 (0) 2024.11.26 20241125(월) ▶️ 굿즈샵 상세보기 페이지 - 상세보기 탭 데이터 불러오기, 리뷰 탭 페이지 데이터 불러오기 (0) 2024.11.25 20241122(금) ▶️ 굿즈샵 상세보기 페이지 - 버튼 클릭 시 해당 탭 메뉴 내용 보여주기 (0) 2024.11.22 20241121(목) ▶️ 굿즈샵 상세보기 페이지 - 가격 정보 데이터 불러오기 (0) 2024.11.21 댓글