Fuse js

Paul Mo·2023년 2월 5일
0
post-thumbnail

검색 기능을 구현하는 게 쉽지 않다고 막연하게 생각하고 있었고 지금까지 검색 기능을 작업할 기회가 없었다. 그런데 이번에 개발을 하면서 휴대폰 번호 인증을 하는 부분을 내가 맡아서 하게 됐는데 작업하는 페이지들 중에 국가명을 검색해서 국가번호를 선택하는 페이지가 있어서 검색 기능을 구현하게 되었다. 검색을 어떻게 해야 하나 생각하고 있었는데 현재 개발하는 앱에 이미 Fuse.js라는 라이브러리로 검색 기능이 구축되어 있어서 쉽게 작업을 할 수 있었다. 그래서 Fuse.js 검색 라이브러리에 대해 공부해본 내용을 공유해보려고 한다.

Fuse.js란?

Fuse.js is a powerful, lightweight fuzzy-search library, with zero dependencies.

공식문서에서 설명하기로는 종속성이 없고 강력하고 가벼운 퍼지검색 라이브러리라고 소개한다. 여기서 말하는 퍼지검색이란 bitap 알고리즘을 사용해 주어진 패턴과 거의 동일한(정확한 것이 아니라) 문자열을 찾는 기술이라고 한다. 정의만 보면 중요한 정보만 고려해서 최선의 값을 찾아내는 React의 휴리스틱 알고리즘과 비슷한 맥락의 알고리즘이 아닐까 하는 생각이 든다.

사용법

공식문서를 읽어봐도 특별히 이해하기 어렵거나 사용하기 어려운 부분은 없었다. 설치하는 방법이나 사용법도 간단하기 때문에 공식문서를 그대로 읽어보면서 해보기에 충분하다.

예제코드는 다음과 같다.

// 1. List of items to search in
const books = [
  {
    title: "Old Man's War",
    author: {
      firstName: 'John',
      lastName: 'Scalzi'
    }
  },
  {
    title: 'The Lock Artist',
    author: {
      firstName: 'Steve',
      lastName: 'Hamilton'
    }
  }
]

// 2. Set up the Fuse instance
const fuse = new Fuse(books, {
  keys: ['title', 'author.firstName']
})

// 3. Now search!
fuse.search('jon')

예제와 같이 'jon'이라는 문자열을 검색하면 퍼지검색을 통해 정확히 같은 문자열이 아니라도 가장 비슷한 값을 가지고 있는 book을 return 해 준다. jon은 첫 번째 book의 auth.firstName이 John이기 때문에 다음과 같이 return 한다.

Output:
[
  {
    item: {
      title: "Old Man's War",
      author: {
        firstName: 'John',
        lastName: 'Scalzi'
      }
    },
    refIndex: 0
  }
]

코어코드

search(query, { limit = -1 } = {}) {
    const {
      includeMatches,
      includeScore,
      shouldSort,
      sortFn,
      ignoreFieldNorm
    } = this.options

    let results = isString(query)
      ? isString(this._docs[0])
        ? this._searchStringList(query)
        : this._searchObjectList(query)
      : this._searchLogical(query)

    computeScore(results, { ignoreFieldNorm })

    if (shouldSort) {
      results.sort(sortFn)
    }

    if (isNumber(limit) && limit > -1) {
      results = results.slice(0, limit)
    }

    return format(results, this._docs, {
      includeMatches,
      includeScore
    })
  }

코어 폴더에 있는 index.js 파일의 search 함수이다. 찾고자 하는 검색 키워드인 query의 String여부와 검색 리스트의 String 여부를 비교해서 _searchStringList, _searchObjectList, _searchLogical 함수를 호출해 검색 결과값을 도출한다.

세 개의 함수중 함수 하나를 예를 들어 자세히 살펴보면

_searchStringList(query) {
    const searcher = createSearcher(query, this.options)
    const { records } = this._myIndex
    const results = []

    // Iterate over every string in the index
    records.forEach(({ v: text, i: idx, n: norm }) => {
      if (!isDefined(text)) {
        return
      }

      const { isMatch, score, indices } = searcher.searchIn(text)

      if (isMatch) {
        results.push({
          item: text,
          idx,
          matches: [{ score, value: text, norm, indices }]
        })
      }
    })

    return results
  }

query를 인수로 넘겨 searcher를 만들어서 검색 리스트에 있는 모든 string과 searchIn 함수로 해당 string과 query를 비교하여 검색 결과를 도출하는 방식이다. searchIn 함수도 살짝 뜯어보면 함수 안에 search 함수가 결국에는 bitTap 알고리즘을 사용해 각 string 마다 패턴과 유사한 정도를 score로 매기고 그중에 가장 유사한 string을 검색 결과로 return 하는 방식인 것 같았다. 결국에는 bitab algorism을 정확히 이해해야 검색 기능이 어떻게 구현되는 것을 이해할 수 있는데 현재 내 실력으로는 읽자마자 이해하기는 힘들었다. bitab 알고리즘의 배경지식을 좀 더 파보고 다시 찬찬히 코드를 전체적으로 훑어보는 시간을 가져야겠다.

코어코드는 생각보다 많이 복잡했던 것 같다. 하지만 사용법과 활용법이 굉장히 쉽게 구현되어 있어 사용하는 데는 어려움이 없었던 게 신기했다. 코어코드 모두를 이해하지는 못했지만 그래도 검색기능이 어떻게 돌아가는지 어느 정도 이해할 수 있었던 유익한 시간이었다.

참조: https://fusejs.io/

profile
프론트 엔드 개발자

0개의 댓글