📌 페이지네이션 공통 컴포넌트화: Next.js App Router에서의 도전기

이번 프로젝트에서는 총 4개의 위치에서 페이지네이션 기능이 사용되었습니다. 반복되는 기능을 매번 새로 만들기보다 공통 컴포넌트로 분리하여 재사용할 수 있도록 하는 것이 효율적이라 판단했고, 이 과정에서 겪었던 문제와 해결 과정을 기록으로 남겨두고자 합니다.


🚨 문제 배경: 앱 라우터와 서버/클라이언트 컴포넌트

우리가 사용한 프레임워크는 Next.js 14 버전이었고, 라우팅은 최신 방식인 App Router를 기반으로 구성했습니다.

이 구조에서는 서버 컴포넌트(Server Components)클라이언트 컴포넌트(Client Components) 가 공존하며, 각각의 역할이 명확히 구분되어 있습니다.

  • 서버 컴포넌트: 클라이언트 없이도 실행 가능한 로직을 담당
  • 클라이언트 컴포넌트: 브라우저에서만 동작하는 로직을 담당

React만 사용할 때 혹은 기존의 Page Router를 썼을 때와는 확연히 다른 구조라 초반에 많이 헤맸습니다.


📋 페이지네이션 기능 요구사항

  1. 페이지 이동은 이전/다음 버튼만 제공 (숫자 버튼 없음)
  2. 페이지 전환 시 서버에 현재 페이지 번호를 전달하여 해당 페이지 데이터를 가져와야 함
  3. 따라서 페이지 번호는 클라이언트 측에서 관리되어야 함

😵 혼란의 시작

이전 React 프로젝트에서는 페이지네이션을 다음과 같은 구조로 작성했습니다:

  • Pagination 컴포넌트는 총 페이지 수와 현재 페이지를 props로 받아 렌더링
  • 상위 컴포넌트에서는 현재 페이지를 state로 관리하고, 페이지 변경 시 데이터를 다시 요청

하지만 App Router 환경에서는 다음과 같은 제약이 있었습니다:

  • 서버 컴포넌트에서는 React의 상태나 훅(useState, useEffect)을 사용할 수 없음
  • 데이터 패칭은 서버 컴포넌트에서 하고, 페이지 상태는 클라이언트에서 관리해야 하는 구조

결국 “페이지 번호를 어떻게 공유할 것인가?”가 가장 큰 문제였습니다.


⭐️ 해결 과정

친절한 Next.js 공식 문서

다행히도 Next.js 공식 문서에서는 App Router 환경에서의 페이지네이션 구현 튜토리얼을 잘 제공하고 있었습니다. 특히 페이지 상태를 쿼리 스트링(query string) 으로 관리하는 방식이 핵심이었죠.

우리 프로젝트에서는 일부 페이지에 2개 이상의 페이지네이션이 사용되었고, 사이드바에도 독립된 페이지네이션이 존재했기 때문에 쿼리 키를 분리해서 관리할 수 있도록 설계해야 했습니다.


💡 Pagination 컴포넌트

export default function Pagination({
  paramKey,
  currentPage,
  lastPage,
}: PageButtonProps) {
  const searchParams = useSearchParams();
  const { replace } = useRouter();
  const pathname = usePathname();

  const goToForwardHandler = () => {
    const params = new URLSearchParams(searchParams);
    params.set(paramKey, String(Math.max(currentPage - 1, 1)));
    replace(`${pathname}?${params.toString()}`);
  };

  const goToNextHandler = () => {
    const params = new URLSearchParams(searchParams);
    params.set(
      paramKey,
      String(currentPage !== lastPage ? currentPage + 1 : currentPage)
    );
    replace(`${pathname}?${params.toString()}`);
  };

  return (
    <>
      <button onClick={goToForwardHandler} disabled={currentPage === 1}>
        <Image
          src={getForwardArrowSrc(currentPage, theme!)}
          alt="왼쪽 화살표"
        />
      </button>
      <button onClick={goToNextHandler} disabled={lastPage === currentPage}>
        <Image
          src={getNextArrowSrc(currentPage, lastPage, theme!)}
          alt="오른쪽 화살표"
        />
      </button>
    </>
  );
}

paramKey를 props로 받아서 쿼리 스트링을 유연하게 다룰 수 있도록 구현했습니다. 페이지 전환 시 URL의 쿼리 값이 변경되면서 자동으로 해당 페이지에 맞는 데이터가 렌더링됩니다.


📦 Pagination 사용 예시

쿼리 스트링을 기준으로 상태를 관리하므로, 서버 컴포넌트와 클라이언트 컴포넌트 간의 역할 분리를 유지하면서도 페이지네이션 기능을 구현할 수 있었습니다.

export default function DashboardList({
  initialData,
  lastPage,
}: DashboardListProps) {
  const [dashboardList, setDashboardList] = useState(initialData);
  const searchParams = useSearchParams();

  const paramKey = 'boardPage';
  const currentPage = Number(searchParams.get(paramKey)) || 1;

  async function getData() {
    const data = await getDashboard(currentPage);
    setDashboardList(data);
  }

  useEffect(() => {
    getData();
  }, [currentPage]);

  return (
    <Pagination
      paramKey={paramKey}
      currentPage={currentPage}
      lastPage={lastPage}
    />
  );
}

🤩 느낀 점

이번 경험을 통해 서비스의 기반 환경을 잘 이해하고, 그에 맞게 기능을 구현하는 연습이 얼마나 중요한지 다시금 느꼈습니다. 웹 환경에서 기본적으로 제공하는 쿼리 스트링을 활용하니, 복잡한 전역 상태 관리 없이도 기능 구현이 훨씬 수월해졌습니다.

추가로, 이번 프로젝트에서는 전역 상태 관리 라이브러리를 사용하지 않았지만, 만약 사용했다면 페이지 상태 공유나 더 정교한 제어가 가능했을 수도 있겠다는 생각도 들었습니다. 다음에는 상태 관리 라이브러리도 함께 적용해보며 다양한 방식을 실험해봐야겠습니다. 😊