Gatsby 블로그 검색기능 도입하기

박세영·2023년 3월 24일
2

검색 기능 도입 배경

정적 콘텐츠를 제공하는 블로그에서 검색 기능은 필수이다.

본인도 공부하며 기록해둔 포스팅을 뒤적거릴 자주있어 검색 기능을 추가하기로 결정했다.

검색 기능의 3 요소

웹 사이트에 검색을 도입하기 위해서는 아래 세 가지 컴포넌트가 필요하다.

  1. search index

search index는 데이터를 조금 더 검색에 활용하기 쉬운 형태로 저장한 복사본을 의미한다. search index을 활용하면 더 빠른 검색이 가능하다. search index이 없다면 쿼리에 매치되는 사이트 내의 모든 콘텐츠를 순회해야하기 때문에 비효율적이다.

  1. search engine

검색 엔진은 콘텐츠를 인덱싱하고, 검색 쿼리를 받아, 쿼리에 알맞은 인덱스를 찾은 다음 그 결과를 반환한다. 검색엔진은 Algolia와 같이 이미 호스팅된 서비스를 사용하는 방법과 Elastic처럼 스스로 호스팅 해야한는 오픈 소스를 이용할 수 있다. 혹은 client side 사용할 수 있는 Fuse와 같은 라이브러리를 활용할 수도 있다.

  1. search ui

유저가 검색 쿼리를 입력할 수 있는 ui와 검색 결과를 보여줄 ui가 필요하다.

검색 관련 서비스, 라이브러리

Gatsby 공식 블로그에 검색 기능을 추가하기 위한 방법으로 두 가지를 제안한다.

  1. js-search와 같은 client-side search
  2. Algolia, ElasticSearch, Solr과 같은 API기반 검색 엔진

이외에도 전체 목록을 가져와서 O(n) 시간 복잡도로 서칭하는 방식으로 직접 구현할수도 있긴 하다. 하지만 검색 쪽을 경험해본 적이 없어서 이번기회에 라이브러리를 사용해볼까 한다. 추가적으로 라이브러리를 사용하면 아래와 같은 기능을 사용할 수 있다.

  • 인덱싱으로 빠른 검색
  • 검색하려는 콘텐츠(제목 혹은 본문)에 더 가중치를 둘 수 있다
  • 검색어 하이라이팅 기능
  • 검색에 and, or과 같은 논리 연산 적용 가능

API기반의 서비스는 유료거나 제한이 있기 때문에 client-side search를 도입하기로 결정했다.

다만, Gatsby에서 제안한 js-search가 아닌 조금더 생태계과 활성화된 fuse.js를 사용하기로 결정했다.

Fuse.js

fuse.js의 특징은 아래와 같다.

  • 가볍고 강력한 fuzzy-search 라이브러리이다
  • 다른 라이브러리에 의존성을 갖지 않는다.
  • 백앤드 설정 없이 클라이언트 사이드에서 처리할 수 있다
💡 **fuzzy searching** fuzzy 검색은 주어진 쿼리와 정확히 일치하는 것이 아닌 대략적으로 비슷한 결과를 찾는 검색 방식이다.

gatsby에서 fuse.js 활용하기

gatsby와 함께 사용하는 경우 빌드 과정에서 인덱스를 생성해 두고, 런타임에 해당 인덱스를 통해 검색하면 된다.

이때 빌드 과정은 플러그인을 활용하고, 런타임 검색은 훅을 활용할 것이다

gatsby-plugin-fusejs

yarn add gatsby-plugin-fusejs

플러그인을 추가하고 gatsby-config.js에 아래와 같이 입력한다.

module.exports = {
	plugins: [
			resolve: 'gatsby-plugin-fusejs',
			options: {
				// 인덱스를 만들기 위한 전체 콘텐츠 쿼리
				query: `
					allMarkdownRemark {
						edges {
							node {
								frontmatter {
									categories
									title
									summary
								}
							}
						}
					}
				`,
				// 인덱싱할 필드
				key: ['title', 'summary', 'categories'],
				// 쿼리 결과를 flat하게 normalization 하기
				normalizer: ({ data }) =>
					data.allMarkdownRemark.edges.map(n => ({
						title: n.frontmatter.title,
						summary: n.frontmatter.summary,
						categories: n.frontmatter.categories,
					})),
			},
		},
	]
{

이제 컴포넌트에서 생성된 search index를 참조할 수 있다.

import { useStaticQuery, graphql } from 'gatsby'
import * as React from 'react'
import { useGatsbyPluginFusejs } from 'react-use-fusejs'

export function Search() {
  const data = useStaticQuery(graphql`
    {
      fusejs {
        index
        data
      }
    }
  `)

  const [query, setQuery] = React.useState('')
  const result = useGatsbyPluginFusejs(query, data.fusejs)

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <ul>
        {result.map(({ item }) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>
    </div>
  )
}

export default Search

react-use-fusejs 에러 발생

react 버전 때문일까.. 해당 훅 라이브러리를 사용하는 과정에서 에러가 발생했다.

라이브러리를 대충 뜯어보니 react를 한겹 감싸서 사용하는 것 같던데 그냥 리액트 네이티브 훅을 사용하니 문제가 해결되긴 했다.

해결하다 보니 그렇게 복잡한 구조가 아닌데 굳이 이렇게 큰 코드베이스를 가진 패키지를 사용할 필요가 있나 생각이 들어 커스텀 훅을 구현했다.

import { useEffect, useMemo, useState } from 'react';
import Fuse from 'fuse.js';

interface UseFuseSearchArgsType {
	query: string;
	fusejs: {
		index: string;
		data: string[];
	};
	fuseOpts?: Fuse.IFuseOptions<string>;
	searchOpts?: Fuse.FuseSearchOptions;
}

export const useFuseSearch = ({
	query,
	fusejs,
	fuseOpts,
	searchOpts,
}: UseFuseSearchArgsType) => {
	const [fuseImpl, setFuseImpl] = useState<Fuse<string>>();
	useEffect(() => {
		const fuse = new Fuse(
			fusejs.data,
			fuseOpts,
			Fuse.parseIndex(JSON.parse(fusejs.index)),
		);
		setFuseImpl(fuse);
	}, [query, fusejs]);
	const result = useMemo(() => {
		if (!query || !fusejs.data) return [];
		return fuseImpl?.search(query, searchOpts) || [];
	}, [query, fuseImpl]);
	return result;
};

디바운스를 포함한 api도 필요할 것 같아 따로 만들었다.

...
interface UseDebounceFuseSearchArgsType extends UseFuseSearchArgsType {
	ms?: number;
}

export const useDebounceFuseSearch = ({
	query,
	fusejs,
	fuseOpts,
	searchOpts,
	ms = 500,
}: UseDebounceFuseSearchArgsType) => {
	const [fuseImpl, setFuseImpl] = useState<Fuse<string>>();
	const [debounce, setDebounce] = useState('');

	useEffect(() => {
		const sto = setTimeout(() => {
			setDebounce(query);
		}, ms);
		return () => clearTimeout(sto);
	}, [query]);

	useEffect(() => {
		const fuse = new Fuse(
			fusejs.data,
			fuseOpts,
			Fuse.parseIndex(JSON.parse(fusejs.index)),
		);
		setFuseImpl(fuse);
	}, [debounce, fusejs]);

	const result = useMemo(() => {
		if (!debounce || !fusejs.data) return [];
		return fuseImpl?.search(debounce, searchOpts) || [];
	}, [debounce, fuseImpl]);

	return result;
};

이 커스텀 훅들은 따로 package로 만들어 npm에 배포해 두었다. 사용법은 아래와 같다.

import React, { useState } from 'react';
import { graphql, useStaticQuery } from 'gatsby';
import { useDebounceFuseSearch } from 'gatsby-use-fusejs';
import * as S from './styles';

const SearchInput = () => {
	...

	// default delay value is 500ms
	const result = useDebounceFuseSearch({ query, fusejs, delay: 1000 });

	...
};

export default SearchInput;

npm: gatsby-use-fusejs

이 패키지를 사용할만한 이유는 아래와 같다(사용해 주신다면 감사합니다🙇🏻)
1. 돌아간다.. (왜인지 react-use-fusejs패키지는 에러가 발생했다)
2. typescript를 지원한다
3. debouncing 기능을 지원한다
4. 16.7KB로 가볍다

References

0개의 댓글