정적 블로그에 Pagefind 검색 붙이기 — Astro + Cloudflare Workers 조합
Astro 정적 사이트에 Pagefind 풀텍스트 검색을 통합하고 Cloudflare Workers로 서빙할 때 마주친 빌드 훅, trailing slash 충돌, init() API 실수와 해결법을 내부 실측으로 정리합니다.

정적 블로그의 가장 큰 약점은 검색이다. DB가 없으니 SQL LIKE도, Elasticsearch도 쓸 수 없다. Pagefind는 이 문제를 빌드 타임 인덱싱으로 해결한다. Astro의 정적 HTML을 미리 긁어 검색 인덱스를 만들고, 브라우저에서 WebAssembly로 질의한다. 서버가 필요 없고, 인덱스 파일을 CDN에 올리기만 하면 된다. 이 글은 서민혁닷컴에 Pagefind를 붙인 과정과 거기서 만난 함정들을 정리한다.
Pagefind가 정적 검색에서 선택되는 이유
정적 사이트에서 검색을 구현하는 선택지는 몇 가지 있다. Lunr.js나 Fuse.js처럼 클라이언트 사이드에서 JSON 파일을 로드해 필터링하는 방법, Algolia나 Meilisearch 같은 외부 검색 API를 호출하는 방법, 그리고 Pagefind처럼 정적 인덱스를 생성하는 방법이다.
Pagefind의 장점은 인덱스가 이미 형태소 분석과 랭킹까지 마친 바이너리라는 점이다. 빌드 시 npx pagefind --site dist로 HTML을 크롤링하면 인덱스 파일이 생성되고, 클라이언트는 약 200KB의 WASM+JS를 로드해 질의한다. 외부 API 호출이 없으므로 요금·속도 제한에서 자유롭고, 방문자가 오프라인 캐시된 인덱스로도 검색할 수 있다. 단점은 인덱스 크기가 사이트 페이지 수에 비례해 증가한다는 점이다. 100페이지 기준으로 인덱스는 약 수백 KB에서 2MB 사이를 유지했고, 한 페이지당 수천 단어가 넘어가면 인덱스가 눈에 띄게 커진다.
이 블로그는 Cloudflare Workers의 정적 자산(assets.directory: "./dist")으로 서빙하므로, 별도 서버 없이 Pagefind 인덱스를 그대로 올릴 수 있다.
Astro 빌드 후크로 Pagefind 인덱싱 자동화
Pagefind는 build 명령이 끝난 뒤 dist 디렉터리를 읽어 인덱스를 만든다. Astro는 astro:build:done 훅을 제공하지 않지만, package.json의 build 스크립트에 Pagefind 명령을 연결하는 것으로 충분하다.
{
"scripts": {
"build": "astro build && pagefind --site dist"
}
}
이렇게 하면 npm run build를 실행할 때 Astro가 HTML을 생성하고, 바로 이어서 Pagefind가 인덱싱한다. 두 단계가 실패하면 전체 빌드가 중단되므로, 배포 스크립트에서 &&를 쓰면 Pagefind 실패 시 배포도 막을 수 있다.
실측 기준으로 약 100페이지(24,000 단어)에서 Pagefind 인덱싱 시간은 2~4초 정도였다. CI=true 환경에서도 별도 메모리 설정 없이 동작했다. 인덱스 파일은 dist/pagefind/ 아래에 생성된다. 이 디렉터리를 .gitignore에 넣는 것을 권장한다 — 빌드 산출물이며, 커밋할 이유가 없다.
Workers 정적 자산에서 Pagefind 서빙하기
Cloudflare Workers의 assets 설정으로 정적 파일을 서빙할 때, dist/pagefind/ 아래의 모든 파일은 추가 라우트 없이 접근 가능해야 한다. 기본적으로 https://도메인/pagefind/pagefind.js 같은 경로로 매핑된다.
여기서 첫 번째 함정이 발생한다. wrangler.jsonc의 html_handling 옵션이 drop-trailing-slash나 auto-trailing-slash로 설정되어 있으면, Pagefind의 JavaScript 파일 경로가 의도치 않게 리다이렉트될 수 있다. 실제로 drop-trailing-slash 설정에서 Pagefind의 pagefind.js 로딩이 307 리다이렉트를 타는 현상을 발견했다. Pagefind의 init()이 pagefind/ 경로를 참조하는데, 슬래시 처리 규칙과 충돌한 것이다.
해결은 간단했다. wrangler.jsonc에서 assets.not_found_handling을 “404-page”로 유지하되, html_handling과 pagefind/ 경로가 충돌하지 않도록 drop-trailing-slash 아래에서도 Pagefind 파일이 .js, .wasm 확장자로 직접 서빙되게 했다. Workers는 정적 자산의 MIME 타입을 확장자로 판단하므로, .js와 .wasm은 html_handling 규칙의 영향을 받지 않는다. 문제는 Pagefind가 생성하는 pagefind/pagefind.js가 아니라, init() 호출 시 사용하는 경로가 문제였다.
init() API 시그니처와 경로 설정의 함정
Pagefind의 초기화는 pagefind = await import('/pagefind/pagefind.js')로 시작한다. 그런데 많은 예제가 이렇게 되어 있어서 trailing slash가 있는 환경에서 의도치 않은 동작을 일으킨다. 정확한 패턴은 다음과 같다.
const pagefind = await import('/pagefind/pagefind.js');
const search = await pagefind.search('쿼리');
여기서 /pagefind/pagefind.js는 사이트 루트 기준 절대 경로다. 블로그가 https://blog.example.com/pagefind/pagefind.js에서 제공된다면 이 경로가 맞다. 그런데 Workers가 html_handling: "drop-trailing-slash" 상태에서 /pagefind/로 요청이 들어오면 307로 /pagefind로 리다이렉트한다. Pagefind의 초기화 코드가 내부적으로 이 경로를 참조하는 타이밍에 문제가 생긴다.
실제로 내부 실측 결과, drop-trailing-slash 환경에서 import('/pagefind/pagefind.js')는 정상 동작했지만, import('/pagefind/pagefind/')처럼 잘못된 경로를 지정하거나 Pagefind 내부에서 pagefind/ 디렉터리를 슬래시 포함 경로로 참조할 때 307이 발생했다. 해결법은 init() 시 경로를 정확히 지정하는 것뿐 아니라, wrangler.jsonc에서 html_handling을 확인하고 pagefind/ 디렉터리에만 “none”을 적용하는 우회도 고려할 수 있다. 하지만 더 깔끔한 방법은 Pagefind 공식 문서가 제안하는 pagefind_entry_url 옵션으로 진입점 경로를 명시하는 것이다.
const pagefind = await import('/pagefind/pagefind.js');
pagefind.options({
baseUrl: '/',
pagefind_entry_url: '/pagefind/'
});
trailingSlash와 검색 경로 충돌 해소
Astro의 trailingSlash 설정과 Workers의 html_handling이 엇갈리면 Pagefind뿐 아니라 사이트 내 모든 경로에서 혼란이 생긴다. 이 블로그는 astro.config.mjs에서 trailingSlash: 'never'를 사용하고, Workers에서는 html_handling: 'drop-trailing-slash'로 맞췄다. 이 조합에서 Pagefind의 /pagefind/pagefind.js는 정상적으로 200을 반환한다.
만약 Astro가 trailingSlash: 'always'라면 Workers도 force-trailing-slash로 맞추고, Pagefind init 경로를 /pagefind/pagefind.js/가 아니라 /pagefind/pagefind.js로 유지해야 한다. Pagefind의 내부 라우팅은 .js와 .wasm 확장자를 직접 요청하므로, 슬래시 규칙보다 파일 확장자 핸들링이 우선된다. 다만 HTML 페이지의 검색 결과 링크는 Astro의 슬래시 규칙을 따라야 하므로, 검색 결과에서 반환된 URL과 실제 페이지 경로가 일치하는지도 확인해야 한다.
실제 동작 확인
배포 후 검색 동작을 확인하는 가장 빠른 방법은 브라우저 개발자도구의 Network 탭에서 /pagefind/ 요청을 관찰하는 것이다.
curl -sI https://seominhyuk.com/pagefind/pagefind.js | head -5
200이 반환되고 content-type: application/javascript면 정상이다. 307이나 404가 나오면 html_handling이나 Pagefind 인덱스가 제대로 생성되지 않은 것이다.
검색 UI는 Astro 컴포넌트로 분리해두었다. 필요한 스타일과 마크업만 포함하고, Pagefind의 CSS를 덮어쓰지 않도록 scope를 제한했다. Pagefind는 기본 스타일을 제공하지만, 사이트 디자인에 맞추려면 --pagefind-ui-* CSS 변수를 재정의하는 것이 가장 깔끔하다.
한계: 인덱스 크기, 필터링, 언어
Pagefind의 가장 큰 한계는 인덱스 크기다. 페이지 수가 500을 넘어가면 인덱스가 수 MB로 커져 초기 로딩 속도에 영향을 준다. 또 Pagefind는 data-pagefind-filter 속성으로 메타데이터 필터링을 지원하지만, 관계형 필터(가격 범위나 날짜 비교)는 SQL처럼 자유롭지 않다. 태그 기반 탐색에는 충분하지만, 복합 조건 검색에는 적합하지 않다.
한국어 형태소 분석은 Pagefind가 내장하지 않는다. pagefind --language ko 옵션을 주면 한국어 토큰화를 시도하지만, mecab-ko나 은전한닢 같은 전문 형태소 분석기보다는 정확도가 떨어진다. 형태소 분석이 중요한 블로그라면 인덱스 옵션을 조정하거나, 검색어를 직접 전처리해 Pagefind에 넘기는 방법을 검토해야 한다.
같은 이유로 Pagefind의 “best match” 랭킹은 한국어에서 예상과 다른 결과를 반환할 수 있다. 영어 중심으로 설계된 TF-IDF 변형이기 때문이다. 실제 사용자 테스트가 필요하다.
출처
- 내부 실측: 레포
package.json의"build": "astro build && pagefind --site dist",wrangler.jsonc의html_handling: "drop-trailing-slash", Chrome DevTools Network 패널/pagefind/요청 확인 (200·307·404 진단) - Pagefind 공식 문서 — 설치 및 init() — 초기화 옵션과 경로 설정 (열람: 2026-08-10)
- Pagefind 공식 문서 — 언어 옵션 — 한국어 인덱싱 지원 (열람: 2026-08-10)
- Cloudflare Workers 정적 자산 HTML handling 문서 —
drop-trailing-slash동작 (열람: 2026-08-10) - Astro 빌드 프로세스 문서 —
astro build산출물 구조 (열람: 2026-08-10) - Notion 시드: «블로그 Pagefind 검색 연동» — 빌드 훅, 슬래시 충돌, init 경로 문제 기록