TL;DR
Fuse.js로 fuzzy search 구현했더니 브라우저가 얼어붙었다. Lbox의 SymSpell 사례를 조사하고, 데이터 규모에 맞는 기술 선택의 중요성을 깨달았다. 결론: 우리 규모에서는 es-hangul만으로 충분했다.
문제 상황: 검색하면 브라우저가 멈춘다
한글 검색 기능을 구현하면서 “오타 허용” 기능을 추가하기 위해 Fuse.js를 도입했다.
// Phase 4: Fuse.js로 오타 허용
import Fuse from 'fuse.js';
const fuseInstance = useMemo(() => {
const allNodes = getAllNodes(templateTree); // 트리 평탄화
return new Fuse(allNodes, {
keys: ['title'],
threshold: 0.3, // 오타 허용 범위
ignoreLocation: true,
minMatchCharLength: 2,
});
}, [templateTree]);
// 검색
const results = fuseInstance.search(query);
결과:
- “점과 지선” → “점과 직선” 오타 교정 ✅
- 브라우저 프리징 발생 ❌
원인 분석: 왜 느렸나?
Fuse.js의 동작 방식
사용자 타이핑: "점과" 입력
↓ 300ms 디바운싱
↓ Fuse.js 검색 시작
↓ 전체 트리 평탄화 (getAllNodes) - 100ms
↓ Fuse 인덱스 재구축 - 200ms
↓ Fuzzy matching (모든 노드) - 300ms
↓ React 리렌더링 - 100ms
총 시간: 700ms → UI 블로킹! 🔥
병목 지점
- 트리 구조의 복잡성
- 단순 리스트가 아니라 중첩된 트리 구조
getAllNodes()함수로 평탄화 필요 → 추가 비용
- 검색마다 전체 스캔
- 타이핑할 때마다 모든 노드에 대해 fuzzy matching 계산
- Bitap 알고리즘: O(n × m) (n=노드 수, m=문자열 길이)
- React 리렌더링
- 검색 결과 변경 → 트리 컴포넌트 리렌더링
- Fuse 계산 + React 렌더링이 동시에 발생
대안 탐색: Lbox의 검색 시스템
문제를 해결하기 위해 대기업 검색 시스템을 조사했다. Lbox(판례 검색 서비스)의 기술 블로그에서 흥미로운 내용을 발견했다:
“SymSpell 알고리즘을 사용하여 오타 교정을 구현했습니다. 이 알고리즘은 edit distance 기준 ‘삭제’만을 추가하여 메모리와 검색 속도 면에서 기존 방식보다 훨씬 빠릅니다.”
핵심 질문:
- SymSpell이 Fuse.js보다 빠른가?
- 우리도 서버 검색으로 전환해야 하나?
SymSpell vs Fuse.js 기술 비교
1. 알고리즘 차이
Fuse.js: Bitap 알고리즘 (실시간 계산)
// 동작 방식
const fuse = new Fuse(allNodes, { threshold: 0.3 });
// 검색할 때마다 매번 계산
fuse.search("점과 지선");
// → 모든 노드에 대해 Levenshtein Distance 계산
// → "점과 직선" (distance=1), "점과 평면" (distance=4) 등
과정:
입력: "점과 지선"
대상: "점과 직선"
1. 문자 하나하나 비교
점 = 점 ✓
과 = 과 ✓
공백 = 공백 ✓
지 ≠ 직 ✗ (distance +1)
선 = 선 ✓
2. 최종 distance = 1
3. threshold(0.3) 이하면 매칭
시간 복잡도: O(n × m)
SymSpell: 삭제 기반 해싱 (사전 구축)
// 동작 방식
const symspell = new SymSpell();
// 초기화 시 한 번만 실행 (마운트 시)
templates.forEach(t => {
symspell.createDictionaryEntry(t.title, 1);
});
// → "점과 직선" → 미리 오타 후보 생성 및 저장
// ["점과 직", "점과 선", "점 직선", ...] → "점과 직선"
// 검색 시: O(1) 해시 조회
symspell.lookup("점과 지선", 2);
// → 사전에서 바로 찾음 ✅
초기화 단계 (한 번만):
"점과 직선" 입력
→ Edit Distance 2 이내 "삭제" 후보 생성:
- "점과 직"
- "점과 선"
- "점 직선"
- "과 직선"
- ...
→ 해시맵에 저장:
{
"점과 직": ["점과 직선"],
"점과 선": ["점과 직선"],
...
}
검색 단계:
"점과 지선" 입력
→ 이것도 삭제 후보 생성:
- "점과 선" ← 이미 해시맵에 있음!
→ 해시맵["점과 선"] → "점과 직선" 반환 ✅
시간 복잡도: O(1) (해시 조회)
2. 성능 비교
| 항목 | Fuse.js | SymSpell | es-hangul (현재) |
|---|---|---|---|
| 초기화 | 빠름 (50ms) | 느림 (300ms) | 빠름 (0ms) |
| 검색 속도 | 느림 (300ms) | 매우 빠름 (1ms) | 빠름 (10ms) |
| 메모리 | 높음 (데이터×2) | 중간 (데이터×10) | 낮음 (데이터×1) |
| 번들 크기 | 12KB | 5-8KB | 3KB |
| 오타 허용 | ✅ 완전히 다른 문자 | ✅ 완전히 다른 문자 | ❌ 불완전 타이핑만 |
| 불완전 타이핑 | ❌ | ❌ | ✅ |
3. 메모리 사용량 비교
Fuse.js
// 인덱스 구조
{
keys: ['title'],
records: [
{ title: "점과 직선의 거리", $: {...} },
{ title: "삼각형의 넓이", $: {...} },
// ... 전체 노드 복사본
],
norm: 0.6,
// Bitap 패턴 등...
}
메모리:
- 원본 데이터 복사 + 인덱스 구조
- 약 12KB 라이브러리 + 데이터 크기 × 2배
SymSpell
// 사전 구조
{
"점과 직": Set(["점과 직선"]),
"점과 선": Set(["점과 직선"]),
"점 직선": Set(["점과 직선"]),
// ... Edit Distance 2 이내 모든 삭제 조합
}
메모리 계산:
템플릿 1개: "점과 직선" (4글자)
Edit Distance 2 기준
삭제 후보 수:
- ED=1: 4개 (각 글자 1개씩 삭제)
- ED=2: 6개 (2개씩 삭제 조합)
총 10개 엔트리
템플릿 500개 × 평균 10개 = 5000 엔트리
→ 약 100-200KB 메모리
클라이언트 vs 서버 검색 의사결정
Lbox는 왜 서버 검색을 하는가?
클라이언트: "음주" 입력
↓ 300ms 디바운싱
↓ API 호출 (50ms)
서버: 미리 구축된 FST에서 O(1) 검색
↓ 결과 반환 (10ms)
클라이언트: 결과 렌더링
총 시간: 360ms (사용자는 체감 안 함 - 타이핑 중)
Lbox의 특징:
- 데이터 규모: 수십만 개 판례 문서
- 검색어 로그: 매일 수만 건 (패턴 학습 가능)
- 복잡한 처리: 형태소 분석, 오타 교정, 띄어쓰기
- ML 모델: Learning to Rank 적용
기술 스택:
- Elasticsearch (분산 검색 엔진)
- FST (Finite State Transducer)
- Airflow (데이터 파이프라인)
- SymSpell (오타 교정)
우리는 왜 클라이언트 검색을 하는가?
클라이언트: "점과" 입력
↓ 300ms 디바운싱
↓ 정확 매칭 시도 (10ms) → 성공! Early return
↓ React 리렌더링 - 20ms
총 시간: 330ms (빠름! ✅)
CheckUS의 특징:
- 데이터 규모: 캠퍼스당 수백 개 템플릿
- 검색어 로그: 없음 (각자 다른 검색어)
- 단순한 처리: 문자열 매칭만
- 즉각 응답: 네트워크 지연 없음
기술 스택:
- es-hangul (한글 라이브러리, 3KB)
- React 상태 관리
- 메모이제이션 (useMemo, useCallback)
의사결정 프레임워크
데이터 규모별 가이드
| 데이터 규모 | 권장 방식 | 이유 |
|---|---|---|
| < 1,000개 | 클라이언트 검색 | 메모리에 모두 로드 가능, 즉각 응답 |
| 1,000 ~ 10,000개 | 클라이언트 + 최적화 | Web Worker, Virtual Scrolling, 인덱싱 |
| > 10,000개 | 서버 검색 | Elasticsearch, 캐싱, 분산 처리 |
Trade-off 분석
클라이언트 검색의 장점:
- ✅ 즉각 응답 (네트워크 지연 없음)
- ✅ 서버 부담 없음 (API 호출 0건)
- ✅ 오프라인 동작 가능
- ✅ 개발 단순함
클라이언트 검색의 단점:
- ❌ 대규모 데이터 처리 어려움
- ❌ 복잡한 검색 기능 제한
- ❌ 사용자 기기 성능 의존
서버 검색의 장점:
- ✅ 대규모 데이터 처리
- ✅ 강력한 CPU/메모리 활용
- ✅ ML 모델 적용 가능
- ✅ 검색 로그 수집 및 분석
서버 검색의 단점:
- ❌ 네트워크 지연 (최소 50ms)
- ❌ 서버 비용 증가
- ❌ API 개발 및 유지보수
- ❌ 오프라인 불가능
최종 선택: es-hangul의 disassemble만으로 충분
SymSpell을 도입하지 않은 이유
// SymSpell의 장점
✅ 오타 허용: "점과 지선" → "점과 직선"
✅ 빠른 검색: O(1) 해시 조회
// SymSpell의 단점
❌ 초기화 비용: 250ms (템플릿 500개 기준)
❌ 메모리 사용: +200KB
❌ 번들 크기: +5-8KB
❌ 코드 복잡도: 디버깅 어려움
사용자 요구사항 재검토
실제로 필요한 기능:
- ✅ 띄어쓰기 무시: “점과직선” → “점과 직선”
- ✅ 불완전 타이핑: “점과 ㅈ” → “점과 직선”
- ✅ 초성 검색: “ㅈㄱㅈㅅ” → “점과 직선”
- ✅ 다중 키워드: “점과 직선 공수2”
- ⚠️ 오타 허용: “점과 지선” → “점과 직선” (빈도 낮음)
결론:
- 4개 핵심 기능은 es-hangul만으로 해결됨
- 오타 허용은 사용자 피드백 후 재검토
- 성능과 단순성 우선
최종 구현
import { getChoseong, disassemble } from 'es-hangul';
// 자모 분해 매칭 헬퍼 함수 (불완전 타이핑 지원)
const matchesJamo = (text: string, query: string): boolean => {
try {
const textDisassembled = disassemble(text).replace(/\s+/g, '');
const queryDisassembled = disassemble(query).replace(/\s+/g, '');
return textDisassembled.includes(queryDisassembled);
} catch (e) {
return false;
}
};
// 4단계 우선순위 검색
const isNodeMatched = (node: TaskTemplate, query: string): boolean => {
const normalizedTitle = node.title.toLowerCase().replace(/\s+/g, '');
const normalizedQuery = query.toLowerCase().replace(/\s+/g, '');
// 1순위: 띄어쓰기 무시 정확 매칭 (빠름)
if (normalizedTitle.includes(normalizedQuery)) {
return true;
}
// 2순위: 자모 분해 매칭 (불완전 타이핑)
if (matchesJamo(node.title, query)) {
return true;
}
// 3순위: 초성 검색
if (getChoseong(node.title).includes(query)) {
return true;
}
// 4순위: 다중 키워드 AND 검색
const keywords = query.trim().split(/\s+/);
if (keywords.length > 1) {
return keywords.every(kw => {
const kwNormalized = kw.replace(/\s+/g, '');
return normalizedTitle.includes(kwNormalized)
|| getChoseong(node.title).includes(kw);
});
}
return false;
};
성능:
평균 검색 시간: 10ms (1순위에서 대부분 매칭)
번들 크기: 3KB (es-hangul)
메모리 사용: 원본 데이터와 동일
배운 점
1. 라이브러리 선택의 함정
잘못된 사고:
Fuse.js = Fuzzy Search = 오타 허용 = 좋은 UX
→ 무조건 도입!
올바른 사고:
1. 사용자가 정말 오타를 많이 내는가?
2. 데이터 규모는 얼마나 되는가?
3. 성능 영향은 어느 정도인가?
4. 더 간단한 대안은 없는가?
→ 상황에 맞는 선택!
2. 규모에 맞는 기술 선택
대기업 솔루션 ≠ 우리에게 최적
| 규모 | 적절한 기술 |
|---|---|
| 수백 개 | 클라이언트 검색 (es-hangul) |
| 수천 개 | 클라이언트 + 최적화 (Web Worker) |
| 수만 개 | 서버 검색 (Elasticsearch) |
Lbox의 기술이 좋다고 해서 우리도 따라하면 안 된다.
3. 성능 vs 기능의 균형
Fuse.js: 기능 ✅, 성능 ❌
SymSpell: 기능 ✅, 성능 ✅, 복잡도 ⚠️
es-hangul: 기능 ⚠️, 성능 ✅, 복잡도 ✅
→ 우리 규모에서는 es-hangul이 최적
4. 데이터 기반 의사결정
가정에서 시작하지 말고, 데이터로 검증:
// 가정: "사용자가 오타를 많이 낼 것이다"
// 검증: 검색 실패율, 사용자 피드백 수집
// Phase 1: es-hangul만 배포
// → 1개월 후 검색 실패율 3%
// Phase 2: 피드백 분석
// → "오타" 관련 불만 5건 미만
// 결론: SymSpell 불필요
결론
프론트엔드 검색 구현 시 고려사항:
- 데이터 규모 먼저 파악하기
- 1,000개 미만: 클라이언트 검색
- 10,000개 이상: 서버 검색
- 사용자 요구사항의 우선순위 정하기
- 필수 기능 vs 선택 기능
- 빈도 높은 문제부터 해결
- 성능 측정 후 최적화하기
- 추측하지 말고 프로파일링
- 병목 지점 찾아서 해결
- 단순함 유지하기
- 복잡한 솔루션은 유지보수 비용 증가
- 필요할 때만 추가
기술은 문제를 해결하는 도구일 뿐, 목적이 아니다.
Comments