기술 포스트

블로그에 검색을 달았다 — Fuse.js 적용기


Article

블로그에 글이 쌓이면서 검색이 필요해졌다. 처음엔 Algolia 같은 검색 서비스를 붙일까 했는데, 글이 백 개도 안 되는 블로그에 외부 인덱싱 서비스는 과하다. 규모가 작을 땐 클라이언트에서 다 하는 게 최적이라고 판단했고, Fuse.js를 골랐다.

구조

내 블로그는 GitHub 저장소의 마크다운을 API로 가져와 ISR로 렌더링한다. 검색용 데이터는 별도 인덱스 없이, 서버 컴포넌트에서 이미 가져온 포스트 목록(제목, 설명, 태그)을 그대로 클라이언트에 넘긴다. 본문 전체는 넣지 않았다. 페이로드가 커지는 것에 비해 검색 품질 향상이 미미했다.

import Fuse from "fuse.js";

const fuse = new Fuse(posts, {
  keys: [
    { name: "title", weight: 0.6 },
    { name: "tags", weight: 0.3 },
    { name: "description", weight: 0.1 },
  ],
  threshold: 0.35,
  ignoreLocation: true,
  minMatchCharLength: 2,
});

가중치 튜닝

처음엔 세 필드를 동일 가중치로 뒀는데, "리액트"를 검색하면 설명에 리액트가 스치듯 언급된 글이 제목에 리액트가 박힌 글보다 위에 오는 경우가 생겼다. 제목 > 태그 > 설명 순으로 가중치를 기울이니 체감 정확도가 확 올라갔다. 태그를 제목 다음에 둔 이유는, 태그는 내가 직접 분류한 신호라서 본문보다 신뢰도가 높기 때문이다.

ignoreLocation: true도 중요했다. 기본값에서는 매칭 위치가 문자열 앞쪽일수록 점수가 좋게 나오는데, 설명 문장 뒤쪽에 있는 키워드가 억울하게 밀렸다.

한글 검색의 미묘함

Fuse.js는 문자 단위로 편집 거리를 계산한다. 영어에서는 오타 한 글자가 거리 1이지만, 한글은 자모가 음절로 합쳐져 있어서 감각이 다르다. "리액트"와 "리엑트"는 음절 하나 차이라 거리 1로 잡히지만, "넥스트"로 "Next.js" 글을 찾는 건 당연히 안 된다. 그래서 자주 검색될 만한 표기는 태그에 한글/영문을 같이 넣는 식으로 데이터 쪽에서 풀었다. 형태소 분석기를 붙이는 방법도 있지만, 이 규모에서 그건 배보다 배꼽이다.

threshold는 0.3~0.4 사이에서 며칠 굴려보며 정했다. 0.6쯤 되면 "타입"을 검색했는데 "스타일" 글이 나오는 수준으로 느슨해진다.

검색 훅

검색 로직은 훅으로 분리했다. 입력마다 Fuse 인스턴스를 새로 만들지 않도록 useMemo로 감쌌다.

export default function useFuseSearch(posts: Post[], query: string) {
  const fuse = useMemo(() => new Fuse(posts, options), [posts]);

  return useMemo(() => {
    if (query.trim().length < 2) return posts;
    return fuse.search(query).map((r) => r.item);
  }, [fuse, posts, query]);
}

서버 검색이 없으니 인프라 비용도 0이고, 입력하는 즉시 결과가 갱신된다. 글이 수천 개가 되면 그때 다시 고민하면 된다. 지금 규모에 맞는 도구를 쓰는 것도 실력이라고 생각한다.

블로그에 검색을 달았다 — Fuse.js 적용기 — 디코드랩(DCODELAB)