- 발행일
옵션이 많아질수록 필요한 검색형 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> 에 걸림
처음 캡처한 clearEl은 DOM에서 분리된 유령 노드가 됐던 거예요. 토글은 그 유령에 걸리니 화면은 그대로였습니다.
고친 방법
두 가지가 가능했습니다.
- 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 같은 속성과 위/아래 키 탐색이 아직 얕아요. 라이브러리를 안 쓰기로 한 이상 이건 제가 갚아야 할 몫이고, 다음 차례로 잡아뒀습니다.