본문으로 건너뛰기
개발 · dev/site-search/블로그 통합검색 만들기

dev · 통합검색

블로그 통합검색 만들기

이 블로그 오른쪽 위 검색창은 검색 서버 없이 동작해요. 노트를 절 단위로 잘라 빌드할 때 색인을 만들고, 브라우저는 필요한 조각만 받아 와요. 그 과정을 움직이는 그림으로 정리했어요.

2026-11-03 · Next.js 16 · Pagefind 1.6 · 쿠버네티스 노트 5편 기준

01. 통합검색이 하는 일

검색창에 낱말을 치면 모든 과목의 노트에서 절(h2 · h3 제목 하나와 그 아래 글)을 찾아 과목별로 묶어 보여 줘요. 결과를 누르면 그 페이지의 그 절로 바로 이동해요. 결과 줄에는 01 입문 › 3부 · Pod › 04. 상태와 재시작처럼 어디에 있는 글인지 경로가 붙어요.

①검색어를 치면 과목별로 묶인 절 결과가 나오고, Enter를 누르면 그 절 제목으로 바로 내려가요.

보통 검색은 브라우저가 검색 서버에 물어보고, 서버가 데이터베이스를 뒤져 답을 줘요. 이 블로그는 모든 페이지를 빌드할 때 미리 만들어 두는 정적 사이트라서 서버가 없어요. 그래서 색인(어떤 낱말이 어느 절에 있는지 적어 둔 목록)도 빌드할 때 파일로 만들어 페이지와 함께 올려 두고, 브라우저가 그 파일을 직접 읽어 검색해요.

색인을 통째로 받으면 글이 많아질수록 무거워져요. 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 레코드가 나와요.

④왼쪽은 h2 레코드가 h3 글을 품고 있어 같은 낱말이 두 결과로 잡혀요. 오른쪽은 절이 겹치지 않아 가장 가까운 절 하나만 나와요.

레코드를 색인으로

그다음 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)에서도 확장 빌드가 쓰이는지 따로 확인했어요. 로컬과 배포의 색인 해시가 같고 질문별 결과도 같았어요.

⑤레코드가 작은 HTML이 되어 Pagefind로 들어가고, 한국어 낱말로 나뉜 뒤 여러 개의 색인 조각 파일로 쓰여요.

03. 검색할 때: 필요한 조각만 받기

브라우저 쪽은 lib/search/의 작은 어댑터가 맡아요. 화면은 search(검색어, 필터) → 결과 목록만 알고, 그 안에서 Pagefind를 부르는지 다른 엔진을 부르는지는 몰라요. 엔진을 바꿀 때 이 부분만 새로 만들면 돼요.

입력이 멈출 때까지 기다리기

글자 하나 칠 때마다 검색하면 "쿠", "쿠버", "쿠버네"… 쓸모없는 검색이 쌓여요. 그래서 입력이 150ms 멈췄을 때만 검색해요(디바운스). 그래도 앞 검색의 답이 늦게 도착할 수 있어서, 응답이 올 때 지금 검색어의 답이 아니면 버려요. 버리지 않으면 "쿠버네티스" 결과가 나왔다가 늦게 온 "쿠버" 결과로 바뀌어 버려요.

⑥글자를 칠 때마다 150ms 타이머가 처음부터 다시 돌고, 늦게 도착한 옛 검색어의 응답은 화면에 쓰지 않고 버려요.

색인 조각 받기

처음 검색할 때 Pagefind 실행 파일과 색인 목록을 받고, 검색어에 들어 있는 낱말의 조각만 더 받아요. 첫 검색은 약 0.8초가 걸렸는데 대부분 이 첫 다운로드였어요. 그래서 검색창에 포커스가 들어오는 순간 미리 불러 두게 했어요(prepare). 한 번 받은 조각은 브라우저가 기억해서, 같은 낱말을 다시 찾으면 바로 나와요.

⑦검색창에 들어가면 엔진을 미리 받아 두고, 검색어의 낱말이 든 조각만 내려받아요. 이미 받은 조각은 다시 받지 않아요.

결과 발췌는 Pagefind가 찾은 글자를 <mark>로 감싸서 줘요. 코드 블록의 <none> 같은 글자가 HTML로 해석되지 않게, <mark>만 남기고 나머지는 모두 글자로 바꾼 뒤 화면에 넣어요.

04. 한국어 검색 다듬기

Pagefind는 한국어 낱말을 나누긴 하지만, "클러스터를"과 "클러스터"가 같은 말이라는 것(어간)까지는 몰라요. 그래서 엔진에 넘기기 전에 검색어를 다듬는 단계(lib/search/query.ts)를 따로 뒀어요. 엔진과 무관한 코드라 엔진을 바꿔도 그대로 써요.

조사 떼기

"클러스터를"로 찾으면 본문에 "클러스터를"이라고 붙어 있는 곳만 나왔어요(15개). 검색어 끝의 조사 을 · 를 · 은 · 는 · 에서 · 으로 등을 떼고 "클러스터"로 찾으면, 앞부분이 같은 "클러스터의 · 클러스터를 · 클러스터에서"가 모두 잡혀요(201개). "정의"처럼 떼고 나면 한 글자만 남는 말은 그대로 둬요.

⑧"클러스터를"에서 조사 "를"을 떼면 같은 말로 시작하는 낱말이 모두 결과에 걸려요.

한글 표기 이름 함께 찾기

노트는 리소스 이름을 영어(Pod, Deployment)로 써서 "파드", "디플로이먼트"로 찾으면 0개였어요. 작은 표기 사전(21개)을 두고, 사전에 있는 말이면 영어 이름으로도 한 번 더 찾아서 두 결과를 번갈아 합쳐요. 같은 절은 한 번만 넣어요. "노드", "볼륨"처럼 본문에서도 한글로 쓰는 말은 사전에 넣지 않았어요.

⑨"파드"와 "Pod"를 동시에 찾고, 두 결과를 하나씩 번갈아 합치면서 겹치는 절은 건너뛰어요.

결과가 적으면 낱말 줄이기

Pagefind는 검색어의 모든 낱말이 들어 있는 절만 찾아요. "오케스트레이션이 필요한 이유"처럼 말하듯 치면 세 낱말이 다 있는 절이 드물어서 결과가 거의 없어요. 결과가 3개보다 적으면 뒤 낱말부터 하나씩 빼고 다시 찾아서 앞 결과 뒤에 붙여요.

⑩세 낱말로 1개 → 뒤 낱말을 빼고 다시 찾아 결과를 뒤에 붙여요. 3개를 넘으면 멈춰요.

05. 품질 재기와 엔진 결정

"잘 찾는다"를 느낌으로 정하지 않으려고, 한국어 질문 25개마다 정답 절을 미리 정해 두고 결과 몇 번째에 나오는지 쟀어요. 조사 붙은 말, 영어 리소스 이름, 명령어, 글자 일부, 오타를 고루 넣었어요. 같은 질문을 스크립트(scripts/search-eval.ts)로 언제든 다시 잴 수 있어요.

⑪고칠 때마다 같은 25개 질문으로 다시 재서, 1위에 정답이 나온 질문이 15개에서 20개로 늘었어요.
단계1위 정답5위 안
처음 (본문만 넣은 레코드)15 / 2519 / 25
① 제목을 <h1>로 — 제목 가중치16 / 2519 / 25
② + 순위 옵션 termSimilarity: 318 / 2518 / 25
③ + 검색어 다듬기 (조사 · 표기 이름 · 낱말 줄이기)20 / 2522 / 25

남은 빈틈은 두 가지예요. 글자 중간 검색("네티스"로 쿠버네티스 찾기)과 오타 보정("kubernets")이에요. 이 둘이 되는 Typesense 같은 검색 서버로 옮기는 방법도 비교했지만, 기술 블로그에서는 드문 경우라 비용 0 · 서버 없음인 Pagefind를 유지하기로 했어요. 1위 정답 비율이 70% 아래로 떨어지면 다시 보기로 기준을 적어 뒀어요.

06. 검색창에서 생긴 문제

검색 자체는 잘 됐는데, 블로그 화면과 만나는 곳에서 문제가 두 가지 나왔어요. 둘 다 원인이 처음 짐작과 달랐어요.

글자를 칠 때마다 페이지가 올라가던 문제

긴 노트를 읽다가 검색창에 글자를 치면 페이지가 위로 밀렸어요. 첫 번째 원인은 결과 목록에서 지금 고른 항목을 보이게 하려고 부른 scrollIntoView()였어요. 이 함수는 결과 패널뿐 아니라 페이지까지 움직여요. 패널의 스크롤 위치를 직접 계산하도록 바꿨더니, 이번엔 글자를 칠 때마다 52px씩 올라갔어요.

두 번째 원인은 브라우저였어요. 블로그는 앵커로 이동할 때 제목이 상단 바에 가리지 않도록 scroll-padding-top: 72px을 둬요. 상단 바가 화면에 붙어 있어서(sticky) 그 안의 입력창도 페이지 내용으로 취급되는데, 커서가 그 72px 영역 안(위에서 20px)에 있으니 브라우저가 "커서가 가려졌다"고 보고 72 − 20 = 52px만큼 페이지를 올린 거예요. 검색창에 포커스가 있는 동안만 이 여백을 0으로 바꿔서 해결했어요.

⑫커서가 위쪽 여백(72px) 안에 있으면 칠 때마다 52px씩 올라가요. 포커스 중 여백을 0으로 두면 그대로 있어요.

앵커로 들어오면 제목이 가끔 어긋나던 문제

검색 결과는 #p3-04 같은 앵커 주소로 이동해요. 그런데 24번 중 7번은 제목이 제자리(위에서 104px)가 아니라 한참 위나 아래에 멈췄어요. 원인은 웹 글꼴 교체였어요. 브라우저는 페이지가 열리자마자 목적지 위치를 정하고 부드럽게 내려가는데, 약 1초 뒤 글꼴이 바뀌면 문서 전체 줄바꿈이 다시 계산되면서 위쪽 글 높이가 줄어요(01 입문은 대상 위쪽이 596px 짧아졌어요). 스크롤은 처음 정한 위치에서 멈추니 제목이 어긋난 거예요.

글꼴 교체는 없앨 수 없어서 블로그 쪽에서 보정해요. 페이지와 글꼴이 다 준비되고 스크롤이 멈춘 뒤, 사용자가 직접 스크롤하지 않았고 제목 위치가 2px 넘게 다르면 한 번만 다시 맞춰요. 그 뒤 36번 반복해 재서 모두 104px이었어요.

⑬스크롤하는 동안 글꼴이 바뀌어 위쪽이 줄면 제목이 기준선을 지나쳐요. 멈춘 뒤 한 번 다시 맞춰 제자리로 돌아와요.

07. 마무리

정리하면 통합검색은 세 층이에요. 빌드 때 절 단위 레코드(엔진과 무관), 그 레코드로 만든 Pagefind 색인 조각, 그리고 검색어를 다듬어 엔진에 묻는 어댑터예요. 층을 나눠 둔 덕분에 품질을 재면서 한 층씩 고칠 수 있었고, 나중에 엔진을 바꾸더라도 레코드와 화면은 그대로 둘 수 있어요.

이 노트도 같은 과정을 거쳐 검색 레코드가 되고, 오른쪽 위 검색창에서 찾을 수 있어요.

SIMPLE · dev / 통합검색 — 2026-11-03