Astro 페이지네이션 도전과 롤백 — getStaticPaths paginate가 전달하지 못한 props
Astro 7에서 getStaticPaths paginate로 페이지네이션을 시도했지만 page 객체가 undefined로 전달된 사례. 원인 분석, 수동 getStaticPaths로의 회귀, Astro 버전별 API 변화와 관련한 교훈.
블로그 포스트가 90개를 넘어가면서 홈페이지와 카테고리 페이지에 페이지네이션이 필요해졌다. 글을 12개씩 나누고 /page/2, /category/it-trials/page/2 같은 URL로 접근할 수 있게 만드는 작업이었다. Astro 7은 SSG(Static Site Generator) 모드에서 getStaticPaths로 동적 페이지를 생성하므로, 페이지네이션도 이 메커니즘을 따라야 한다. 그런데 첫 시도는 실패로 끝났다. template에 전달한 page 객체가 undefined였다.
시도: getStaticPaths paginate 사용
Astro 2.x 시절에는 getStaticPaths에 paginate() 헬퍼 함수가 내장되어 있었다. 공식 문서 예제는 대략 이런 형태였다.
export async function getStaticPaths({ paginate }) {
const posts = await getAllPosts();
return paginate(posts, { pageSize: 12 });
}
이 방식은 paginate()가 페이지별 params와 props를 자동 생성해 주고, template에는 page라는 이름으로 페이지 메타데이터(현재 페이지, 전체 페이지, 글 목록)가 전달된다. Astro 7에서도 먹힐 거라고 생각했다. 실제로 getStaticPaths가 함수를 인자로 받을 수 있고, 구조 분해로 paginate를 꺼내 쓰는 문법 자체는 에러를 내지 않았다.
그래서 아래처럼 작성했다.
export async function getStaticPaths({ paginate }) {
const posts = await getAllPosts();
return paginate(posts, { pageSize: 12 });
}
빌드는 통과했다. 하지만 생성된 페이지를 열어 보니 page 변수가 undefined였다. template에서 page.data에 접근하는 모든 코드가 런타임 에러를 냈다.
관찰: page 객체는 어디서 증발했나
에러 메시지는 단순했다.
TypeError: Cannot read properties of undefined (reading 'data')
Astro.props를 찍어보니 page가 없었다. getStaticPaths가 반환한 배열의 각 항목에 props: { page: ... }가 포함되어 있을 거라는 기대와 달리, paginate()가 생성한 props가 template까지 전달되지 않은 것이다.
원인을 추적하기 위해 getStaticPaths의 반환값을 직접 로깅해 봤다.
export async function getStaticPaths({ paginate }) {
const posts = await getAllPosts();
const result = paginate(posts, { pageSize: 12 });
console.log(JSON.stringify(result[0], null, 2));
return result;
}
출력된 첫 번째 항목의 구조는 이랬다.
{
"params": { "page": "2" },
"props": {
"page": {
"data": [...],
"currentPage": 1,
"totalPages": 8,
...
}
}
}
props에 page가 분명히 들어 있다. 그런데 template에서는 undefined다. Astro 7에서 paginate()의 반환 타입이 내부 props 전달 파이프라인과 호환되지 않는 것으로 보인다. Astro 3.0부터 paginate()가 deprecated되고 결국 제거된 이유이기도 하다.
평가: 왜 paginate가 동작하지 않는가
Astro 3.0 릴리스 노트에는 paginate()가 제거되고 getStaticPaths의 수동 반환 방식으로 대체되어야 한다고 명시되어 있다. Astro 7에서 paginate()를 호출하는 것 자체는 막히지 않는다. getStaticPaths가 ({ paginate })를 인자로 받는 인터페이스가 아직 남아 있기 때문이다. 하지만 내부 구현에서 이 인터페이스는 더 이상 props 전달을 보장하지 않는다.
실제로 Astro 7의 getStaticPaths 시그니처를 다시 확인해 보면, 콜백 인자는 라우트 매칭용 컨텍스트일 뿐 paginate 같은 헬퍼를 바인딩해 주지 않는다. 즉 ({ paginate })로 받은 paginate는 undefined가 아니라 함수 객체이지만, 그 함수가 생성한 props를 template이 해석하는 방식이 깨져 있다. 이는 Astro가 내부적으로 페이지 생성 파이프라인을 리팩터링하면서 paginate()의 props 매핑 코드를 유지보수하지 않았기 때문으로 추정된다.
해결: 수동 getStaticPaths
paginate()에 더 이상 의존할 수 없다면, getStaticPaths가 params와 props를 직접 반환하는 방식으로 전환해야 한다. 모든 페이지네이션 상태를 수동으로 계산하고 전달한다.
export async function getStaticPaths() {
const posts = await getAllPosts();
const totalPages = Math.max(1, Math.ceil(posts.length / POSTS_PER_PAGE));
// 1페이지는 index.astro가 담당하므로 2페이지부터 생성
return Array.from({ length: Math.max(0, totalPages - 1) }, (_, i) => ({
params: { page: String(i + 2) },
}));
}
const { page } = Astro.params;
const currentPage = Number(page);
const posts = await getAllPosts();
const totalPages = Math.max(1, Math.ceil(posts.length / POSTS_PER_PAGE));
const currentPosts = posts.slice(
(currentPage - 1) * POSTS_PER_PAGE,
currentPage * POSTS_PER_PAGE,
);
핵심 변화는 세 가지다.
getStaticPaths에paginate를 전달받지 않고 params를 직접 생성한다.page는 props가 아니라Astro.params에서 꺼내고,Number()로 정수 변환한다.- 글 목록과 페이지 메타데이터는 params와 별개로 template에서 다시 계산한다.
props 대신 params로 페이지 번호를 전달하는 이유는, params는 Astro의 라우트 매칭에 필수적인 값이고 props는 선택적인 추가 데이터이기 때문이다. 페이지 번호는 URL의 일부(/page/2에서 2)이므로 params가 자연스럽다. 실제 글 데이터는 getAllPosts()를 template에서 재호출해 필요한 구간을 잘라 쓴다. 이 방식은 페이지 수가 많아져도 빌드 타임에 모든 페이지가 생성되므로, SSG의 성능 이점을 그대로 유지한다.
카테고리 페이지도 같은 원칙으로 구현했다.
export async function getStaticPaths() {
const paths = [];
for (const c of CATEGORIES) {
const posts = await getPostsByCategory(c.slug);
const totalPages = Math.max(1, Math.ceil(posts.length / POSTS_PER_PAGE));
for (let p = 2; p <= totalPages; p++) {
paths.push({
params: { slug: c.slug, page: String(p) },
props: { category: c },
});
}
}
return paths;
}
여기서는 category 객체를 props로 전달했다. 카테고리 이름, 설명, 슬러그 등 페이지 렌더링에 필요한 고정 데이터는 params로 표현하기 어렵기 때문이다. 다만 이 props는 페이지가 많아져도 고정된 데이터(category 이름·설명)이므로 빌드 캐시에 문제되지 않는다. 페이지 번호 자체는 여전히 Astro.params.page에서 가져온다.
이 구현은 commit e5a7a7c에 포함되어 현재 main 브랜치에서 동작 중이다. src/pages/page/[page].astro와 src/pages/category/[slug]/page/[page].astro가 각각 홈과 카테고리의 페이지네이션을 담당하고, src/components/Pagination.astro가 페이지 전환 UI를 그린다.
한계: 앞으로 주의할 점
이 수동 방식은 paginate()에 비해 코드량이 늘어난다. 페이지네이션 관련 로직이 세 파일(페이지 템플릿, 페이지 라우트, 컴포넌트)에 분산되어 있어, 예를 들어 페이지 크기를 바꾸려면 세 군데를 모두 수정해야 한다. 공통 유틸리티로 postsPerPage 상수와 getTotalPages() 함수를 src/lib/posts.ts에 모아 두었지만 완전히 중앙화된 것은 아니다.
또 하나, 현재 방식은 getStaticPaths가 빌드 타임에 모든 페이지를 생성한다. 포스트가 100개라면 홈 9페이지 + 카테고리당 19페이지 = 약 5070개의 HTML이 정적으로 생성된다. 지금은 문제가 안 되지만, 포스트가 수천 개로 늘어나면 빌드 시간이 길어질 수 있다. 그 시점에서는 ISR(Incremental Static Regeneration)이나 SSR 전환을 고려해야 한다. Astro 7은 아직 ISR을 공식 지원하지 않으므로, 그 전까지는 페이지 수에 선형으로 비례하는 빌드 시간을 감수하거나 엣지에서 온디맨드 렌더링하는 방식으로 전환해야 한다.
마지막으로, Astro.params에서 꺼낸 page 값은 문자열이므로 Number() 변환이 필요하다. 이 변환을 잊으면 "2" * POSTS_PER_PAGE 같은 JavaScript의 암묵적 문자열-숫자 연산이 들어가서 미묘한 버그를 만든다. 이 패턴이 여러 파일에 흩어져 있으므로, 앞으로 리팩터링한다면 Astro 미들웨어나 공통 유틸리티 함수로 페이지네이션 로직을 모을 계획이다.
paginate()가 다시 도입될 가능성은 낮아 보인다. Astro 팀은 getStaticPaths의 단순한 params/props 패턴을 밀고 있고, 페이지네이션은 콘텐츠 컬렉션의 쿼리 레이어(필터·정렬·오프셋)가 성숙해지면 그 위에서 해결하려는 방향으로 보인다. Astro 8이나 그 이후에 콘텐츠 컬렉션에 페이지네이션 기능이 내장될지 지켜볼 만하다.
출처
- 내부 실측: 레포
src/pages/page/[page].astro및src/pages/category/[slug]/page/[page].astro(getStaticPathsparams/props 수동 전달 구현),src/components/Pagination.astro(UI 컴포넌트),src/lib/posts.ts(POSTS_PER_PAGE=12,getTotalPages()), 커밋e5a7a7c(초기 페이지네이션 도입),getStaticPaths반환값pageprops 로깅 결과 (props 존재하나 template에서 undefined) - Astro 3.0 Release Notes — Removed
paginate()— paginate 제거 및 수동 대체 권고 (열람: 2026-08-11) - Astro
getStaticPathsAPI — params/props 반환 명세 (열람: 2026-08-11) - Astro Configuration:
trailingSlash— 페이지 경로 정규화 참고 (열람: 2026-08-11)