블로그를 준비하고 있어요
잠시만 기다려주세요...
Nextjs에서 페이지네이션 구현하기Nextjs에서 페이지네이션 구현하기
nextjs

페이지네이션을 하고 싶어서 글을 찾아 보았지만, 양도 적고 마음에 쏙 들지 않았다.
블로그에서 찾은 글들은 대부분 useState와 API를 이용한 예제가 많았다.
이런 방식은 쉽게 구현이 가능하지만 서버사이드 랜더링의 이점을 활용하지 못한다.
오늘은 Nextjs에서 서버사이드 랜더링(SSR)로 페이지네이션을 구현해보려고 한다.
역시나 참고하게 된 글은 공식문서 이다.
1. SSR Pagenation 아이디어
이 글의 핵심은 page 컴포넌트에서 쿼리 스트링을 입력 받도록 한다는 것이다.
쿼리 스트링을 이용하면 다음과 같은 이점이 있다고 한다.
분석과 추적 : 추가적인 클라이언트 측의 코드 없이 사용자 행동 추적 가능
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();
const pathname = usePathname();
const { replace } = useRouter();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
replace(`${pathname}?${params.toString()}`);
}
}
이렇게 코드를 짜고 검색창에 입력하면 주소가 변경되는 것을 볼 수 있다.
그런데 반대롤 주소창에 '/category?query=이게되는교' 라고 치고 들어가면 input의 입력창에는 아무것도 없는 것을 볼 수 있다.
주소창의 쿼리와 검색 입력창의 동기화를 위해 input에 아래와 같은 코드를 추가해주자.
defaultValue를 입력 후 query를 이용해 URL로 접속하면 input값이 동기화 되어 있다.
<input
defaultValue={searchParams.get("query")?.toString()}
/>
타이핑을 치면 모음, 자음이 하나씩 들어갈 때 마다 변화가 일어난 다는 것.
검색창에 마지막 입력이 발생한 뒤 300ms 후에 실행되도록 수정해보자.
이것을 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);
});
}
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을 나누어 페이지를 만든다.
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 (
<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. 후기
페이지네이션을 어떻게 구현해야 할지 엄청 많이 고민했는데 한방에 끝났다.
최신 트랜드는 책보다 공식문서가 낫다는 의견이다.
댓글을 불러오는 중...