
페이지네이션을 하고 싶어서 글을 찾아 보았지만, 양도 적고 마음에 쏙 들지 않았다.
블로그에서 찾은 글들은 대부분 useState와 API를 이용한 예제가 많았다.
이런 방식은 쉽게 구현이 가능하지만 서버사이드 랜더링의 이점을 활용하지 못한다.
오늘은 Nextjs에서 서버사이드 랜더링(SSR)로 페이지네이션을 구현해보려고 한다.
역시나 참고하게 된 글은 공식문서 이다.
1. SSR Pagenation 아이디어
이 글의 핵심은 page 컴포넌트에서 쿼리 스트링을 입력 받도록 한다는 것이다.
쿼리 스트링을 이용하면 다음과 같은 이점이 있다고 한다.
북마크 및 URL 공유 : 검색 매개변수가 주소창에 있으므로 공유하거나 북마크가 가능
서버사이드 랜더링 : 서버에서 직접 사용하여 초기 상태를 랜더링 가능
분석과 추적 : 추가적인 클라이언트 측의 코드 없이 사용자 행동 추적 가능
그럼 이제 코드를 보자.
prop에서 searchParams으로 쿼리 스트링을 파싱할 수 있다.
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
const searchParams = await props.searchParams;
const query = searchParams?.query || "";
const currentPage = Number(searchParams?.page) || 1;
return (
<div>
<p>searchParams</p>
<p>currentPage</p>
</div>
);
}내가 만일 '/test?page=1&query="힘센캥거루"'로 접속한다면, div 내에서 쿼리가 출력되는 것을 볼 수 있다.
그럼 이제 남은 일은 이 쿼리를 맛있게 요리하는 일만 남았다.
2. 검색창 구현
공식 문서에서 Search에 대한 컴포넌트를 그대로 가져온다.
일단 여기서는 검색어를 입력했을 때 브라우저의 콘솔로그에 찍히도록만 만들어 놓았다.
"use client";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
export default function Search({ placeholder }: { placeholder: string }) {
function handleSearch(term: string) {
console.log(term);
}
return (
<div className="relative flex flex-1 flex-shrink-0">
<label htmlFor="search" className="sr-only">
Search
</label>
<input
className="peer block w-full rounded-md border
border-gray-200 py-[9px] pl-10 text-sm outline-2
placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value);
}}
/>
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
);
}이렇게 한 뒤 나는 layout 컴포넌트에 아래와 같이 넣어주었다.
그냥 컴포넌트에 넣어도 되고, 어디 넣든지 상관은 없다.
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="mx-auto max-w-screen-xl w-full prose">
<div className="flex justify-between">
<div>
<h1>목록</h1>
</div>
<div className="w-80">
<Search placeholder="검색어를 입력하세요" /> // 서치박스
</div>
</div>
<div className="mb-8">
<TagGroup path="/category" tags={tagsArray} />
</div>
<div>{children}</div>
</div>
);
}이제 검색어를 입력하고 콘솔로그를 살펴보자.
그러면 입력이 일어날 때 마다 콘솔로그에 입력이 출력되는 것을 볼 수 있다.

이제 useSearchParams, usePathname, useRouter를 호출하고 객체를 생성한다.
아래 코드와 주석을 참고해보자.
"use client";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
export default function Search() {
const searchParams = useSearchParams();
// useSearchParams 호출
const pathname = usePathname();
// usePathname으로 기존의 url 경로 이용
const { replace } = useRouter();
// useRouter의 replace로 기존 주소 치환
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
// 내장함수 URLSearchParams로 쿼리스트링 생성
if (term) {
params.set("query", term); // input에 값이 있다면 재할당
} else {
params.delete("query"); // 값이 없다면 삭제
}
replace(`${pathname}?${params.toString()}`);
// 생성된 쿼리 스트링 앞에 원래의 URL 주소를 더해 주소창의 주소를 치환함.
}
// ...
}이렇게 코드를 짜고 검색창에 입력하면 주소가 변경되는 것을 볼 수 있다.

그런데 반대롤 주소창에 '/category?query=이게되는교' 라고 치고 들어가면 input의 입력창에는 아무것도 없는 것을 볼 수 있다.
주소창의 쿼리와 검색 입력창의 동기화를 위해 input에 아래와 같은 코드를 추가해주자.
defaultValue를 입력 후 query를 이용해 URL로 접속하면 input값이 동기화 되어 있다.
<input
// ...
defaultValue={searchParams.get("query")?.toString()}
// ...
/>
그런데 여기서 문제가 있다.
타이핑을 치면 모음, 자음이 하나씩 들어갈 때 마다 변화가 일어난 다는 것.
검색창에 마지막 입력이 발생한 뒤 300ms 후에 실행되도록 수정해보자.
이것을 debounce라고 하는데, 라이브러리를 설치하는게 정신건강에 좋다.
yarn add use-debounce그리고 코드를 아래와 같이 변경해주자.
디바운스를 호출 후, handleSearch 함수를 useDebouncedCallback으로 감싸준다.
마지막에는 300을 넣어주면, 마지막 입력이 실행된 뒤 300ms 뒤에 주소가 변경된다.
// ...
import { useDebouncedCallback } from "use-debounce";
const handleSearch = useDebouncedCallback((term) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
replace(`${pathname}?${params.toString()}`);
}, 300);이제 서버측에서는 해당 쿼리를 파싱해 필요한 정보만 리턴하면 된다.
3. 서버사이드 검색 기능 구현
서버사이드는 아마 사람마다 모두 다를 것 같다.
나의 경우는 현재 각 mdx의 메타데이터를 json 형태로 저장하고 사용중이다.
그럼 주소창의 query를 이용해서 filter를 한 뒤 컴포넌트로 리턴하면 된다.
import getPostCategory from "@/utils/getPostCategory";
import CategoryPage from "@/components/blog/CategoryPage";
import { postCardObject } from "@/types/allTypes"; // 이건 내 개인적으로 정의한 타입임.
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
const searchParams = await props.searchParams;
const query = searchParams?.query?.toLowerCase() || "";
const currentPage = Number(searchParams?.page) || 1;
// 쿼리를 파싱. 아직 페이지네이션은 구현 안됨.
// 대소문자 구분을 피하기 위해 모두 소문자로 치환시킴.
let posts: postCardObject[] = await getPostCategory();
// 모든 포스트 데이터를 불러옴.
if (query) {
posts = posts.filter((post) => {
const title = post.data.title.toLowerCase();
return title.includes(query);
});
}
// 쿼리가 있을 경우에는 필터를 걸어서 posts를 재할당 함.
// 나중에는 깔끔하게 getPostCategory 내부로 넣고 파라미터로 query를 넘겨줌.
return (
<>
<CategoryPage postCategorys={posts} />
</>
); // 그리고 이것을 포스트 목록으로 랜더링한다.
}
테스트를 해보니 검색어에 따라 포스트들이 필터링된다.
이렇게 하면 검색 기능은 끝이다.
4. 페이지네이션 구현
페이지네이션도 검색창 구현과 동일하다.
useSearchParams, useRouter, usePathname으로 클릭하는 페이지의 쿼리 스트링을 넘겨주면 된다.
조금 다른점이 있기는 하다.
페이지에 보여줄 포스트의 수와 총 페이지를 컴포넌트에 미리 할당해주어야 한다.
export default async function getPostCategory(
perPagePosts: number,
page = 1,
tag?: string
) {
// ...
const totalPage = Math.ceil(postCategorys.length / perPagePosts);
postCategorys = postCategorys.slice(
0 + perPagePosts * (page - 1),
perPagePosts + perPagePosts * (page - 1)
);
// ...
return { postCategorys, totalPage };
}
포스트 데이터를 불러오는 함수에서 한 페이지당 6개의 포스트를 슬라이스하고 총 페이지수를 리턴하도록 했다.
그리고 총 페이지 수에서 10을 나누어 페이지를 만든다.
일단 대충 Link로 만들어 테스트해보자.
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
// ...
return (
<div>
<CategoryPage postCategorys={postCategorys} />
<div>
{Array.from({ length: totalPage }, (_, i) => i + 1).map((i) => (
<Link
key={i}
className={`mx-3 bg-gray-200 ${
currentPage === i ? "bg-gray-700 text-white" : ""
}`}
href={`?page=${i}`}
>
page {i}
</Link>
))}
</div>
</div>
);
}

위의 링크를 타고 들어가면 query에 따라 포스트가 제공되는걸 볼 수 있다.
이제 'use client' 컴포넌트를 만들어보자.
구현이 상당히 귀찮으니 Claude에게 한번 맡겨보자.

아.. 달다...
역시 리엑트는 클로드 형이야.
그리고 첫번째를 골라 코드를 살펴보고 몇가지를 변경해준다.

import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
export default function Pagination({
totalItems,
itemsPerPage = 10,
className = "",
}: {
totalItems: number;
itemsPerPage: number;
className?: string;
}) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const currentPage = Number(searchParams.get("page")) || 1;
const totalPages = Math.ceil(totalItems / itemsPerPage);
// 클릭된 페이지를 주소창 쿼리로 넣어줌
function handlePageChange(page: number) {
if (page >= 1 && page <= totalPages) {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
replace(`${pathname}?${params.toString()}`);
}
}
// 렌더링 해줄 페이지 배열 만들기
function renderPageNumbers() {
const pages = [];
const showAroundCurrent = 2;
const startPage: number = Math.max(1, currentPage - showAroundCurrent);
const endPage: number = Math.min(
totalPages,
currentPage + showAroundCurrent
);
// 시작 부분 처리
if (startPage > 1) {
pages.push(1);
if (startPage > 2) {
pages.push("...");
}
}
// 현재 페이지 주변 숫자
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
// 끝 부분 처리
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
pages.push("...");
}
pages.push(totalPages);
}
return pages;
}
// return 아래는 그냥 대충 봐도 될듯.
return (
<div className={`flex items-center justify-center space-x-2 ${className}`}>
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
aria-label="Previous page"
>
<ChevronLeftIcon className="w-5 h-5" />
</button>
{renderPageNumbers().map((page, index) => (
<button
key={index}
onClick={() => typeof page === "number" && handlePageChange(page)}
disabled={page === "..."}
className={`
px-4 py-2 rounded-lg transition-colors duration-200
${typeof page === "number" ? "hover:bg-gray-100" : ""}
${
page === currentPage
? "bg-blue-500 text-white hover:bg-blue-600"
: ""
}
${page === "..." ? "cursor-default" : "cursor-pointer"}
disabled:cursor-not-allowed
`}
aria-label={typeof page === "number" ? `Page ${page}` : "More pages"}
aria-current={page === currentPage ? "page" : undefined}
>
{page}
</button>
))}
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
aria-label="Next page"
>
<ChevronRightIcon className="w-5 h-5" />
</button>
</div>
);
}이렇게 한 뒤 category의 layout으로 들어가 테스트용으로 페이지네이션을 넣어 줬다.
임의로 총 포스트의 개수는 100개, 한 페이지당 포스트 수는 6개로 해보았다.
// ...
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
// ...
<div>
<Pagenation totalItems={100} itemsPerPage={6} />
</div>
<div>페이지네이션 테스트 중</div>
//...
}
문제 없이 잘 실행된다.
그럼 서버사이드에서 클라이언트 컴포넌트로 totalItem와 itemsPerPage만 제대로 넘겨주면 끝이다.
물론, 이전에 예시로 했던 코드에서 getPostCategory는 조금 변경해주는건 덤이다.
5. 후기
페이지네이션을 어떻게 구현해야 할지 엄청 많이 고민했는데 한방에 끝났다.
최신 트랜드는 책보다 공식문서가 낫다는 의견이다.
내 글이 도움이 되길 바란다.
댓글을 불러오는 중...