정적 블로그에 페이지네이션(Pagination) 기능을 추가해보자

Next.js SSG(Static Site Generation) 빌드된 블로그에 페이지네이션(Pagination) 기능을 추가해서 인프라 비용을 절감하는 방법을 알아봅시다.
페이지네이션(Pagination)이란?
페이지네이션(Pagination)은 마케팅용, 현실용 이렇게 두 가지로 설명드릴 수 있습니다.
- 마케팅: 수많은 콘텐츠들을 여러 페이지들로 분리해서 유저들의 탐색 경험을 향상시켜주는 기술
- 현실: 제한된 콘텐츠만 보여주면서 유저들의 참여율을 높이고, 페이지 렌더링 속도, 전송 데이터 크기를 줄여서 인프라 비용을 아끼는 기술
구현 원리
계산하기 쉽도록 포스팅 개수가 50개인 상황을 상상해 봅시다.
- 한 페이지에 10개씩 목록을 보여준다면 총 5개의 페이지 생성
- 한 페이지에 20개씩 목록을 보여준다면 총 3개의 페이지 생성(50/20 = 2.5 → 반올림하면 3)
이렇게 총 포스팅 개수를 한 화면에 표시할 개수로 나누고, 반올림한 값 만큼의 페이지를 미리 생성해주면 되는데요, 블로그에 태그(Tag)기반 카테고리 기능 추가할 때 사용한 동적 라우팅(Dynamic Routes)
기능을 이용하면 SSG(Static Site Generation) 방식으로 생성된 블로그에서도 페이지네이션 기능을 추가할 수 있습니다.
URL 구조
현제 저희 블로그 URL 구조는 아래처럼 설계되어 있습니다.
- 포스팅 목록 루트 페이지(
/articles
): 모든 게시글 목록 렌더링 - 포스팅 페이지(
/articles/[slug]
): 개별 포스팅 렌더링
저는 포스팅 목록 페이지를 /articles/page/1, /articles/page/2 ..
이런 구조로 설계하고 싶은데요, 이를 위해 Dynamic Routes 기능을 사용할거고, 첫번째 페이지 목록이 표시될 /articles/page/1
경로는 포스팅 목록 루트 페이지(/articles
)로 redirect 처리해서 렌더링 하려고 합니다.
폴더 구조
URL 구조에 맞게 폴더 구조도 잡아주도록 하겠습니다. 혹시 Dynamic Routes, SSG가 익숙하지 않으신 분들은 블로그에 태그(Tag)기반 카테고리 기능 추가하기 포스팅에 있는 사전 지식 부분을 참고하시기 바랍니다.
src/app/articles
├── [slug] # 개별 포스팅 렌더링(/articles/post-slug)
│ └── page.tsx
├── page
│ └── [page-number] # 포스팅 목록 페이지 렌더링(/articles/page/1, /articles/page/2 ...)
│ └── page.tsx
└── page.tsx # 첫번째 포스팅 목록 페이지 렌더링(/articles)
페이지네이션(Pagination) 구현하기
이제 총 포스팅 개수를 제 블로그 전체 포스팅 수와 동일한 14개로 놓고, 포스팅들의 slug가 post-01 ~ post-14
이렇게 있다고 가정해 보겠습니다.
위에서 한 페이지에 몇개의 목록을 보여줄지 정했다면 몇개의 페이지를 생성해야 하는지 알 수 있다고 했는데요, 5개씩 묶어서 포스팅 목록을 보여준다고 할 때 페이지네이션 구현에 필요한 각각의 요소들이 어떻게 작동하는지 하나씩 살펴보겠습니다.
GetPostsByPage
포스팅 정보들은 사전에 정렬되어 있다면, 페이지 번호를 Key로 사용할 때 총 3개의 페이지가 생성될거고, 페이지 번호마다 일관된 포스팅 목록들을 가져올 수 있습니다.
- 1 페이지:
post-01 ~ post-05
- 2 페이지:
post-06 ~ post-10
- 3 페이지:
post-11 ~ post-14
// src/lib/api.ts
import { type Post } from "@/interfaces/post";
export const getPostsByPage = async (input: {
page: number;
limit: number;
}): Promise<{ posts: Post[]; totalPosts: number; totalPages: number }> => {
const { page, limit } = input;
// 전체 포스팅 목록 가져와서
const posts = await getAllPosts();
// 총 개수 구하고
const totalPosts = posts.length;
// 몇개의 페이지가 생성되는지 계산한 후
const totalPages = Math.max(1, Math.ceil(totalPosts / limit));
// 포스팅 정보 담아놓은 배열에서 가져올 시작, 끝 지점 구하고
const start = (page - 1) * limit;
const end = start + limit;
return {
posts: posts.slice(start, end), // 원본 배열에서 복사하기
totalPosts,
totalPages,
};
};
const PAGE_SIZE = 5;
getPostsByPage({ page: 1, limit: PAGE_SIZE });
// { posts: ["post-01", ..., "post-05"], totalPosts: 14, totalPages: 3 };
getPostsByPage({ page: 2, limit: PAGE_SIZE });
// { posts: ["post-06", ..., "post-10"], totalPosts: 14, totalPages: 3 };
getPostsByPage({ page: 3, limit: PAGE_SIZE });
// { posts: ["post-11", ..., "post-14"], totalPosts: 14, totalPages: 3 };
Pagination Component
개인적으로 1, 2, 3 ...
이렇게 숫자 버튼 클릭해서 페이지 선택하는 방식은 데스크탑에서는 몰라도 모바일 환경에서는 버튼이 작아서 불편했는데요, 무한 스크롤 스타일과 버튼 방식의 장점들만 가져와서 이전 페이지
, 다음 페이지
버튼 단 두 개 만으로 페이지를 선택할 수 있는 컴포넌트를 만들어 보겠습니다.
(무한 스크롤은 사용자가 굳이 현재 어느 페이지에 있는지 신경쓸 필요가 없고, 버튼 방식은 앞, 뒤 두 개의 버튼만 있으면 페이지 이동이 가능해서 UI가 단순해집니다)
// src/app/_components/post-pagination.tsx
import Link from "next/link";
type Props = {
page: number;
totalPages: number;
};
export function PostPagination({ page, totalPages }: Props) {
const previous = page > 1 ? page - 1 : null;
const next = page < totalPages ? page + 1 : null;
if (!previous && !next) {
return null;
}
return (
<section>
<ul className="my-12 flex flex-row justify-center gap-3 text-slategray">
{previous && (
<li>
<Link href={previous == 1 ? `/articles` : `/articles/page/${previous}`}>
<span className="text-base font-medium hover:underline border-2 rounded-lg px-4 py-2">← 이전 페이지</span>
</Link>
</li>
)}
{next && (
<li>
<Link href={`/articles/page/${next}`}>
<span className="text-base font-medium hover:underline border-2 rounded-lg px-4 py-2">다음 페이지 →</span>
</Link>
</li>
)}
</ul>
</section>
);
}
-
총 페이지(totalPages) 3개
- 1 페이지: previous = null, next = 2 → 다음 페이지(
/articles/page/2
) - 2 페이지: previous = 1, next = 3 → 이전 페이지(
/articles
), 다음 페이지(/articles/page/3
) - 3 페이지: previous = 2, next = null → 이전 페이지(
/articles/page/2
)
- 1 페이지: previous = null, next = 2 → 다음 페이지(
-
총 페이지(totalPages) 1개
- 1 페이지: previous = null, next = null → 페이지네이션 필요 없음 → null
위에 URL 구조 설명에서 첫번째 페이지 목록이 표시될 /articles/page/1
경로는 기존 포스팅 목록 루트 페이지(/articles
)로 redirect 처리한다고 말씀드렸죠?
이를 위해서는 포스팅 목록 페이지 렌더링하는 컴포넌트들이(/articles/page.tsx
, /articles/page/[page-number]/page.tsx
) getPostsByPage
함수를 호출할 때 limit을 동일하게 맞춰주면 되는데요, limit 5개로 설정하고 각 컴포넌트 확인해 보겠습니다.
Root Page Component
// src/app/articles/page.tsx
import { getPostsByPage } from "@/lib/api";
...
import { PostPagination } from "@/app/_components/post-pagination";
// 한 페이지에 포스팅 5개로 제한
const PAGE_SIZE = 5;
export default async function Index() {
const { posts, totalPages } = await getPostsByPage({
page: 1, // 1 페이지만 가져와서 보여줌
limit: PAGE_SIZE
});
return (
<main>
<div className="mb-20">
<ul>
{posts.map((post) => (
<li key={post.slug} className="my-6 border">
<a href={`/articles/${post.slug}`}>
<img src={post.coverImage}></img>
<h2 className="text-lg mb-4">{post.title}</h2>
<p className="text-sm">{post.excerpt}</p>
</a>
</li>
))}
</ul>
</div>
<h2 className="text-lg mb-4 text-center">{`현재 페이지: /articles · 포스팅 개수: ${posts.length}`}</h2>
{/* 첫 페이지니까 page 값 직접 1로 전달해줌 */}
<PostPagination page={1} totalPages={totalPages} />
</main>
);
}
1 페이지(/articles
)에서는 PostPagination
params 값으로 page 1을 직접 전달했으니 다음 페이지 버튼만 나오는 것을 볼 수 있습니다.
Page Component
// src/app/articles/page/[page-number]/page.tsx
import { permanentRedirect } from "next/navigation";
...
import { getAllPosts, getPostsByPage } from "@/lib/api";
import { PostPagination } from "@/app/_components/post-pagination";
type Params = {
params: {
page: string;
};
};
// 한 페이지에 포스팅 5개로 제한
const PAGE_SIZE = 5;
export async function generateStaticParams() {
const posts = getAllPosts();
// 총 페이지 수 계산해서
const totalPages = Math.max(1, Math.ceil(posts.length / PAGE_SIZE));
// params 값으로 넘겨줌(문자열)
return Array.from({ length: totalPages }).map((_, i) => ({
page: String(i + 1),
}));
// [{ page: "1" }, { page: "2" }, { page: "3" }, ...];
}
export default async function Index({ params }: Params) {
const { page } = params;
const pageNum = Number(page);
// /articles/page/1 => /articles 경로로 redirect
if (pageNum == 1) {
permanentRedirect("/articles");
}
const { posts, totalPages } = await getPostsByPage({
page: pageNum,
limit: PAGE_SIZE,
});
return (
<main>
...
<div className="mb-20">
<ul>
{posts.map((post) => (
..위와 동일
))}
</ul>
</div>
<h2 className="text-lg mb-4 text-center">{`현재 페이지: /articles/page/${pageNum} · 포스팅 개수: ${posts.length}`}</h2>
{/* params 값으로 전달받은 page 번호를 이용해서 버튼 생성*/}
<PostPagination page={pageNum} totalPages={totalPages} />
</main>
);
}
curl 이용해서 확인해보면 1 페이지(/articles/page/1
) 접근하면 /articles
로 308 redirect 정상적으로 처리되고
curl -I http://localhost:3000/articles/page/1
HTTP/1.1 308 Permanent Redirect
...
location: /articles
2 페이지(/articles/page/2
)에서는 이전 페이지 버튼, 다음 페이지 버튼 둘 다 나오고, 이전 페이지 버튼에 1 페이지(/articles/page/1
)가 아닌, 루트 페이지(/articles
)로 링크된 것 보이고
3 페이지(/articles/page/3
)는 PostPagination
컴포넌트에서 다음 페이지 버튼 조건을 충족하지 않으니(page(3) < totalPages(3)
) null 처리되어서 이전 페이지 버튼만 렌더링 된 것을 보실 수 있습니다.
마무리
복잡한 API 통신, 클라이언트 컴포넌트, CSS 효과 없이 최대한 심플하게 정적 블로그에 페이지네이션 기능을 구현하는 방법을 알아보았습니다.
다음 포스팅에서는 Markdown 파일에서 첨부한 이미지 파일들에 lazy loading 적용해서 페이지 로딩 속도를 높이고 이미지 전송량을 줄이는 방법, 커스텀 댓글 기능 만드는 방법을 알아보겠습니다.