발행일

옵션이 많아질수록 필요한 검색형 select — searchableSelect.js 복구 회고

옵션이 많아질수록 필요한 검색형 select — searchableSelect.js 복구 회고

용어 폼의 값영역·해당 인스턴스·도메인 필드는 클래스를 참조합니다. 클래스가 수백 개로 늘면서, 네이티브 <select>를 스크롤로 훑어 고르는 게 고역이 됐어요. 입력으로 좁혀 고르는 검색형 select(searchableSelect.js)를 다시 도입하고, 거기서 줄줄이 나온 후속 결함을 잡은 기록입니다.


1. 왜 검색형이 필요했나

네이티브 select는 옵션이 10개일 땐 완벽하지만, 수백 개가 되면 원하는 항목을 찾는 비용이 선형으로 늘어요. 예전엔 select2 같은 검색형 단일 선택을 썼는데, 그 의존을 걷어내면서 이 필드들이 평범한 select로 회귀해 있었습니다.

즉 이건 새 기능이 아니라 의존성 정리의 부작용을 갚는 일이었어요. 그래서 "어떻게 되살릴까"가 곧 "왜 걷어냈는지를 되돌리지 않으면서 되살릴 수 있나"가 됐습니다.


2. 세 가지 방법과 고른 이유

방법장점포기하는 것판단
① select2 재도입예전 코드 그대로 복구, 검증된 동작걷어낸 이유를 되돌림. jQuery 의존 + 번들 증가 + 스타일 오버라이드 부채
② tom-select / choices.js 도입기능 풍부, jQuery 불필요새 의존성 학습·스타일 정합. 필요한 건 "단일 선택 + 검색" 하나뿐인데 나머지가 다 따라옴
③ 네이티브 select 위에 얇은 검색 UI 직접 얹기의존성 0. 폼 수집 로직 무변경접근성·엣지 케이스를 직접 챙겨야 함✓ 채택

②는 합리적인 선택지였습니다. 실제로 후보 라이브러리의 번들 크기와 API를 훑어봤어요. 접은 이유는 필요한 기능이 너무 적었기 때문입니다. 다중 선택·태그 입력·원격 로딩·그룹핑 전부 안 쓰는데, 그 코드를 다 들고 오면서 스타일까지 프로젝트 톤에 맞춰 오버라이드해야 했어요. 관계 피커처럼 서버 검색이 필요한 필드는 이미 별도 컴포넌트가 있어서, 여기서 필요한 건 이미 내려온 옵션을 문자열로 좁히는 것뿐이었습니다.

③을 고르면서 제약을 하나 못박았어요. 네이티브 select의 동작(폼 수집)은 그대로 두고, 그 위에 검색 UI만 얹는다. 이 제약이 있으면 폼 collect 로직을 한 줄도 안 건드립니다. 나중에 이 컴포넌트를 걷어내도 select만 다시 보이면 끝이고요.

// 단일 <select> → 검색 가능한 콤보박스. 네이티브 select의 value 만 갱신하므로
// 폼 수집(collect) 로직은 그대로 호환된다.
// 적용 대상: select.searchable-select
// 동적으로 다시 그려지는 폼을 위해 window.initSearchableSelect() 로 재호출 가능 (멱등).

processed-searchable 클래스로 이미 처리한 select는 건너뛰게 해서, 폼이 동적으로 다시 그려져도 중복 초기화가 안 되게 했습니다(멱등). 이 폼은 편집 중에 tbody를 다시 그리는 구조라, 멱등성이 없으면 초기화가 겹쳐 쌓입니다.


3. 구조 — 숨긴 select + 표시 버튼 + 검색 패널

원본 select는 hidden으로 감추고, 그 위에 세 조각을 만들었어요.

조각역할
표시 버튼(ss-display)현재 선택값 + 해제(×) + 펼침(∨) 표시
검색 패널(ss-panel)검색 input + 필터된 옵션 리스트
원본 <select>화면엔 숨김, 폼 제출 시 value 제공

옵션을 고르면 원본 select의 value만 갱신합니다. 그래서 서버로 가는 데이터 형태는 네이티브 select와 100% 동일해요.

function syncDisplay() {
  const opt = select.options[select.selectedIndex];
  const hasValue = opt && opt.value !== "";
  labelEl.textContent = hasValue ? opt.text : placeholder;
  clearEl.classList.toggle("hidden", !hasValue);   // ← 나중에 이 줄이 함정이 됨
}

4. 복구한 결함들

도입 직후 후속 결함이 줄줄이 나왔습니다. 직접 만든 대가이기도 하고, 원래 라이브러리가 조용히 처리해주던 것들이기도 해요.

4.1 placeholder가 잘림 → 필드 폭 확대

도메인 필드의 안내문(예: "주술(Subject)에 해당하는 클래스를 선택해주세요.")이 좁은 폭에서 잘리거나 2줄이 됐어요. range/instance/domain 필드 폭을 max-w-[28rem]로 넓혀 한 줄에 들어가게 하고, 라벨은 말줄임(truncate) 대신 break-words로 뒀습니다.

truncate를 안 쓴 게 의도적이에요. 클래스 이름은 prefix:name 형태라 뒤쪽이 잘리면 무슨 클래스인지 구분이 안 됩니다. 두 줄이 되더라도 다 보이는 쪽이 낫다고 봤어요.

4.2 선택 후 해제가 안 되는 버그

클래스 참조 select은 값이 있으면 빈 옵션(value="")을 안 그립니다. 그래서 select.value = ""로 해제하려 해도 그런 옵션이 없어서 무효 였어요.

여기서도 두 갈래였는데, 빈 옵션을 항상 그려넣기는 접었습니다. 목록에 의미 없는 빈 줄이 하나 생기고, 서버에서 내려주는 옵션 구성에 프런트가 개입하게 되니까요. 대신 인덱스 자체를 비웠습니다.

// choose("") 에서 — 빈 옵션이 없으므로 선택 자체를 비운다
select.selectedIndex = -1;   // × 로 해제 → 빈 값 저장

4.3 돋보기 아이콘 위치

검색 input 오른쪽에 돋보기를 넣되, 클릭을 방해하지 않도록 pointer-events-none로 뒀습니다.

<div class="relative">
  <input class="ss-search w-full pl-3 pr-9 py-2 ..." placeholder="검색어를 입력하세요." />
  <i class="fa-solid fa-magnifying-glass absolute right-3 top-1/2 -translate-y-1/2
            text-gray-400 pointer-events-none"></i>
</div>

5. 진짜 함정 — × 아이콘이 사라지던 버그

가장 오래 걸린 건 "× 해제 버튼이 안 보이는" 버그였습니다. 코드상 clearEl.classList.toggle("hidden", ...)로 분명히 토글하는데, 화면엔 반영이 안 됐어요.

어떻게 찾았나

토글이 실행되긴 하는지부터 확인했습니다. 콘솔 로그를 찍어보니 함수는 호출되고 hasValue도 정확했어요. 그럼 토글 대상이 화면의 그 요소가 아니라는 뜻입니다. 이때 확인한 게 isConnected였어요.

// syncDisplay 안에서 찍어본 것
console.log(clearEl.isConnected);              // false  ← DOM에서 떨어져 나감
console.log(display.querySelector(".ss-clear")); // <svg class="ss-clear ...">

clearEl은 DOM에 붙어 있지도 않고, 지금 화면에 있는 건 <i>가 아니라 <svg>였습니다. 여기서 범인이 드러났어요.

원인 — Font Awesome 6의 svg(JS) 모드

이 프로젝트는 FA를 all.min.js로 로드해서, 동적으로 만든 <i> 태그를 런타임에 <svg>교체 합니다.

// 변경 전: 셋업 시 <i> 를 한 번 캡처
const clearEl = display.querySelector(".ss-clear");
// → FA 가 이 <i> 를 <svg> 로 교체하는 순간, clearEl 은 "떨어져 나간 옛 <i>" 를 가리킴
// → hidden 토글이 화면의 svg 가 아니라 사라진 <i> 에 걸림

처음 캡처한 clearElDOM에서 분리된 유령 노드가 됐던 거예요. 토글은 그 유령에 걸리니 화면은 그대로였습니다.

고친 방법

두 가지가 가능했습니다.

  • FA를 CSS 모드로 전환 — 근본적이지만 프로젝트 전역 설정이라 아이콘 전수 확인이 필요합니다. 이 버그 하나 때문에 걸 범위가 아니었어요.
  • 참조를 캐시하지 않고 매번 다시 조회 — FA가 svg로 바꿔도 ss-clear 클래스는 보존되니까 가능합니다.

후자를 골랐습니다.

// 변경 후: syncDisplay 에서 매번 다시 조회
function syncDisplay() {
  const clearEl = display.querySelector(".ss-clear");   // svg 로 교체돼도 클래스는 남음
  ...
  clearEl.classList.toggle("hidden", !hasValue);
}

정적 아이콘(돋보기·∨)은 토글이 없어서 무관했습니다. 동적으로 토글하는 FA 아이콘만 이 참조 캐시 문제에 걸려요.


6. 검증

항목결과
옵션 검색수백 개 목록에서 입력으로 좁혀 선택 동작
폼 수집서버로 가는 값이 네이티브 select와 동일 (collect 로직 무변경)
재초기화폼 재렌더 후 initSearchableSelect() 재호출 시 중복 초기화 0건 (멱등)
해제(×)빈 옵션이 없는 select에서도 해제 → 빈 값 저장
placeholder도메인 안내문 한 줄 표시, 긴 클래스명은 두 줄로 전부 노출
아이콘 토글값 유무에 따라 × 표시/숨김 정상 (FA svg 교체 후에도)

7. 회고와 남은 것

  • 옵션 수가 늘면 select는 검색형이 필요합니다. 수백 개를 스크롤로 찾게 두는 건 사실상 못 쓰는 UI예요. 단, 네이티브 value만 갱신 하는 식으로 얹으면 폼 로직을 안 건드립니다.
  • 라이브러리를 접을 땐 "필요한 기능이 얼마나 되나"로 재는 게 좋았습니다. 기능이 풍부한 게 장점이 아니라 비용일 수 있어요. 반대로 접근성·엣지 케이스를 직접 떠안는 것도 비용이라, 이건 매번 다시 재야 하는 판단입니다.
  • 빈 값이 없는 select의 해제는 selectedIndex = -1. value = ""는 그런 옵션이 있어야만 동작해요.
  • FA svg 모드에서 동적 아이콘 참조를 캐시하지 마세요. <i><svg> 교체로 셋업 시점 참조가 유령이 됩니다. 토글 직전에 클래스로 다시 조회 하는 게 안전했어요.
  • "분명히 실행됐는데 화면이 안 바뀐다"면 isConnected를 찍어봅니다. 이번에 제일 값어치 있었던 한 줄이었어요. 로그를 아무리 늘려도 함수는 정상이었으니까요.

남은 건 접근성입니다. 직접 만든 대가로 role="combobox" / aria-expanded / aria-activedescendant 같은 속성과 위/아래 키 탐색이 아직 얕아요. 라이브러리를 안 쓰기로 한 이상 이건 제가 갚아야 할 몫이고, 다음 차례로 잡아뒀습니다.