rhanziy

Next.js - 페이지네이션 구현기(custom pagination, with supabase) 본문

Next.js

Next.js - 페이지네이션 구현기(custom pagination, with supabase)

rhanziy 2024. 11. 11. 19:08

페이지네이션 라이브러리를 안쓰고 큰 흐름을 파악하고자 직접 구현해봤다.
의도한대로 동작은 되었는데 3번은 갈아 엎은듯. 하하. 페이지네이션이 이렇게 복잡한 것일 줄이야.
그래서 구현과정을 정리해보고자 제목을 [페이지네이션 구현기]로 정했다.

1차 시도 데이터 배열 slice

처음에는 단순하게 데이터가 리스트가 담긴 배열을 
현재페이지 * 보여줄 페이지 개수 ~ 다음페이지 * 보여줄 페이지 개수로 slice해서 보여줬었는데
데이터가 많아지면 비효율적이기때문에 일찌감치 리팩토링 했다.

arr.slice((currentPage - 1) * itemCountPerPage, Math.min(currentPage * itemCountPerPage, totalItems)


2차 시도 searchParams

useSearchParams를 통해 url에서 페이지 번호를 받아와서 바뀔때마다 data fetch해오는게 정석적인 방법인 것 같아서 구현해보았다.
가져올 데이터의 테이블과 총 list데이터 갯수를 params로 넘겨받는 usePagination 공통 훅을 하나 만들었다.
접근법은 맞는데, 중복되는 코드들과 데이터를 fetch해오는 비동기함수를 useEffect로 처리하는게 최적화된 방법이 아닌 것 같아서 다시 개선했다.
👇🏻 문제라고 생각했던 부분

 useEffect(() => {
    const fetch = async () => {
      try {
        const lists: T[] = await fetchPageData(
          table,
          totalItems,
          currentPage,
          itemCountPerPage,
        );
        setFetchData(lists);
      } catch (error) {
        console.error('Error fetching list', error);
      }
    };
    fetch();
  }, [table, currentPage, totalItems]);


최종코드

next app router에서는 server component의 props로 searchParams가 전달된다고 한다.
그래서 server component의 props로 전달받은 searchParams를 하위 컴포넌트의 props로 전달하여 url의 page값이 바뀌면 데이터를 fetch 해오도옥 처리했다.
 
/reviews/page.tsx

export default async function Reviews({
  searchParams,
}: {
  searchParams: { page: string };
}) {

  // 1. /reviews 페이지에 접근하면 url의 page key의 값을 받아오고 있으면 number처리, 없으면 1페이지 처리
  const page = searchParams.page ? parseInt(searchParams.page) : 1;
  // 2. 해당 페이지의 review데이터와 총 item 갯수를 불러오는 비동기 함수
  const { data, count } = await getReviews(page);
  // 3. ITEMCOUNTPERPAGE는 1 페이지당 보여줄 아이템 갯수이고 총 페이지를 계산한다.
  const pageCount = Math.ceil(count / ITEMCOUNTPERPAGE);

  // 4. 사용자의 잘못된 접근 처리
  if (page < 1) {
    redirect('/reviews?page=1');
  } else if (page > pageCount) {
    redirect(`/reviews?page=${pageCount}`);
  }

  return (
    <div className={wrapper}>
      <Suspense fallback={<ReviewSkeleton />}>
        <ReviewList reviews={data} page={page} pageCount={pageCount} />
      </Suspense>
    </div>
  );
}

 
supabase에서 데이터를 불러오는 getReviews 비동기 함수

export async function getReviews(page: number = 1) {
  const supabase = createClient();

  try {
    const { data, count } = await supabase
      .from('reviews')
      .select(
        'id, created_at, age, gender, nickname, category, content, date',
        // 테이블의 전체 item 갯수를 반환함!
        { count: 'exact' },
      )
      .order('created_at', { ascending: false })
      // range를 통해 해당하는 페이지 범위의 데이터만 가져온다.
      .range((page - 1) * ITEMCOUNTPERPAGE, page * ITEMCOUNTPERPAGE - 1);

    // data가 undefined일 경우 pageCount 계산을 위한 처리
    if (!data || data.length === 0)  {
      return { data: [], count: 0 };
    }
    return { data, count };
  } catch (error) {
    console.log(error);
    throw error;
  }
}

 

client side - ReviewList
여기서 유저의 페이지 변경 이벤트를 처리하는 usePagination은 다른 페이지에서도 사용하니 hook으로 만들어 놓았고,
customPagination 컴포넌트에서 onPageChange 핸들러로 사용된다.

'use client';
import ReviewComponent from './ReviewComponent';
import CustomPagination from '@/app/components/pagination/CustomPagination';
import usePagination from '@/app/components/pagination/usePagination';
import { IReview } from '@/app/types';
import * as styles from '../style/style.css';

export function ReviewList({
  reviews,
  page,
  pageCount,
}: {
  reviews: IReview[];
  page: number;
  pageCount: number;
}) {
  const handlePageChange = usePagination(pageCount);

  if (!reviews || reviews.length === 0) {
    return (
      <div className={styles.emptyReviewContainer}>
        아직 작성된 후기가 없어요.
      </div>
    );
  }

  return (
    <>
      <div className={styles.reviewWrapper}>
        <ReviewComponent reviewList={reviews} />
      </div>
      <CustomPagination
        currentPage={page}
        pageCount={pageCount}
        onPageChange={handlePageChange}
      />
    </>
  );
}

 
client side - usePagination hook
여기서 params로 받아오는 keyword는 multiplePagination을 위한 키워드.
admin페이지에서는 한페이지에 2개의 데이터 리스트를 보여주며, 각각 petPage, userPage로 페이지 번호를 받아온다.
기본값은 page.
다음편에서 multiplePagination 구현한걸 작성하도록 하겠슴. 원래 컴포넌트를 분리했어야하지만 귀찮아서 한 파일에 구현해봤다...

'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback } from 'react';

const usePagination = (keyword = 'page') => {
  const searchParams = useSearchParams();
  const router = useRouter();

  const handlePageChange = useCallback(
    (page: number) => {
      const newParams = new URLSearchParams(searchParams.toString());
      newParams.set(keyword, page.toString());
      router.push(`?${newParams.toString()}`);
    },
    [searchParams, keyword, router],
  );

  return handlePageChange;
};

export default usePagination;

 
client side - CustomPagination

import * as styles from './style.css';

interface Props {
  currentPage: number; // 현재 페이지
  pageCount: number; // 전체 페이지 개수
  onPageChange: (page: number) => void; // 페이지 변경 핸들러
  showPageCount?: number;
}

export default function CustomPagination({
  currentPage,
  pageCount,
  onPageChange,
  showPageCount = 4,
}: Props) {
  const noPrev = currentPage === 1;
  const noNext = currentPage === pageCount;

  const handlePrev = () => {
    if (noPrev) return;
    onPageChange(currentPage - 1);
  };

  const handleNext = () => {
    if (noNext) return;
    onPageChange(currentPage + 1);
  };

  // 페이지가 많아지면 가운데 생략처리를 위한 계산
  const halfVisible = Math.floor(showPageCount / 2);

  // 시작 페이지와 끝 페이지 계산
  let startPage = Math.max(currentPage - halfVisible, 1);
  let endPage = Math.min(startPage + showPageCount - 1, pageCount);

  // 끝 페이지가 전체 페이지 수보다 적으면 시작 페이지 조정
  if (endPage - startPage < showPageCount - 1) {
    startPage = Math.max(endPage - showPageCount + 1, 1);
  }

  // 페이지 배열 생성
  const pages = Array.from(
    { length: endPage - startPage + 1 },
    (_, i) => startPage + i,
  );

  return (
    <>
      {pageCount > 0 && (
        <div className={styles.paginationContainer}>
          <button
            className={styles.controlButton}
            onClick={handlePrev}
            disabled={noPrev}
          >
            이전
          </button>

          {/* 첫 번째 페이지 버튼과 생략 기호 */}
          {startPage > 1 && (
            <>
              <button
                className={styles.paginationButton}
                onClick={() => onPageChange(1)}
              >
                1
              </button>
              {startPage > 2 && <span>...</span>} {/* 생략 기호 */}
            </>
          )}

          {/* 페이지 버튼 */}
          {pages.map((page) => (
            <button
              key={page}
              className={`${styles.paginationButton} ${
                page === currentPage ? styles.active : ''
              }`}
              onClick={() => onPageChange(page)}
            >
              {page}
            </button>
          ))}

          {/* 마지막 페이지 버튼과 생략 기호 */}
          {endPage < pageCount && (
            <>
              {endPage < pageCount - 1 && <span>...</span>} {/* 생략 기호 */}
              <button
                className={styles.paginationButton}
                onClick={() => onPageChange(pageCount)}
              >
                {pageCount}
              </button>
            </>
          )}

          <button
            className={styles.controlButton}
            onClick={handleNext}
            disabled={noNext}
          >
            다음
          </button>
        </div>
      )}
    </>
  );
}

 

구현영상


혹시 더 좋은 방법이있거나 개선점을 발견하시면 언제든 댓글 남겨주시면 감사하겠습니다^____^

Comments