app router의 페이지네이션
Next.js App Router에서 페이지네이션을 공통 컴포넌트로 만들며 겪은 시행착오. URL 쿼리 기반 상태 관리와 서버·클라이언트 컴포넌트 경계에서의 구현 판단을 정리한다.
📌 페이지네이션 공통 컴포넌트화: Next.js App Router에서의 도전기
이번 프로젝트에서는 총 4개의 위치에서 페이지네이션 기능이 사용되었습니다. 반복되는 기능을 매번 새로 만들기보다 공통 컴포넌트로 분리하여 재사용할 수 있도록 하는 것이 효율적이라 판단했고, 이 과정에서 겪었던 문제와 해결 과정을 기록으로 남겨두고자 합니다.
🚨 문제 배경: 앱 라우터와 서버/클라이언트 컴포넌트
우리가 사용한 프레임워크는 Next.js 14 버전이었고, 라우팅은 최신 방식인 App Router를 기반으로 구성했습니다.

이 구조에서는 서버 컴포넌트(Server Components) 와 클라이언트 컴포넌트(Client Components) 가 공존하며, 각각의 역할이 명확히 구분되어 있습니다.
- 서버 컴포넌트: 클라이언트 없이도 실행 가능한 로직을 담당
- 클라이언트 컴포넌트: 브라우저에서만 동작하는 로직을 담당
React만 사용할 때 혹은 기존의 Page Router를 썼을 때와는 확연히 다른 구조라 초반에 많이 헤맸습니다.
📋 페이지네이션 기능 요구사항
- 페이지 이동은 이전/다음 버튼만 제공 (숫자 버튼 없음)
- 페이지 전환 시 서버에 현재 페이지 번호를 전달하여 해당 페이지 데이터를 가져와야 함
- 따라서 페이지 번호는 클라이언트 측에서 관리되어야 함
😵 혼란의 시작
이전 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}
/>
);
}
🤩 느낀 점
이번 경험을 통해 서비스의 기반 환경을 잘 이해하고, 그에 맞게 기능을 구현하는 연습이 얼마나 중요한지 다시금 느꼈습니다. 웹 환경에서 기본적으로 제공하는 쿼리 스트링을 활용하니, 복잡한 전역 상태 관리 없이도 기능 구현이 훨씬 수월해졌습니다.
추가로, 이번 프로젝트에서는 전역 상태 관리 라이브러리를 사용하지 않았지만, 만약 사용했다면 페이지 상태 공유나 더 정교한 제어가 가능했을 수도 있겠다는 생각도 들었습니다. 다음에는 상태 관리 라이브러리도 함께 적용해보며 다양한 방식을 실험해봐야겠습니다. 😊