본문 바로가기

사이드 프로젝트 아카이빙/위치 기반 날씨 앱

[Fuse.js] 라이브러리 도입 후 겪은 검색 UX 이슈와 해결 과정

반응형

들어가며

기존에는 지역명과 위치 정보가 강하게 결합된 JSON 데이터에 의존하고 있었다.

이후 Kakao Local API를 활용하면서, 지역명으로 위·경도를 조회할 수 있게 되었고
검색용 데이터와 좌표 데이터를 분리해서 다룰 수 있게 됐다.

 

Kakao Local API를 사용해서 지역명으로 위/경도를 구해올 수 있었으니 더 이상 json에서

지역 정보를 가지고 있을 필요가 없었기 때문이다.

 

 

[카카오 API] 위치 기반 날씨 앱 구현기 — 좌표와 지역명 매핑 문제를 카카오 API로 해결하기

들어가며수행 과제로 날씨 애플리케이션을 개발하게 되었고, 요구 기능 중 다음 두 가지를 해결해야 했다.1. 사용자의 현재 위치 정보(위/경도, 지역명)와 날씨 정보를 출력2. 지역명을 검색하여

meal-coding.tistory.com

 

이제는 UX 개선 차원에서 검색 기능을 개선할 차례이다.

 

검색 기능 개선 전

 

화면을 보면 기능적으론 문제가 없었고, 항목을 선택하면 날씨 정보도 가져올 수 있다.

그렇지만 아쉬운 점이 크게 2가지 정도 있었다.

 

1️⃣ 하이라이트가 존재하지 않아서 입력된 검색어와 후보 간의 정확도를 한눈에 보기 어렵다.

2️⃣ 입력된 데이터와 깊은 관계가 없는 데이터도 출력되고 있어 기능의 신뢰도가 떨어진다.

 

위의 2가지 문제를 해결하기 위해서 부분 검색이 가능하고 하이라이트를 줄 수 있도록 도움을 주는 방법을

chatGPT와 논의하다가 Fuse.js 라이브러리를 알게 됐다.

 

이번 포스팅에서는 Fuse.js를 적용해 지역 검색 UX를 개선한 과정과
실제 적용 중 마주친 한계 및 보완 방식을 함께 정리해보려고 한다.


내용

📖 #1. Fuse.js 라이브러리란

Fuse.js는 검색어와 문자열의 일치하는 정도를 수치로 나타내서 연관성이 높은 결과값을 반환해 주는 라이브러리이다.

(무려 공식문서도 있다!)

 

Fuse.js — Lightweight Fuzzy-Search Library

Powerful, lightweight fuzzy-search library for JavaScript with zero dependencies.

www.fusejs.io

 

"무(無) 의존성"을 지향해 상당히 가벼운 라이브러리임에도 불구하고 성능 자체는 꽤나 강력하다.

공식 문서에서 소개하는 예제 코드를 살펴보자.

 

제목과 저자의 정보가 담긴 객체 배열로 books가 선언 됐다.

books 배열
const books = [
  { title: "Old Man's War", author: 'John Scalzi' },
  { title: 'The Lock Artist', author: 'Steve Hamilton' },
  { title: 'JavaScript Patterns', author: 'Stoyan Stefanov' }
]

 

books를 Fuse 라이브러리에 넣고 option으로 검색 속성(key)와 일치율을 확인할 수 있게 includeScore를 true로 정의한다.

이렇게 하면 검색에 이용할 수 있는 fuse 인스턴스가 생성된다.

fuse 인스턴스 생성
import Fuse from 'fuse.js'

const fuse = new Fuse(books, {
  keys: ['title', 'author'],
  includeScore: true
})

 

fuse 인스턴스에서 search() 메서드를 호출해 검색어를 파라미터로 던지면 다음과 같이 결과를 얻을 수 있다.

결과
// ✅ item: 조회된 객체
// ✅ refIndex: 객체 배열에서 인덱스 위치
// ✅ score: 일치율 (낮을 수록 정확도가 높다.)

fuse.search('jon')
// [{ item: { title: "Old Man's War", author: "John Scalzi" }, refIndex: 0, score: 0.25 }]

fuse.search('patterns')
// [{ item: { title: "JavaScript Patterns", ... }, refIndex: 2, score: 0.0 }]

 

전체 코드로 보면 아래와 같다.

전체 코드
import Fuse from 'fuse.js'

const books = [
  { title: "Old Man's War", author: 'John Scalzi' },
  { title: 'The Lock Artist', author: 'Steve Hamilton' },
  { title: 'JavaScript Patterns', author: 'Stoyan Stefanov' }
]

const fuse = new Fuse(books, {
  keys: ['title', 'author'],
  includeScore: true
})

fuse.search('jon')
// [{ item: { title: "Old Man's War", author: "John Scalzi" }, refIndex: 0, score: 0.25 }]

fuse.search('patterns')
// [{ item: { title: "JavaScript Patterns", ... }, refIndex: 2, score: 0.0 }]

 

추가로 제공되는 기능을 살펴보면 라이브러리 무게에 비해 역시 좋다.

 

1️⃣ Fuzzy search: Bitap 알고리즘 기반의 오타 허용 매칭 기능
2️⃣ Token search: 여러 단어(Token)으로 구성된 쿼리를 용어로 분할하고, 각 용어를 유사 일치시켜 IDF를 사용해 순위를 매김
3️⃣ Extended search: 정확한 일치, 접두사 일치, 접미사 일치, 역일치 및 포함 일치 연산 검색
4️⃣ Logical search: $and와 $or처럼 구조화된 쿼리를 위한 표현식

 

나는 부분 검색 허용과 더불어 일부 오타도 허용해 주기 위해서 Fuzzy search를 활용하기로 했다.


📝 #2. 개선 계획

제일 먼저 개선 돼야 하는 부분은 아무래도 "들어가며"에서 언급한 2가지이다.

"하이라이트 부재로 한 눈에 보이지 않고, 연관성이 떨어지는 검색 결과도 확인되므로 신뢰도가 떨어진다."

 

추가로 일부 오타도 허용할 계획이고, Kakao Local API로 선택된 지역에 대한 위/경도를 얻어오고 있으니

API 요청 비용도 절감하기 위해 캐시를 적용 해보려 한다.

 

1️⃣ 일부 오타 허용

2️⃣ 연관성 높은 검색 결과 제공

3️⃣ 부분 검색 지원을 통한 하이라이트

4️⃣ 캐시와 TTL을 통해 Kakao Local API 중복 호출을 줄이고 응답 비용을 절감

 

계획이 모두 마무리 되면 다음의 흐름으로 검색 기능이 개선될 예정이다.

pseudo code
검색어 입력
-> Fuse 검색 결과 표시
-> 사용자가 "서울특별시 종로구 청운동" 선택
-> region cache 조회
   -> miss, 새 값 계산 후 저장
-> Kakao Geocoding API 호출
-> lat/lon 획득 후 region cache 저장
-> grid cache 조회
   -> miss, 새 값 계산 후 저장
-> convertToGridcoord 실행
-> nx, ny 저장
-> Weather API 조회

pesudo code sequence diagram

 

검색어를 입력할 때는 다양한 경우의 수가 존재한다.

 

매우 정확하게 입력되는 경우가 있는 반면에 공백이 제거된 상태로 입력되는 경우가 있을 수 있다.

또 행정구역 단위에서 중간 혹은 마지막 단위만 입력되는 경우도 있을 수 있다.

이를 고려해서 검색을 위한 객체 배열은 아래 타입을 따르도록 정의한다.

검색 객체 타입
type DistrictSearchItem = {
  fullName: string;   // "서울특별시-종로구-청운동"
  separates: string[]; // ["서울특별시", "종로구", "청운동"]
  parsed: string;     // "서울특별시종로구청운동"
};

 

[ 📍 ] fullName:

· 원본 지역명 문자열

· 검색과 하이라이트의 기준으로 가장 적합

 

[ 📍 ] separates: 

· 행정구역 단위 배열

· 특정 단계 이름만 입력했을 때도 검색을 허용 및 보완

 

[ 📍 ] parsed:

· 공백 (또는 구분자)를 제거한 검색 보조 필드

· 사용자가 띄어쓰기 없이 입력해도 매치 가능


🏗️ #3. Fuse.js 라이브러리 사용

여기서는 일부 오타 허용 / 연관성 높은 검색 결과 제공 / 부분 검색 지원을 통한 하이라이트 3가지를 동시에 진행할 수 있다.

왜냐하면 Fuse.js 라이브러리에 option만 잘 넣어주면 쉽게 결과를 얻을 수 있기 때문이다.

fuse 인스턴스 생성
const fuse = new Fuse(items, {
    includeScore: true,
    includeMatches: true,
    threshold: 0.4,
    ignoreLocation: true,
    minMatchCharLength: 1,
    keys: [
      { name: "fullName", weight: 0.5 },
      { name: "parsed", weight: 0.3 },
      { name: "separates", weight: 0.2 },
    ] satisfies ReadonlyArray<{
      name: keyof DistrictSearchItem;
      weight: number;
    }>,
  });

 

[ 📍 ] includeScore: 

검색 결과에 유사도 점수를 포함

점수가 낮을수록 더 적합한 결과를 의미

현재는 바로 노출하지 않더라도, 후속 정렬 보정이나 디버깅 시에 유용

 

[ 📍 ] includeMatches:

어떤 필드의 어느 구간이 검색어와 일치했는지 'matches' 데이터로 반환

이 옵션이 있어야 '후보 목록(CandidateList)'에서 문자열 하이라이트가 가능

 

[ 📍 ] threshold:

fuzzy match 허용 범위 지정하는 것으로 일부 오타 허용

값이 클수록 더 느슨하게 검색하고, 작을수록 더 엄격하게 검색

현재 검색 대상이 지역명처럼 비교적 정형화된 문자열이기 때문에, 너무 높게 잡으면 부정확한 결과가 늘어날 수 있음

 

[ 📍 ] ignoreLocation:

문자열 내 특정 위치보다 전체 유사도를 우선해서 검색해 연관성 높은 검색 결과 제공에 활용

지역명 검색은 접두사 일치도 중요하지만, 중간/후반 단어 매치도 충분히 의미가 있으므로 'true'가 실용적

 

[ 📍 ] minMatchCharLength:

검색을 시작할 최소 길이를 의미

너무 짧은 부분 일치는 무시

 

[ 📍 ] keys:

어떤 필드를 어떤 가중치로 검색할지 정의

데이터 검색 대상인 객체 배열 혹은 배열의 상태에 따라서 달라짐

 

생성된 fuse 인스턴스로 검색을 실행하면 바로 결과를 확인할 수 있다.

테스트를 검색으로 결과값을 먼저 확인해 본다.

 

🔍 검색어: 서울특별시 동작구
{
    "item": {
        "fullName": "서울특별시-동작구",
        "separates": ["서울특별시", "동작구"],
        "parsed": "서울특별시동작구"
    },
    "matches": [
        {
            "indices": [
                [0, 4],
                [6, 8]
            ],
            "value": "서울특별시-동작구",
            "key": "fullName"
        },
        {
            "indices": [
                [0, 7]
            ],
            "value": "서울특별시동작구",
            "key": "parsed"
        },
        {
            "indices": [
                [0, 4]
            ],
            "value": "서울특별시",
            "key": "separates",
            "refIndex": 0
        }
    ],
    "score": 0.000007934069688998281
}

 

부분 검색으로 2만 개가 넘는 지역명 json 데이터에서 매우 빠른 속도로 결과를 받아볼 수 있었다.

item, refIndex, score는 앞서 설명으로 쉽게 알아볼 수 있다.

여기서 새로 확인할 값은 matches이다.

 

[ ✅ ] matches:

결과에 대한 근거로 item 중 일치한 키(key), 값(value), 일치범위(indices)를 의미

 

검색된 결과는 공백이 전부 제거된 parsed로부터 얻어졌기 때문에

indices를 그대로 사용하지는 못하지만, 검색 기준을 fullName에 두고서 search() 메서드를 실행하면

하이라이트에 구간을 바로 확인할 수 있는 indices를 확인할 수 있다.

 

최종적으로 검색된 결과에서 indices를 이용해 문자열을 분할한 뒤, 일치 구간만 별도 스타일로 감싸서 렌더링 한다.


⚠️ #4 Fuse.js 사용 간 이슈 발생

검색 기능을 개선하는 데에 Fuse.js 라이브러리 단독 기반으로 진행하면서 2가지 이슈가 발생했다.

이 부분은 Codex를 활용해서 빠르게 해결해 나갔다.

 

[ 1️⃣ ] 후보 결과가 기대보다 많이 생성된다.

기대보다 더 많은 검색 결과

입력 기대 실제
서울특별시 동작구 노량 서울특별시 동작구 노량진동 위주로 좁혀짐 서울특별시 동작구 ... 계열의 다른 후보 결과도 함께 노출

 

원인을 Codex에게 물어보니 3가지로 좁힐 수 있었다.

 

첫 번째, fuzzy search를 수행하므로 입력 문자열과 유사한 후보를 폭넓게 남긴다.

두 번째, threshold가 너무 느슨하거나 ignoreLocation이 true로 설정되어 있으면 문자열 내 어느 위치에서든 매치가 쉽게 발생한다.

세 번째, 지역명처럼 구조가 정형화된 문자열에서도 긴 입력에 대해 후보가 충분히 좁혀지지 않을 수 있다.

 

Fuse.js 라이브러리 단독 사용은 짧은 입력에는 유리하지만 긴 입력에서 정확히 좁혀지는 UX는 부족할 수 있다 판단하고 있었다.

 

[ 2️⃣ ] 하이라이트가 과도하게 표시된다.

 

과도한 하이라이트

 

입력 기대 실제
서울특별시 동작구 입력한 구간만 강조 문자열 전체 또는 필요 이상 넓은 범위가 강조

 

모든 동작이 그런 것은 아니지만 일부 결과에서 하이라이트가 과도하게 표시되는 걸 확인했다.

 

matches.indices는 정확한 구간(exact substring)이라기보다 부분 검색에서

관련 있다고 판단한 범위를 제공한다.

당연하게도 판단 근거는 내가 작성한 옵션 값들이라서 설정을 잘못하면 위와 같은 문제가 발생한다.

 

결과값을 직접 보면 이해가 빠를 것 같다.

🔍 검색어: 서울특별시 동작구
{
    "item": {
        "fullName": "서울특별시-동작구-동작동",
        "separates": ["서울특별시", "동작구", "동작동"],
        "parsed": "서울특별시동작구동작동"
    },
    "matches": [
        {
            "indices": [[0, 4], [6, 8], [10, 12]],
            "value": "서울특별시-동작구-동작동",
            "key": "fullName"
        },
        {
            "indices": [[0, 10]],
            "value": "서울특별시동작구동작동",
            "key": "parsed"
        },
        {
            "indices": [[0, 4]],
            "value": "서울특별시",
            "key": "separates",
            "refIndex": 0
        }
    ],
    "score": 0.05678907467248129
}

 

DistrictSerachItem 타입으로 정의했던 3가지 속성 별로 mathces로 결과값이 들어온다.

이때 indices를 보면 value에서 문자열을 모두 포함하는 걸 확인할 수 있다.

 

큰 비중을 차지하는 원인은 3가지 옵션들이 문제로 확인 되어 재정의가 필요한 상황이다.

threshold: 0.4,			// ⚠️ 허용 범위가 넓어 부분 일치만으로도 후보를 생성
ignoreLocation: true,		// ⚠️ 문자열의 시작 위치를 강하게 보지 않음
minMatchCharLength: 1,		// ⚠️ 한 글자 단위 매치도 허용

 

[ 3️⃣ ] 결론

라이브러리만으로는 구현하는 데에 어려움이 있다고 판단하여 Codex와 의논했고

후보 선정은 Fuse.js 라이브러리가 담당하지만 하이라이트는 사용자 입력 기준 표시 로직을 따로 둬서 진행하는 방향을 얻을 수 있었다.


🧱 #5 Fuse.js 라이브러리 기반 검색 유틸 메서드 + 하이라이트 UI

이슈 처리를 위해 2가지 목표가 있다.

 

1️⃣ 후보 선정을 위한 검색 유틸 메서드

  • Fuse 기반 부분 검색은 유지한다.
  • 다만 긴 입력에서는 exact > prefix > contains > fuzzy fallback 우선순위로 후처리 한다.
  • 이를 통해 입력이 구체적일수록 후보군이 더 빠르게 좁혀지도록 한다.

2️⃣ 하이라이트 UI

  • Fuse matches의 indices를 직접 화면에 사용하지 않는다.
  • 화면 하이라이트는 화면 출력용 데이터인 displayName 정의하고 해당 데이터를 기준으로 exact match로 처리한다.
  • 이를 통해 사용자가 입력한 문자열이 어디에 대응되는지 더 직관적으로 보여준다.

 

검색 유틸 메서드를 위해 fuse 인스턴스의 옵션을 재정의 한다.

fuse 인스턴스 재정의
const fuse = new Fuse(items, {
    includeScore: true,
    includeMatches: true,
    threshold: 0.28,				// 0.4 > 0.28
    ignoreLocation: true,
    minMatchCharLength: 2,			// 1 > 2
    keys: [
      { name: "displayName", weight: 0.5 },	// fullName > displayName
      { name: "parsed", weight: 0.3 },
      { name: "separates", weight: 0.2 },
    ] satisfies ReadonlyArray<{
      name: keyof DistrictSearchItem;
      weight: number;
    }>,
  });

 

그리고 fuse 인스턴스를 기반으로 검색 기능을 수행할 메서드를 정의한다.

 

Fuse 기반 지역 검색 메서드
/**
 * Fuse 기반 지역 검색
 * @param input
 * @param engine
 * @param limit
 * @returns
 */
export const searchDistricts = (
  input: string,
  engine: DistrictSearchEngine,
  limit = 20,
): DistrictSearchResult[] => {
  // 검색어 정규화
  const parsedInput = parseLocationText(input);

  if (!parsedInput) {
    return [];
  }

  // fuse 검색 결과 (20건으로 제한)
  const fuseResults = engine.fuse.search(parsedInput, {
    limit: Math.max(limit, SEARCH_CANDIDATE_POOL_LIMIT),
  });

  // 검색 데이터 후처리를 위한 배열 생성
  const mappedResults = fuseResults.map((_result) => ({
    item: _result.item,
    matches: _result.matches,
    score: _result.score,
  }));

  // 검색 길이에 따라서 후보군 좁히기 (exact/prefix/contains)
  const narrowedResults = applyStrictSearchFilter(mappedResults, parsedInput);
  return sortSearchResults(narrowedResults, parsedInput).slice(0, limit);
};

 

마지막으로 각종 유틸 메서드를 추가해 주면 검색 기능은 작성이 끝난다.

검색 기능 사용자 라이브러리
/**
 * 검색 비교용 정규화
 *    - 공백/구분자 제거, 소문자 변환
 * @param value
 * @returns
 */
export const parseLocationText = (value: string): string => {
  return value.trim().replace(SPACE_REGEX, "").replace(DASH_REGEX, "").toLowerCase();
};

type SearchPriority = 0 | 1 | 2 | 3;

/**
 * 정규화된 검색어와 후보 문자열 간의 우선순위를 계산한다.
 * - exact > prefix > contains > fuzzy fallback
 * @param candidate 검색 후보
 * @param parsedInput 정규화된 검색어
 * @returns 우선순위 값
 */
const getSearchPriority = (candidate: DistrictSearchItem, parsedInput: string): SearchPriority => {
  if (candidate.parsed === parsedInput) {
    return 0;
  }

  if (candidate.parsed.startsWith(parsedInput)) {
    return 1;
  }

  if (candidate.parsed.includes(parsedInput)) {
    return 2;
  }

  return 3;
};

/**
 * 긴 검색어 입력에서는 exact/prefix/contains 기준으로 후보를 한 번 더 좁힌다.
 * @param results Fuse 검색 결과
 * @param parsedInput 정규화된 검색어
 * @returns 후처리된 검색 결과
 */
const applyStrictSearchFilter = (
  results: DistrictSearchResult[],
  parsedInput: string,
): DistrictSearchResult[] => {
  if (parsedInput.length < SEARCH_STRICT_MATCH_MIN_LENGTH) {
    return results;
  }

  const narrowedResults = results.filter(
    (_result) => getSearchPriority(_result.item, parsedInput) < 3,
  );

  return narrowedResults.length > 0 ? narrowedResults : results;
};

/**
 * exact/prefix/contains 우선순위와 Fuse score 기준으로 정렬한다.
 * @param results 검색 결과
 * @param parsedInput 정규화된 검색어
 * @returns 정렬된 검색 결과
 */
const sortSearchResults = (
  results: DistrictSearchResult[],
  parsedInput: string,
): DistrictSearchResult[] => {
  return [...results].sort((a, b) => {
    const priorityDiff =
      getSearchPriority(a.item, parsedInput) - getSearchPriority(b.item, parsedInput);

    if (priorityDiff !== 0) {
      return priorityDiff;
    }

    return (a.score ?? Number.POSITIVE_INFINITY) - (b.score ?? Number.POSITIVE_INFINITY);
  });
};

/**
 * Fuse 검색용 지역 목록 생성
 *    - 좌표 데이터 없이 지역명 문자열만으로 인덱스를 구성
 * @returns
 */
export const buildDistrictSearchIndex = (): DistrictSearchItem[] => {
  return typedDistricts.map((_district) => {
    const separates = _district.split("-").filter(Boolean);

    return {
      fullName: _district,
      displayName: separates.join(" "),
      separates,
      parsed: parseLocationText(_district),
    };
  });
};

/**
 * Fuse 기반 지역 검색
 * @param input
 * @param engine
 * @param limit
 * @returns
 */
export const searchDistricts = (
  input: string,
  engine: DistrictSearchEngine,
  limit = 20,
): DistrictSearchResult[] => {
  const parsedInput = parseLocationText(input);

  if (!parsedInput) {
    return [];
  }

  const fuseResults = engine.fuse.search(parsedInput, {
    limit: Math.max(limit, SEARCH_CANDIDATE_POOL_LIMIT),
  });

  const mappedResults = fuseResults.map((_result) => ({
    item: _result.item,
    matches: _result.matches,
    score: _result.score,
  }));

  const narrowedResults = applyStrictSearchFilter(mappedResults, parsedInput);
  return sortSearchResults(narrowedResults, parsedInput).slice(0, limit);
};

/**
 * UI 표시용
 * @param item
 * @returns
 */
export const toDisplayDistrictName = (item: DistrictSearchItem): string => {
  return item.displayName;
};

 

이제는 하이라이트 UI 차례이다.

 

searchDistricts 메서드로 생성한 후보 리스트를 순회하면서

사용자 입력값과 대조하여 일치하는 구간과 그렇지 않은 구간에 표시를 남긴다.

하이라이트 구간 표시 pseudo code
export const buildDisplayHighlightParts = (
  displayName, // 후보 항목의 화면 출력용 문자열
  query		// 사용자 입력값
) => {
  // 입력값 정규화 (앞뒤 공백 제거 + 연속된 띄어쓰기를 단일 띄어쓰기로 변환)
  
  // 입력값이 비었을 경우 처리 진행
  
  // displayName 시작 위치 저장 (cursor)
  // displayName에서 일치하는 query 부분이 있다면 마지막 위치 저장 (matchIndex)
  
  // 마지막 위치가 확인된다면 displayName 순회
  	// displayName과 query가 일치하지 않는 앞 문자열이 존재하면 처리
    // displayName과 query가 일치하는 구간 처리
    // matchIndex 이동
    
  // displayName과 query가 일치하지 않는 뒷 문자열이 존재하면 처리
  
  // 만약 displayName과 query가 일치하는 구간이 전무하면 fallback 필요
  // 정상 처리 완료했다면 값 반환
}

 

displayName 안에서 query 값이 정확하게 일치하는 구간이 계속 나타난다면 표시가 남으므로

하이라이트 처리될 예정이다.

하이라이트 구간 표시 definition
/**
 * 화면 표시 문자열 기준으로 검색어 일치 구간을 분리한다.
 * - 후보 선정은 Fuse가 담당
 * - 하이라이트는 사용자가 입력한 문자열을 기준으로 직관적으로 표현
 * @param displayName 화면에 렌더링할 문자열
 * @param query 사용자가 입력한 검색어
 * @returns 하이라이트 여부가 포함된 문자열 조각 목록
 */
export const buildDisplayHighlightParts = (
  displayName: string,
  query: string,
): SearchHighlightPart[] => {
  // 입력값 정규화 (앞뒤 공백 제거 + 연속된 띄어쓰기를 단일 띄어쓰기로 변환)
  const normalizedQuery = query.trim().replace(SPACE_REGEX, " ");
  
  // 입력값이 비었을 경우 처리 진행
  if (!normalizedQuery) {
    return [{ text: displayName, matched: false }];
  }

  const lowerDisplayName = displayName.toLowerCase();
  const lowerQuery = normalizedQuery.toLowerCase();
  const parts: SearchHighlightPart[] = [];

  
  // displayName 시작 위치 저장 (cursor)
  // displayName에서 일치하는 query 부분이 있다면 마지막 위치 저장 (matchIndex)
  let cursor = 0;
  let matchIndex = lowerDisplayName.indexOf(lowerQuery, cursor);

  
  // 마지막 위치가 확인된다면 displayName 순회
  while (matchIndex !== -1) {
  	// displayName과 query가 일치하지 않는 앞 문자열이 존재하면 처리
    if (cursor < matchIndex) {
      parts.push({
        text: displayName.slice(cursor, matchIndex),
        matched: false,
      });
    }

    // displayName과 query가 일치하는 구간 처리
    const matchEnd = matchIndex + normalizedQuery.length;
    parts.push({
      text: displayName.slice(matchIndex, matchEnd),
      matched: true,
    });

    // matchIndex 이동
    cursor = matchEnd;
    matchIndex = lowerDisplayName.indexOf(lowerQuery, cursor);
  }

    
  // displayName과 query가 일치하지 않는 뒷 문자열이 존재하면 처리
  if (cursor < displayName.length) {
    parts.push({
      text: displayName.slice(cursor),
      matched: false,
    });
  }
  
  // 만약 displayName과 query가 일치하는 구간이 전무하면 fallback 필요
  // 정상 처리 완료했다면 값 반환
  return parts.length > 0 ? parts : [{ text: displayName, matched: false }];
};

 

표시를 남겼으니 화면에서 UI로 처리해 주면 마무리된다.

하이라이트 UI 처리
const highlightText = (candidate: DistrictSearchResult): ReactNode => {
    const parts = buildDisplayHighlightParts(candidate.item.displayName, input);

    return (
      <>
        {parts.map((part, idx) => (
          <span
            key={`${candidate.item.fullName}-${idx}`}
            className={
              part.matched
                ? cn(searchPageStyles.candidateHighlight)
                : cn(searchPageStyles.candidateName)
            }
          >
            {part.text}
          </span>
        ))}
      </>
    );
  };

🥪 #6 중간 결과 확인

지금까지의 과정으로 UX/UI는 상당 부분 개선했다.

 

이 단계에서 Fuse.js는 후보 생성 엔진으로 유지하되,
최종 UX는 후처리와 별도 하이라이트 로직이 책임지는 구조로 정리됐다.

 

[ 1️⃣ ] 입력 길이에 따른 후보 생성 개선

🔍검색어: 서울특별시
🔍검색어: 서울특별시 동작구
🔍검색어: 서울특별시 동작구 노량

 

[ 2️⃣ ] 일치 구간에만 하이라이트 표시

🔍검색어: 서울특별시 동작구
🔍검색어: 로구 청

 

[ 3️⃣ ] 오타 허용

🔍검색어: 사울특별시 ㅈ로구

 

개선 계획 4건에서 3건을 처리할 수 있었다.🎉

 

[ ✅ ] 1️⃣ 일부 오타 허용

[ ✅ ] 2️⃣ 연관성 높은 검색 결과 제공

[ ✅ ] 3️⃣ 부분 검색 지원을 통한 하이라이트

[       ] 4️⃣ 캐시와 TTL을 통해 Kakao Local API 중복 호출을 줄이고 응답 비용을 절감

 

남은 건 캐시 및 TTL 적용인데 내용이 길어져 캐시/TTL 적용은 다음 글에서 이어서 다루려고 한다.


정리

지금은 지역명을 json 파일이 존재해서 Fuse.js 라이브러리를 적용하기 수월했다 생각한다.

만약 아무것도 없는 상태에서라면 검색용 데이터셋을 생성하는 것부터 시작해야 했다.

 

무엇보다 지금 만들고 있는 애플리케이션은 백엔드 서버 없이 돌아가는 프로젝트라서

json 파일이 필수는 아니지만 적절한 방안이라고 생각한다.

(어디까지나 데이터셋이 준비된 상태를 가정했던 것 같다.)

 

이번 작업을 통해 느낀 점은
검색 라이브러리 하나만으로는 좋은 UX가 완성되지 않는다는 점이었다.

Fuse.js는 후보를 빠르게 찾는 데에는 충분히 유용했지만,
긴 입력에서의 후보 축소와 직관적인 하이라이트까지 단독으로 해결해주지는 못했다.

결국 사용자 경험을 높이기 위해서는
라이브러리를 중심에 두되, 서비스 요구사항에 맞는 후처리와 표시 로직을 별도로 설계해야 하는 점을 배웠다.

 

반응형