dev · 통합검색
블로그 통합검색 만들기
이 블로그 오른쪽 위 검색창은 검색 서버 없이 동작해요. 노트를 절 단위로 잘라 빌드할 때 색인을 만들고, 브라우저는 필요한 조각만 받아 와요. 그 과정을 움직이는 그림으로 정리했어요.
01. 통합검색이 하는 일
검색창에 낱말을 치면 모든 과목의 노트에서 절(h2 · h3 제목 하나와 그 아래 글)을 찾아 과목별로 묶어 보여 줘요. 결과를 누르면 그 페이지의 그 절로 바로 이동해요. 결과 줄에는 01 입문 › 3부 · Pod › 04. 상태와 재시작처럼 어디에 있는 글인지 경로가 붙어요.
서버 없이 검색하는 방법
보통 검색은 브라우저가 검색 서버에 물어보고, 서버가 데이터베이스를 뒤져 답을 줘요. 이 블로그는 모든 페이지를 빌드할 때 미리 만들어 두는 정적 사이트라서 서버가 없어요. 그래서 색인(어떤 낱말이 어느 절에 있는지 적어 둔 목록)도 빌드할 때 파일로 만들어 페이지와 함께 올려 두고, 브라우저가 그 파일을 직접 읽어 검색해요.
색인을 통째로 받으면 글이 많아질수록 무거워져요. Pagefind는 색인을 여러 조각으로 나눠 두고, 검색한 낱말이 들어 있는 조각만 받아요. 서버 비용이 0이고, 글이 수천 편이 돼도 한 번에 받는 양이 작아요.
02. 빌드할 때: 노트를 절 단위 레코드로
검색 결과가 "페이지"가 아니라 "절"로 나와야 바로 그 자리로 갈 수 있어요. 그래서 빌드 단계(build-content)가 노트 HTML을 읽어 제목마다 잘라서 검색 레코드를 만들어요. 레코드 하나에는 이동할 주소(/kubernetes/01-intro#p3-04), 제목, 경로, 본문, 코드가 따로 들어가요.
자르는 규칙은 콘텐츠 유형의 theme.json에 있어요. 어떤 제목에서 자를지(h2[id], h3[id]), 무엇을 뺄지(오른쪽 목차, 그림 버튼, 스크립트)를 유형마다 정해 두면, 모양이 다른 노트도 같은 방식으로 레코드가 돼요.
type SearchRecord = {
url: string; // "/kubernetes/01-intro#p3-04"
topic: string; // "kubernetes"
pageTitle: string; // "01 입문"
heading: string; // "04. 상태와 재시작"
breadcrumb: string[]; // ["3부 · Pod", "04. 상태와 재시작"]
text: string; // 본문 (코드 제외)
code?: string; // 코드 블록
};
레코드는 검색 엔진과 상관없는 형식으로 .generated/search-records.json에도 저장해요. 나중에 엔진을 바꿔도 이 파일을 그대로 넣으면 돼요.
절이 겹치지 않게 자르기
처음 설계는 "다음 같은 수준 이상의 제목까지가 한 절"이었어요. 그러면 h2 레코드 안에 그 아래 h3 글이 모두 들어가서, h3에 있는 낱말을 찾으면 h2 결과와 h3 결과가 같은 발췌로 두 번 나와요. 그래서 수준과 관계없이 다음 제목까지로 바꿨어요. h2 레코드에는 h2 바로 아래 도입 글만 들어가요. 대신 레코드 맨 앞에 제목을 넣어서 h2 제목으로 찾으면 h2 레코드가 나와요.
레코드를 색인으로
그다음 build-search가 레코드를 Pagefind에 넣어요. 레코드마다 작은 HTML을 만들어 넣는데, 제목은 <h1>로 감싸서 Pagefind가 제목에 높은 점수를 주게 하고, 코드는 점수를 절반으로 낮춰요. 주소 · 경로 같은 정보는 색인 글자에서 빼고 결과에 실어 보낼 값(메타)으로만 붙여요.
<main data-pagefind-body>
<h1>04. 상태와 재시작</h1>
<p>CrashLoopBackOff는 컨테이너가 계속 죽고 …</p>
<pre data-pagefind-weight="0.5">kubectl describe pod …</pre>
<div data-pagefind-ignore> <!-- 메타 · 필터 --> </div>
</main>
Pagefind는 언어를 ko로 정하면 한국어 문장을 낱말로 나눠서 색인해요. 이 단어 분리는 확장 빌드(pagefind_extended)에만 들어 있어서, 배포 서버(Vercel, Linux)에서도 확장 빌드가 쓰이는지 따로 확인했어요. 로컬과 배포의 색인 해시가 같고 질문별 결과도 같았어요.
03. 검색할 때: 필요한 조각만 받기
브라우저 쪽은 lib/search/의 작은 어댑터가 맡아요. 화면은 search(검색어, 필터) → 결과 목록만 알고, 그 안에서 Pagefind를 부르는지 다른 엔진을 부르는지는 몰라요. 엔진을 바꿀 때 이 부분만 새로 만들면 돼요.
입력이 멈출 때까지 기다리기
글자 하나 칠 때마다 검색하면 "쿠", "쿠버", "쿠버네"… 쓸모없는 검색이 쌓여요. 그래서 입력이 150ms 멈췄을 때만 검색해요(디바운스). 그래도 앞 검색의 답이 늦게 도착할 수 있어서, 응답이 올 때 지금 검색어의 답이 아니면 버려요. 버리지 않으면 "쿠버네티스" 결과가 나왔다가 늦게 온 "쿠버" 결과로 바뀌어 버려요.
색인 조각 받기
처음 검색할 때 Pagefind 실행 파일과 색인 목록을 받고, 검색어에 들어 있는 낱말의 조각만 더 받아요. 첫 검색은 약 0.8초가 걸렸는데 대부분 이 첫 다운로드였어요. 그래서 검색창에 포커스가 들어오는 순간 미리 불러 두게 했어요(prepare). 한 번 받은 조각은 브라우저가 기억해서, 같은 낱말을 다시 찾으면 바로 나와요.
결과 발췌는 Pagefind가 찾은 글자를 <mark>로 감싸서 줘요. 코드 블록의 <none> 같은 글자가 HTML로 해석되지 않게, <mark>만 남기고 나머지는 모두 글자로 바꾼 뒤 화면에 넣어요.
04. 한국어 검색 다듬기
Pagefind는 한국어 낱말을 나누긴 하지만, "클러스터를"과 "클러스터"가 같은 말이라는 것(어간)까지는 몰라요. 그래서 엔진에 넘기기 전에 검색어를 다듬는 단계(lib/search/query.ts)를 따로 뒀어요. 엔진과 무관한 코드라 엔진을 바꿔도 그대로 써요.
조사 떼기
"클러스터를"로 찾으면 본문에 "클러스터를"이라고 붙어 있는 곳만 나왔어요(15개). 검색어 끝의 조사 을 · 를 · 은 · 는 · 에서 · 으로 등을 떼고 "클러스터"로 찾으면, 앞부분이 같은 "클러스터의 · 클러스터를 · 클러스터에서"가 모두 잡혀요(201개). "정의"처럼 떼고 나면 한 글자만 남는 말은 그대로 둬요.
한글 표기 이름 함께 찾기
노트는 리소스 이름을 영어(Pod, Deployment)로 써서 "파드", "디플로이먼트"로 찾으면 0개였어요. 작은 표기 사전(21개)을 두고, 사전에 있는 말이면 영어 이름으로도 한 번 더 찾아서 두 결과를 번갈아 합쳐요. 같은 절은 한 번만 넣어요. "노드", "볼륨"처럼 본문에서도 한글로 쓰는 말은 사전에 넣지 않았어요.
결과가 적으면 낱말 줄이기
Pagefind는 검색어의 모든 낱말이 들어 있는 절만 찾아요. "오케스트레이션이 필요한 이유"처럼 말하듯 치면 세 낱말이 다 있는 절이 드물어서 결과가 거의 없어요. 결과가 3개보다 적으면 뒤 낱말부터 하나씩 빼고 다시 찾아서 앞 결과 뒤에 붙여요.
05. 품질 재기와 엔진 결정
"잘 찾는다"를 느낌으로 정하지 않으려고, 한국어 질문 25개마다 정답 절을 미리 정해 두고 결과 몇 번째에 나오는지 쟀어요. 조사 붙은 말, 영어 리소스 이름, 명령어, 글자 일부, 오타를 고루 넣었어요. 같은 질문을 스크립트(scripts/search-eval.ts)로 언제든 다시 잴 수 있어요.
| 단계 | 1위 정답 | 5위 안 |
|---|---|---|
| 처음 (본문만 넣은 레코드) | 15 / 25 | 19 / 25 |
① 제목을 <h1>로 — 제목 가중치 | 16 / 25 | 19 / 25 |
② + 순위 옵션 termSimilarity: 3 | 18 / 25 | 18 / 25 |
| ③ + 검색어 다듬기 (조사 · 표기 이름 · 낱말 줄이기) | 20 / 25 | 22 / 25 |
남은 빈틈은 두 가지예요. 글자 중간 검색("네티스"로 쿠버네티스 찾기)과 오타 보정("kubernets")이에요. 이 둘이 되는 Typesense 같은 검색 서버로 옮기는 방법도 비교했지만, 기술 블로그에서는 드문 경우라 비용 0 · 서버 없음인 Pagefind를 유지하기로 했어요. 1위 정답 비율이 70% 아래로 떨어지면 다시 보기로 기준을 적어 뒀어요.
06. 검색창에서 생긴 문제
검색 자체는 잘 됐는데, 블로그 화면과 만나는 곳에서 문제가 두 가지 나왔어요. 둘 다 원인이 처음 짐작과 달랐어요.
글자를 칠 때마다 페이지가 올라가던 문제
긴 노트를 읽다가 검색창에 글자를 치면 페이지가 위로 밀렸어요. 첫 번째 원인은 결과 목록에서 지금 고른 항목을 보이게 하려고 부른 scrollIntoView()였어요. 이 함수는 결과 패널뿐 아니라 페이지까지 움직여요. 패널의 스크롤 위치를 직접 계산하도록 바꿨더니, 이번엔 글자를 칠 때마다 52px씩 올라갔어요.
두 번째 원인은 브라우저였어요. 블로그는 앵커로 이동할 때 제목이 상단 바에 가리지 않도록 scroll-padding-top: 72px을 둬요. 상단 바가 화면에 붙어 있어서(sticky) 그 안의 입력창도 페이지 내용으로 취급되는데, 커서가 그 72px 영역 안(위에서 20px)에 있으니 브라우저가 "커서가 가려졌다"고 보고 72 − 20 = 52px만큼 페이지를 올린 거예요. 검색창에 포커스가 있는 동안만 이 여백을 0으로 바꿔서 해결했어요.
앵커로 들어오면 제목이 가끔 어긋나던 문제
검색 결과는 #p3-04 같은 앵커 주소로 이동해요. 그런데 24번 중 7번은 제목이 제자리(위에서 104px)가 아니라 한참 위나 아래에 멈췄어요. 원인은 웹 글꼴 교체였어요. 브라우저는 페이지가 열리자마자 목적지 위치를 정하고 부드럽게 내려가는데, 약 1초 뒤 글꼴이 바뀌면 문서 전체 줄바꿈이 다시 계산되면서 위쪽 글 높이가 줄어요(01 입문은 대상 위쪽이 596px 짧아졌어요). 스크롤은 처음 정한 위치에서 멈추니 제목이 어긋난 거예요.
글꼴 교체는 없앨 수 없어서 블로그 쪽에서 보정해요. 페이지와 글꼴이 다 준비되고 스크롤이 멈춘 뒤, 사용자가 직접 스크롤하지 않았고 제목 위치가 2px 넘게 다르면 한 번만 다시 맞춰요. 그 뒤 36번 반복해 재서 모두 104px이었어요.
07. 마무리
정리하면 통합검색은 세 층이에요. 빌드 때 절 단위 레코드(엔진과 무관), 그 레코드로 만든 Pagefind 색인 조각, 그리고 검색어를 다듬어 엔진에 묻는 어댑터예요. 층을 나눠 둔 덕분에 품질을 재면서 한 층씩 고칠 수 있었고, 나중에 엔진을 바꾸더라도 레코드와 화면은 그대로 둘 수 있어요.
이 노트도 같은 과정을 거쳐 검색 레코드가 되고, 오른쪽 위 검색창에서 찾을 수 있어요.