점점 포스트 수가 많아져서 뿌듯하긴 한데, 가끔 제가 쓴 글을 찾기가 어렵더라고요. 그래서 이번엔 검색 기능을 직접 구현해보기로 했습니다.

어떻게 개발할까

  • husky pre-commit hook으로 커밋할 때 자동으로 캐시를 생성
  • Next.js에 검색용 API 엔드포인트 추가 (Fuzzy 검색 간단하게 적용해보기)
  • 검색바 구현 (디바운싱과 쓰로틀링 비교해보기)

가장 좋은 방법은 아닐순 있어도 우선 목표를 달성하고, 추후 조금씩 최적화하면 될 것 같습니다.

캐시를 생성하자!

제 블로그는 아래의 순서로 포스트를 렌더링합니다.

  1. 마크다운 파일을 읽어옵니다.
  2. 읽어온 내용을 HTML로 파싱합니다. (remark를 활용합니다) 이 때, 파싱의 결과는 string입니다.
  3. 파싱한 내용을 마크다운용 스타일이 적용된 styled-component에 dangerouslySetInnerHTML로 넣습니다.

검색 기능을 구현하려면 모든 포스트에 대한 내용을 가지고 있는 일종의 캐시가 필요합니다.

저는 이 캐시를 매 커밋 직전에 자동으로 생성하고자 합니다. 이를 위해 아래의 파일을 생성했습니다.

// cache/cache.ts
 
import fs from "fs"
import { JSDOM } from "jsdom"
 
import { markdownToHtmlForCache } from "../lib/utils/markdownToHtml"
import { getAllPosts } from "../lib/utils/posts"
import { CachePost } from "./type"
 
const postsData = getAllPosts(["slug", "title", "content"])
 
;(async () => {
  const postsCache: CachePost[] = await Promise.all(
    postsData.map(async ({ slug, title, content }) => {
      const cacheHTML = await markdownToHtmlForCache(content)
      const { document } = new JSDOM(cacheHTML).window
      const elements = document.querySelectorAll("h1, h2, h3, h4, h5, h6, p, ol, ul")
 
      let extractedContent = ""
 
      elements.forEach((ele) => (extractedContent += ele.textContent))
 
      return {
        slug,
        title,
        content: extractedContent,
      }
    }),
  )
 
  fs.writeFile(`./cache/cache.json`, JSON.stringify(postsCache), (error) => {
    if (error) {
      console.error(error)
    }
    console.log("캐시 생성 완료")
  })
})()
// cache/cache.ts
 
import fs from "fs"
import { JSDOM } from "jsdom"
 
import { markdownToHtmlForCache } from "../lib/utils/markdownToHtml"
import { getAllPosts } from "../lib/utils/posts"
import { CachePost } from "./type"
 
const postsData = getAllPosts(["slug", "title", "content"])
 
;(async () => {
  const postsCache: CachePost[] = await Promise.all(
    postsData.map(async ({ slug, title, content }) => {
      const cacheHTML = await markdownToHtmlForCache(content)
      const { document } = new JSDOM(cacheHTML).window
      const elements = document.querySelectorAll("h1, h2, h3, h4, h5, h6, p, ol, ul")
 
      let extractedContent = ""
 
      elements.forEach((ele) => (extractedContent += ele.textContent))
 
      return {
        slug,
        title,
        content: extractedContent,
      }
    }),
  )
 
  fs.writeFile(`./cache/cache.json`, JSON.stringify(postsCache), (error) => {
    if (error) {
      console.error(error)
    }
    console.log("캐시 생성 완료")
  })
})()

우선 파일 내용을 읽어와야 합니다. 저는 이미 마크다운을 읽고 HTML로 파싱하는 함수를 만들어 쓰고 있었으므로, 해당 함수들을 활용해 HTML로 파싱된 내용을 불러옵니다.

그렇게 파싱된 HTML string은 18번째 줄에서 Document Object로 파싱됩니다. 노드 환경에서 DOM API를 사용하기 위해 jsdom 모듈을 활용했습니다.

그 후, 필요한 요소를 querySelectorAll()로 가져오고, 각 요소를 돌며 textContent를 추출해 postsCache에 담습니다.

15번째 줄에 보시면 마크다운 string으로부터 HTML string을 생성하는 markdownToHtmlForCache()가 비동기 함수이므로 postsData.map()Promise.all()로 감싸줍니다.

마지막으로 postsCachecache.json에 저장합니다. 이 다음 개발할 API에서 이 json 파일을 캐시로 사용하려고 합니다.

Top-level await를 썼으면 더 보기 좋았겠지만 Next.js와는 독립적으로 동작할 부분 때문에 전체 프로젝트의 설정을 바꾸고 싶지는 않아 즉시 실행 함수로 작성했습니다.

이제 이 cache.ts를 실행하는 명령어를 pre-commit 훅으로 추가해주면 됩니다.

// package.json
{
  "scripts": {
    "cache": "tsx ./cache/cache.ts"
  }
}
// package.json
{
  "scripts": {
    "cache": "tsx ./cache/cache.ts"
  }
}

cache.ts 실행을 위해 tsxdevDependency로 추가했습니다.

# husky pre-commit hook
# ./husky/pre-commit
 
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
 
npm run cache && git add ./cache/cache.json
# husky pre-commit hook
# ./husky/pre-commit
 
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
 
npm run cache && git add ./cache/cache.json

검색용 엔드포인트를 만들자

우선, 검색용 API를 개발하기 앞서서 한가지 생각해볼 문제가 있습니다.

네이버 자동완성
네이버의 자동완성

많은 서비스들이 검색시 '대충 입력해도 원하는걸 찾아 보여주는' 자동완성 기능을 제공합니다. 저는 이것처럼 사용자의 입력값이 완벽하지 않아도 찾고있을 가능성이 있는 글을 보여주는 기능을 붙이고 싶습니다.

Fuzzy 간단하게 적용해보기

참고한 태곤님 블로그 포스트

Fuzzy 알고리즘은 이를 구현할 때 쓸 수 있는 방법중 하나입니다. 두 문자열의 유사성을 파악해서 유사도가 높은지 여부를 판단하는 식입니다.

관련해서 복잡한 알고리즘 이론들이 많이 나와있지만 저는 최대한 간단하게 접근하면서 제가 직접 바꿀 수 있도록 태곤님 블로그에 나온 방법을 적용, 일부 코드를 조금 수정했습니다. (대소문자 둘 다 대응할 수 있도록, title과 content 두가지를 기준으로 찾을 수 있도록)

태곤님 포스트는 꼭 읽어보세요! 간단하면서도 흥미롭더라고요😁 다만 마지막에 longestDistance가 긴 순으로 정렬하는것이 좋은 반응을 얻었다는 의견을 소개해주셨는데, 왜 그런지 궁금하긴 했습니다. 저는 짧은 순으로 쓰기로 했습니다.

아래는 제가 수정한 코드입니다.

// utils/fuzzy.ts
 
// 태곤님의 createFuzzyMatcher()
const regex = createFuzzyMatcher(query)
 
const findFuzzyPostData = (option: "title" | "content", regex: RegExp) => {
  let results = []
  for (const postData of CacheDB) {
    const match = postData[option].match(regex)
 
    if (!match || match.index === undefined || match[0].length > 50) {
      continue
    }
 
    const index = match.index
 
    results.push({
      ...postData,
      [option]: [
        postData[option].slice(0, index),
        match[0],
        postData[option].slice(index + match[0].length),
      ],
      matchLength: match[0].length,
      matchedOne: option,
    })
  }
 
  return results
}
 
export default function getFuzzyPostData(query: string) {
  const regex = createFuzzyMatcher(query)
  const result = [
    ...findFuzzyPostData("title", regex),
    ...findFuzzyPostData("content", regex),
  ].sort((post1, post2) => post2.matchLength - post1.matchLength)
 
  return result
}
// utils/fuzzy.ts
 
// 태곤님의 createFuzzyMatcher()
const regex = createFuzzyMatcher(query)
 
const findFuzzyPostData = (option: "title" | "content", regex: RegExp) => {
  let results = []
  for (const postData of CacheDB) {
    const match = postData[option].match(regex)
 
    if (!match || match.index === undefined || match[0].length > 50) {
      continue
    }
 
    const index = match.index
 
    results.push({
      ...postData,
      [option]: [
        postData[option].slice(0, index),
        match[0],
        postData[option].slice(index + match[0].length),
      ],
      matchLength: match[0].length,
      matchedOne: option,
    })
  }
 
  return results
}
 
export default function getFuzzyPostData(query: string) {
  const regex = createFuzzyMatcher(query)
  const result = [
    ...findFuzzyPostData("title", regex),
    ...findFuzzyPostData("content", regex),
  ].sort((post1, post2) => post2.matchLength - post1.matchLength)
 
  return result
}

프론트엔드단에서 matchstring에 따로 스타일을 줄 수 있도록 배열로 분리하고, 어느 부분이 match된건지를 알리는 matchedOne 프로퍼티를 넣었습니다. 태곤님의 로직을 그대로 적용하면 longestDistance에 제한이 없어 content같이 긴 문구에서는 아주 길게도 match를 찾는 이슈가 발생해 해당 부분도 예외처리를 추가했습니다.

엔드포인트

실제 엔드포인트 구현 부분은 fuzzy.ts에서 모든 로직을 수행하고 있어 매우 간단했습니다.

export default async function searchAPI(req: NextApiRequest, res: NextApiResponse) {
  const { q } = req.query
 
  if (req.method !== "GET" || typeof q !== "string") {
    return res.status(400).json({ message: "잘못된 요청입니다." })
  }
  console.log(q)
 
  const results = q ? getFuzzyPostData(q) : []
 
  return res.status(200).setHeader("Content-Type", "application/json").json(results)
}
export default async function searchAPI(req: NextApiRequest, res: NextApiResponse) {
  const { q } = req.query
 
  if (req.method !== "GET" || typeof q !== "string") {
    return res.status(400).json({ message: "잘못된 요청입니다." })
  }
  console.log(q)
 
  const results = q ? getFuzzyPostData(q) : []
 
  return res.status(200).setHeader("Content-Type", "application/json").json(results)
}

다음 포스트에선 이렇게 개발한 API를 활용해 실제 블로그에 적용해보겠습니다.