기술 블로그

초성 검색을 지원하는 Local Async SelectBox 구현하기 본문

프론트엔드

초성 검색을 지원하는 Local Async SelectBox 구현하기

jaegwan 2026. 2. 9. 10:33
반응형

프로젝트 규모가 커짐에 따라 서버 부하를 줄이기 위해 전체 데이터를 클라이언트에서 관리하며 필터링해야 하는 경우가 많아집니다. 특히 한국어 서비스에서는 사용자의 검색 경험을 결정짓는 '초성 검색'과 UI의 안정성을 보장하는 '포탈(Portal) 렌더링'이 필수적입니다.

이번 포스트에서는 hangul-js를 활용하여 검색 효율을 높이고, 외부 환경에 영향을 받지 않는 독립적인 LocalAsyncSelectBox를 구축한 과정을 정리합니다.


1. 커스텀 컴포넌트 구현의 필요성

기존 오픈소스 라이브러리들은 범용성을 위해 설계되었으나, 다음과 같은 기술적 제약이 있었습니다.

  • 한글 검색 최적화 미흡: 단순 텍스트 매칭만 지원하여 초성 검색 등 한국어 특화 기능을 별도로 구현해야 함.
  • 레이아웃 간섭: 드롭다운 UI가 부모 컨테이너의 overflow: hidden이나 z-index 설정에 영향을 받아 가려지는 현상 발생.
  • 낮은 예측 가능성: 라이브러리 내부 로직에 의존할 경우 특이 케이스(Edge Case) 대응 시 디버깅 리소스가 증가함.

이러한 문제를 해결하기 위해 제어권을 완전히 확보한 커스텀 컴포넌트를 제작하여 장기적인 유지보수 효율을 높이고자 했습니다.


2. 핵심 구현 사항

한글 초성 검색 로직 (hangul-js)

사용자가 'ㅅㄱ'만 입력해도 '사과'를 찾을 수 있도록 정규표현식과 hangul-js를 조합했습니다. 입력값이 초성인지 여부를 판단하여 필터링 로직을 분기 처리했습니다.

const filterData = useCallback((keyword: string) => {
  if (!keyword.trim() || keyword.length < minLength) {
    setOptions([]);
    setIsOpen(false);
    return;
  }

  const searchKeyword = keyword.toLowerCase();
  const isChosung = /^[ㄱ-ㅎ]+$/.test(searchKeyword); // 초성 여부 검사

  const filtered = data
    .filter(item => {
      if (!item?.label) return false;
      const searchTarget = item.label.toLowerCase();

      if (isChosung) {
        // 초성 검색: 각 글자의 첫 자음을 분해하여 비교
        const targetChosung = Hangul.disassemble(searchTarget, true)
          .map(char => char[0])
          .join('');
        return targetChosung.includes(searchKeyword);
      }

      // 일반 검색 및 한글 형태소 검색 지원
      return searchTarget.includes(searchKeyword) || Hangul.search(searchTarget, searchKeyword) !== -1;
    })
    .slice(0, 5); // 성능 최적화를 위한 노출 개수 제한

  setOptions(filtered);
  // ...이후 렌더링 제어
}, [data, minLength]);

ModalPortal과 뷰포트 좌표 계산

드롭다운이 레이아웃 구조에 구애받지 않도록 ModalPortal을 통해 DOM 계층의 최상단에서 렌더링되도록 했습니다. 이때 부모 요소와의 정렬을 위해 getBoundingClientRect()를 활용해 동적으로 좌표를 계산합니다.

const updateDropdownPosition = useCallback(() => {
  if (containerRef.current) {
    const rect = containerRef.current.getBoundingClientRect();
    setDropdownRect({
      top: rect.bottom + window.scrollY,
      left: rect.left + window.scrollX,
      width: rect.width,
    });
  }
}, []);

3. 실무 적용 예시: 정산 파일 업로드 폼

구현된 컴포넌트는 CalculateFileUploadForm 내에서 매체 및 상품 선택 섹션에 적용되었습니다.

  • Debounce 처리: 300ms의 타이머를 설정해 연속적인 입력 시 불필요한 연산을 방지했습니다.
  • 데이터 무결성: 입력 중에는 onChange(null)를 호출하여 선택되지 않은 상태를 명확히 전달함으로써 잘못된 데이터가 제출되는 것을 방지했습니다.

4. 마치며

컴포넌트 설계 시 가장 중점을 둔 것은 "확장성과 안정성"입니다. 라이브러리의 불투명한 내부 로직에 의존하기보다 직접 제어 가능한 구조를 구축함으로써, 예기치 못한 UI 버그를 사전에 차단하고 일관된 사용자 경험을 제공할 수 있게 되었습니다.

반응형
Comments