Fresh Notes
Fresh에서 글이 늘어날 때: URL로 유지되는 페이지네이션 | Fresh Notes

Fresh

Fresh에서 글이 늘어날 때: URL로 유지되는 페이지네이션

Fresh의 카테고리와 검색 결과를 서버에서 페이지로 나눕니다. 검색어를 유지하는 링크, 같은 날짜의 글 순서, 빈 결과와 404, 페이지별 canonical을 실제 응답으로 확인합니다.

2026.11.12·Fresh·5 min read

목록을 나누되, 보고 있는 위치는 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에 남겼기 때문에 새로고침이나 주소 공유 뒤에도 같은 조건으로 목록을 요청할 수 있습니다.

#Fresh#Pagination#SSR#Search#URL