All Articles

Gatsby 블로그에 검색기능 구현하기

만들게 된 배경


Gatsby로 블로그를 잘 작성하고 있는 중이다. 그런데 내가 예전에 썼던 블로그를 다시 찾아서 보려고 하니 여간 불편한게 아니다. 내가 다운받아서 만든 이 Gatsby-starter-lumen은 정말 마음에 드는 블로그 템플릿이지만 안타깝게도 검색기능이 없다. 검색 플러그인이 있나 찾아봤는데 총 3개 정도 있는 것 같다.

Gatsby 공식사이트에 나와있는 Adding-Search에 들어가 보면 어떻게 플러그인을 설치해야 하는지 나와있고 Algolia, elasticlunr, js-search를 사용해서 어떻게 구현가능한지 설명해놓았다. 하지만 Algolia는 기업을 위한 전문 플러그인이고, elasticlunr는 참 좋지만, 한글을 지원하지 않는다. 일본어는 지원하면서때문에 나는 공부도 할 겸해서 직접 구현하기로 했다.

elasticLunr를 보면 받은 단어들을 토큰화하고 우선순위를 매겨서 빠르고 정확한 결과값을 보여준다. 하지만 나는 내가 직접 만들 것이고 검색을 하면 결과값을 보여주는 정도로만 만들어 놓았다. 만약 블로그 포스팅이 점점 더 많아진다면 내가 만든 검색기능은 적합하지 않을 것이다. 하지만 내 블로그 포스트들이 검색에 부담을 가질 정도로 늘어날 것 같지는 않을 것 같다.

모든 post한 데이터 가져오기

내 블로그는 템플릿이어서 Markdown을 content 폴더 아래 포스트로 넣게 되면 알아서 변환하여 돌려준다. graphql을 사용하여 내 모든 데이터를 가져와보자. http://localhost:8000/___graphql에서 데이터를 미리 확인해볼 수 있다.

grpahql 쿼리결과

graphql 결과를 확인해 보면 이렇게 가져온 데이터를 const { tags, title, date, description, slug } = node.frontmatter를 통해서 가져올 수 있음을 확인할 수 있다. 이제 이 결과를 map()을 사용해서 렌더링 할 수 있을 것이다!

Search Component 만들기

이제 어느 정도 이해를 했으면 Search Component를 만들어 보면 된다. 아래와 같이 Component 폴더 아래에 Search 디렉토리와 사용할 Search.js 파일을 만들어 준다.

Search.js를 작성한다. 나는 좀 더 쉽고 예쁘게 만들기 위해 MDBRecat를 사용했다.

// Search.js

import React, { useState } from "react";
import { MDBCol, MDBFormInline, MDBIcon } from "mdbreact";
import styles from "./Search.module.scss";

const Search = () => {
  const emptyQuery = "";

  const [state, setState] = useState({
    filteredData: [],
    query: emptyQuery,
  });

  const handleInputChange = (event) => {
    const query = event.target.value;
    setState({
      query,
      filteredData,
    });
  };

  return (
    <div className={styles["search"]}>
      <MDBCol md="12">
        <MDBFormInline className="md-form">
          <MDBIcon icon="search" />
          <input
            className="form-control form-control-sm ml-3 w-75"
            type="text"
            placeholder="Search"
            aria-label="Search"
            onChange={handleInputChange}
          />
        </MDBFormInline>
      </MDBCol>
    </div>
  );
};

간단하게 설명하면 Basic한 Search 모듈이다. input값을 받을 수 있는 handleInputChange를 작성하여 input값이 입력될 때마다 filterdData를 넣게끔 만들어 준다.

StaticQuery로 Graphql 데이터 가져오기

Gatsby는 원래 Page 쿼리로만 데이터를 가지고 올 수 있었다. 그래서 만약 특정 컴포넌트에서 데이터를 가지고 오려고 한다면, Header나 Layout 등 감싸고 있는 컴포넌트에서 데이터를 받아서 props 형태로 전달해줘야 했다. Gatsby v2 버전부터는 StaticQuery를 이용해 컴포넌트에서도 graphql로 데이터를 가지고 올 수 있다. 이전과 달리 제약없어져 매우 편해졌다.

그리고 위에서 확인했던 것과 같이 query를 전달해서 받아올 수 있도록 한다.

// StaticQuery export
export default (props) => (
  <StaticQuery
    query={graphql`
      query {
        allMarkdownRemark(sort: { order: DESC, fields: frontmatter___date }) {
          edges {
            node {
              excerpt(pruneLength: 200)
              id
              frontmatter {
                title
                description
                date(formatString: "MMMM DD, YYYY")
                tags
              }
              fields {
                slug
              }
            }
          }
        }
      }
    `}
    render={(data) => <Search data={data} {...props} />}
  />
);

handleInputChange로 데이터 필터링 하기

이제 사용자가 값을 입력하게 되면 그 값에 맞추어서 데이터를 filter해서 보여주기를 만들 차례다.

const handleInputChange = (event) => {
  const query = event.target.value;
  const { data } = props;
  const posts = data.allMarkdownRemark.edges || [];

  const filteredData = posts.filter((post) => {
    const { description, title, tags } = post.node.frontmatter;
    return (
      (description &&
        description.toLowerCase().includes(query.toLowerCase())) ||
      (title && title.toLowerCase().includes(query.toLowerCase())) ||
      (tags && tags.join("").toLowerCase().includes(query))
    );
  });

  setState({
    query,
    filteredData,
    above,
  });
};

간단하게 만들어 보았다. toLowerCase()를 통해 영문으로 입력했을 때 대소문자를 함께 검색하도록 해준다.

render기능 붙이기

const renderSearchResults = () => {
  const { query, filteredData } = state;
  const hasSearchResults = filteredData && query !== emptyQuery;
  const posts = hasSearchResults ? filteredData : [];
  return (
    posts &&
    posts.map(({ node }) => {
      const { excerpt } = node;

      const { slug } = node.fields;
      const { title, date, description } = node.frontmatter;
      return (
        <div key={slug} className={styles["search-article"]}>
          <article key={slug}>
            <header>
              <h2 className={styles["search-title"]}>
                <Link to={slug}>{title}</Link>
              </h2>
            </header>
            <section>
              <p
                className={styles["search-description"]}
                dangerouslySetInnerHTML={{
                  __html: description || excerpt,
                }}
              />
              <p className={styles["search-date"]}>
                <em>{date}</em>
              </p>
            </section>
          </article>
        </div>
      );
    })
  );
};

return (
  <div className={styles["search"]}>
    <MDBCol md="12">... 중간내용 생략</MDBCol>
    {state.query && (
      <div className={styles["search-result-container"]}>
        {renderSearchResults()}
      </div>
    )}
  </div>
);

render 기능은 renderSearchResults 함수를 구현해서 넣어준다. slug에는 이제 클릭한 곳으로 넘어가기 위한 url을 넣어준다. 이렇게 하면 slug가 key값이 되므로 map()으로 매핑해주기 위한 고유값까지 해결이 된다.

{
  state.query && (
    <div className={styles["search-result-container"]}>
      {renderSearchResults()}
    </div>
  );
}

내용을 보면 알 수 있지만 state.query가 있을 때에만 render를 해주게 하였다.

원하는 곳에 Search 컴포넌트 import하기

이제 다 만들었으면 이 컴포넌트를 원하는 곳에 import해서 넣어주면 된다. css는 각자 알맞게 넣어주면 된다. 내가 사용한 템플릿의 경우에는 첫 페이지에 보여주는 것이 맞다고 생각하여 index-template에 import했다.

//index-template.js

import Search from "../components/Search/Search";

const IndexTemplate = ({ data, pageContext }) => {
  // 중간 생략...
  return (
    <Layout title={pageTitle} description={siteSubtitle}>
      <Sidebar isIndex />
      <Page>
        <Search />
        <Feed edges={edges} />
        <Pagination
          prevPagePath={prevPagePath}
          nextPagePath={nextPagePath}
          hasPrevPage={hasPrevPage}
          hasNextPage={hasNextPage}
        />
      </Page>
    </Layout>
  );
};

최종 Search 코드

import React, { useState } from "react";
import { Link, graphql, StaticQuery } from "gatsby";
import { MDBCol, MDBFormInline, MDBIcon } from "mdbreact";
import styles from "./Search.module.scss";

const Search = (props) => {
  const emptyQuery = "";

  const [state, setState] = useState({
    filteredData: [],
    query: emptyQuery,
  });

  const handleInputChange = (event) => {
    const query = event.target.value;
    const { data } = props;
    const posts = data.allMarkdownRemark.edges || [];

    const filteredData = posts.filter((post) => {
      const { description, title, tags } = post.node.frontmatter;
      return (
        (description &&
          description.toLowerCase().includes(query.toLowerCase())) ||
        (title && title.toLowerCase().includes(query.toLowerCase())) ||
        (tags && tags.join("").toLowerCase().includes(query))
      );
    });

    setState({
      query,
      filteredData,
    });
  };

  const renderSearchResults = () => {
    const { query, filteredData } = state;
    const hasSearchResults = filteredData && query !== emptyQuery;
    const posts = hasSearchResults ? filteredData : [];
    return (
      posts &&
      posts.map(({ node }) => {
        const { excerpt } = node;

        const { slug } = node.fields;
        const { title, date, description } = node.frontmatter;
        return (
          <div key={slug} className={styles["search-article"]}>
            <article key={slug}>
              <header>
                <h2 className={styles["search-title"]}>
                  <Link to={slug}>{title}</Link>
                </h2>
              </header>
              <section>
                <p
                  className={styles["search-description"]}
                  dangerouslySetInnerHTML={{
                    __html: description || excerpt,
                  }}
                />
                <p className={styles["search-date"]}>
                  <em>{date}</em>
                </p>
              </section>
            </article>
          </div>
        );
      })
    );
  };

  return (
    <div className={styles["search"]}>
      <MDBCol md="12">
        <MDBFormInline className="md-form">
          <MDBIcon icon="search" />
          <input
            className="form-control form-control-sm ml-3 w-75"
            type="text"
            placeholder="Search"
            aria-label="Search"
            onChange={handleInputChange}
          />
        </MDBFormInline>
      </MDBCol>
      {state.query && (
        <div className={styles["search-result-container"]}>
          {renderSearchResults()}
        </div>
      )}
    </div>
  );
};

export default (props) => (
  <StaticQuery
    query={graphql`
      query {
        allMarkdownRemark(sort: { order: DESC, fields: frontmatter___date }) {
          edges {
            node {
              excerpt(pruneLength: 200)
              id
              frontmatter {
                title
                description
                date(formatString: "MMMM DD, YYYY")
                tags
              }
              fields {
                slug
              }
            }
          }
        }
      }
    `}
    render={(data) => <Search data={data} {...props} />}
  />
);

후기

기본적인 검색 기능이다. 위에서 말했지만 시간 복잡도가 O(n)이 되므로 모든 쿼리에서 일일이 넘겨받은 검색어를 찾아야 한다는 단점이 있다. 하지만 아직은 이정도로 해두자. 블로그가 점점 더 발전되어 가는 것 같아 기분이 좋다.