목록을 나누되, 보고 있는 위치는 URL에 남깁니다
5편의 카테고리 목록과 6편의 서버 검색은 결과를 한 화면에 모두 표시합니다. 글이 늘어나면 화면을 길게 스크롤해야 하고, 검색 결과의 중간 위치를 링크로 공유하기도 어렵습니다.
이번에는 한 페이지에 여섯 개씩 표시합니다. 다음 페이지를 눌렀을 때 카테고리나 검색어는 유지하고, 페이지 번호만 바꿉니다.
/fresh → Fresh 카테고리 첫 페이지
/fresh?page=2 → Fresh 카테고리 두 번째 페이지
/search?q=Deno&page=2 → Deno 검색 결과의 두 번째 페이지페이지 이동은 일반 링크로 처리합니다. 서버가 URL을 읽고 해당 목록을 HTML로 반환하므로, 페이지 탐색을 위해 새 Island가 필요하지 않습니다.
코드는 Fresh Notes의 임시 복사본에 적용했습니다. Fresh 2.3.3과 Deno 2.8.3으로 빌드 및 응답을 확인했으며, 실제 블로그에 페이지네이션 코드를 적용한 것은 아닙니다.
순서를 정한 뒤에 자릅니다
먼저 여섯 개를 자르고 나머지 작업을 하면 결과가 달라집니다. 예를 들어 전체 목록의 첫 여섯 개를 고른 뒤 Fresh 글만 남기면, Fresh 글이 뒤에 충분히 있어도 첫 페이지가 비어 보일 수 있습니다.
두 라우트 모두 필터링과 정렬이 끝난 배열을 페이지네이션 함수에 넘깁니다.
| 화면 | 배열을 준비하는 순서 |
|---|---|
| 카테고리 | 최신순으로 정렬된 글 목록에서 해당 카테고리만 선택 → 페이지 분할 |
| 검색 | 검색어로 필터링 → 관련도순 정렬 → 동점은 최신순 → 페이지 분할 |
기존 최신순은 날짜 내림차순, 같은 날짜면 order 내림차순입니다. 두 값까지
같으면 slug로 순서를 정합니다. 페이지를 나누기 위해 날짜에 시각을 새로 넣거나
순번을 바꿀 필요는 없습니다.
빈 첫 페이지와 존재하지 않는 페이지를 구분합니다
이번 예제의 응답 규칙을 먼저 정했습니다.
| 입력 | 처리 |
|---|---|
page 없음 또는 page=1 |
첫 페이지 |
| 범위 안의 양의 정수 | 해당 페이지 |
page=0, page=01, page=1e2 |
404 |
| 마지막 페이지를 초과함 | 404 |
| 결과 없음, 첫 페이지 | 200과 빈 결과 안내 |
빈 검색은 유효한 검색 화면입니다. 반면 결과가 없는 목록의 두 번째 페이지는 제공하지 않습니다. 잘못된 페이지 번호를 첫 페이지로 조용히 바꾸는 대신, 이 프로젝트에서는 존재하지 않는 목록 페이지로 처리합니다.
utils/pagination.ts를 만듭니다.
const PAGE_SIZE = 6;
export function paginate<T>(items: readonly T[], rawPage: string | null) {
if (rawPage !== null && !/^[1-9][0-9]*$/.test(rawPage)) return null;
const page = rawPage === null ? 1 : Number(rawPage);
const lastPage = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
if (!Number.isSafeInteger(page) || page > lastPage) return null;
const offset = (page - 1) * PAGE_SIZE;
return {
page,
lastPage,
offset,
items: items.slice(offset, offset + PAGE_SIZE),
};
}
export function pageHref(path: string, page: number, query = "") {
const params = new URLSearchParams();
if (query) params.set("q", query);
if (page > 1) params.set("page", String(page));
const search = params.toString();
return search ? `${path}?${search}` : path;
}Math.max(1, ...) 덕분에 결과가 없어도 첫 페이지를 표시할 수 있습니다.
Number()를 호출하기 전에 형식을 검사하는 이유는 지수 표기나 앞에 0을 붙인 값을
페이지 주소로 받지 않기 위해서입니다. 아주 큰 수는 안전한 정수 검사에서
제외합니다.
링크는 검색어를 URL에 직접 이어 붙이지 않고 URLSearchParams로 만듭니다. 예를
들어 묶음 & Deno의 &는 검색어의 일부여야 합니다. 문자열을 그대로 연결하면
쿼리 항목을 구분하는 기호로 해석될 수 있습니다.
WHATWG URL 표준
두 화면이 같은 탐색 링크를 사용합니다
components/Pagination.tsx에 다음 컴포넌트를 추가합니다.
import { pageHref } from "../utils/pagination.ts";
interface Props {
path: string;
page: number;
lastPage: number;
query?: string;
}
export default function Pagination({ path, page, lastPage, query }: Props) {
if (lastPage <= 1) return null;
return (
<nav aria-label="페이지 탐색" class="mt-8 flex items-center gap-4">
{page > 1 && <a href={pageHref(path, page - 1, query)}>이전 페이지</a>}
<span>{page} / {lastPage} 페이지</span>
{page < lastPage && (
<a href={pageHref(path, page + 1, query)}>다음 페이지</a>
)}
</nav>
);
}첫 페이지에는 이전 링크가 없고, 마지막 페이지에는 다음 링크가 없습니다. 첫
페이지로 돌아가는 주소에는 page=1을 붙이지 않습니다.
카테고리에서 글 번호와 canonical을 맞춥니다
routes/[category].tsx의 기존 import에 다음 항목을 추가합니다.
import { HttpError } from "fresh";
import Pagination from "../components/Pagination.tsx";
import { pageHref, paginate } from "../utils/pagination.ts";categoryInfo는 유지하고, 핸들러를 교체합니다. 요청 URL은 Fresh의 ctx.url에서
읽을 수 있습니다.
Fresh Context 문서
export const handler = define.handlers({
async GET(ctx) {
const category = ctx.params.category.toLowerCase();
if (!Object.hasOwn(categoryInfo, category)) {
throw new HttpError(404);
}
const info = categoryInfo[category as keyof typeof categoryInfo];
const posts = await getPosts();
const filtered = posts.filter(
(post) => post.meta.category.toLowerCase() === category,
);
const pagination = paginate(filtered, ctx.url.searchParams.get("page"));
if (!pagination) throw new HttpError(404);
return {
data: {
category,
info,
posts: pagination.items,
page: pagination.page,
lastPage: pagination.lastPage,
offset: pagination.offset,
},
};
},
});목록의 map()은 그대로 사용합니다. 글 앞의 번호는 배열 인덱스에 시작 위치를
더합니다.
{
String(data.offset + index + 1).padStart(2, "0");
}그래야 두 번째 페이지가 다시 01로 시작하지 않고 07부터 이어집니다. 글 목록의
</section> 뒤, </main> 앞에 탐색 링크를 넣습니다.
<Pagination path={path} page={data.page} lastPage={data.lastPage} />;이때 페이지 컴포넌트의 기존 canonical, pageTitle 선언을 다음 코드로
바꿉니다. 아래의 path는 위 탐색 링크에도 사용합니다.
const path = `/${data.category}`;
const canonical = new URL(pageHref(path, data.page), SITE.url).href;
const suffix = data.page > 1 ? ` · ${data.page}페이지` : "";
const pageTitle = `${data.info.title}${suffix} | ${SITE.name}`;첫 페이지의 canonical은 /fresh, 두 번째 페이지는 /fresh?page=2를 가리킵니다.
/fresh?page=1로 들어와도 첫 페이지를 보여주며, canonical만 /fresh로
통일합니다. 공개 카테고리의 각 페이지를 모두 첫 페이지로 지정하지 않는 것은
Google의 페이지네이션 안내에도
부합합니다.
404는 12편의 공통 오류 페이지로 연결됩니다.
검색에서는 전체 결과 수와 검색어를 유지합니다
routes/search.tsx에는 다음 import를 추가합니다.
import { HttpError } from "fresh";
import Pagination from "../components/Pagination.tsx";
import { paginate } from "../utils/pagination.ts";검색 함수가 반환한 배열을 자르되, 자르기 전의 결과 수를 따로 전달합니다.
export const handler = define.handlers({
async GET(ctx) {
const query = ctx.url.searchParams.get("q")?.trim() ?? "";
const results = query ? await searchPosts(query) : [];
const pagination = paginate(results, ctx.url.searchParams.get("page"));
if (!pagination) throw new HttpError(404);
return {
data: {
query,
results: pagination.items,
totalResults: results.length,
page: pagination.page,
lastPage: pagination.lastPage,
},
};
},
});기존 검색 결과의 개수 표시는 results.length에서 data.totalResults로
바꿉니다. 두 번째 페이지에 글이 두 개만 보여도 전체 결과가 여덟 개라면 검색
결과 8개로 표시해야 하기 때문입니다. results.map()은 현재 페이지 배열을
렌더링하는 데 그대로 사용합니다. 기존 코드에 post.readingTime 표시가 있다면
제거합니다. 현재 SearchResult에는 그 필드가 없습니다.
카테고리와 같은 위치에 탐색 링크를 추가하되, 검색어를 함께 넘깁니다.
<Pagination
path="/search"
page={data.page}
lastPage={data.lastPage}
query={query}
/>;검색 폼에는 page를 담는 숨은 입력을 추가하지 않습니다. 두 번째 페이지에서 새
검색어를 제출하면 새 검색의 첫 페이지부터 보여주기 위해서입니다. 검색 응답에
있던 noindex, follow도 유지합니다.
같은 날짜의 글 여덟 개로 확인했습니다
페이지 경계가 잘못되면 글 하나가 두 페이지에 중복되거나 빠질 수 있습니다. 이를
확인하기 위해
16편의 임시 파일 방식처럼 같은
날짜에 속하는 테스트 글 여덟 개를 만들었습니다. 검색 점수도 동점이 되도록 제목과
태그를 맞추고 order만 1부터 8까지 다르게 넣었습니다.
13편의 HTTP 검증 방식으로 빌드된 서버 핸들러를 직접 호출했습니다. 광고 스크립트를 요청하거나 실행하지 않았습니다.
| 확인 항목 | 결과 |
|---|---|
| 카테고리와 검색의 1·2페이지 | 8, 7, 6, 5, 4, 3 → 2, 1, 중복·누락 없음 |
| 카테고리 2페이지의 번호 | 07, 08 |
검색어 묶음 & Deno로 다음·이전 이동 |
두 링크에서 검색어 복원, 2페이지에서도 전체 8개 표시 |
| 검색어 없음·검색 결과 없음·빈 카테고리의 첫 페이지 | 200, 페이지 탐색 링크 없음 |
| 잘못된 번호와 범위를 넘긴 페이지 | 404, 공통 오류 안내 |
| 카테고리의 canonical과 검색의 robots 메타 | 첫 페이지와 두 번째 페이지의 canonical 구분, 검색의 noindex, follow 유지 |
변경한 코드의 타입 검사와 빌드, 서버 응답 테스트 네 개가 통과했습니다.
줄어든 것은 렌더링하는 목록의 길이입니다
이번에는 글 파일을 전부 읽고 검색 점수를 계산한 다음에 배열을 자릅니다. 응답에 넣는 글 카드는 줄어들지만, 요청마다 읽는 파일 수와 검색 계산량은 그대로입니다.
또한 중복과 누락이 없다는 검증은 목록이 바뀌지 않는 동안의 결과입니다. 첫 페이지를 본 뒤 새 글이 발행되면, 다음 요청에서 각 페이지의 경계가 밀릴 수 있습니다. 글이 드물게 추가되는 이 블로그에서는 주소가 단순한 페이지 번호 방식부터 적용할 수 있습니다.
이제 카테고리의 오래된 글이나 검색 결과의 다음 묶음을 링크로 열 수 있습니다. 검색어와 페이지를 URL에 남겼기 때문에 새로고침이나 주소 공유 뒤에도 같은 조건으로 목록을 요청할 수 있습니다.