• 20241125(월) ▶️ 굿즈샵 상세보기 페이지 - 리뷰탭 페이지네이션(TanStack Query)

    2024. 11. 25.

    by. 서카츄

    리뷰 탭에 있는 페이지네이션을 구현해 보려고 한다.

    내가 생각하는 리뷰는 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">
                  리뷰 &#40;{totalCount}&#41;
                </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" : ""}
    />

     

    현재 페이지가 총 페이지 수보다 작을 때만 다음 페이지로 이동 가능

    마지막 페이지일 때는 버튼이 비활성화되고 클릭 불가능

     

     

     

     

     

     

    결과

     

    생각보다 엄청 복잡하게 구현한 느낌인데...전부 완성하고 시간되면 다시 들여다 봐야겠다...🥲

    댓글