검색 엔지니어링수정 2026-08-07

한글 검색 구현 — 분석기·Nori·초성·자동완성·무결과 처리

한국어 검색 품질을 결정하는 분석기 파이프라인, 형태소 분석기 Nori, 자소분해 기반 초성·한영검색, 자동완성 전략, 무결과 폴백 정책을 정리한다.

한국어 검색은 공백으로 단어를 자르는 순간부터 깨진다. "서울대학교병원에서"를 그대로 색인하면 "서울대학교"로 검색해도 잡히지 않고, "ㅅㅇㄷ"로 찾는 초성검색이나 "tjfmf"를 "설을"로 읽어주는 한영검색은 Elasticsearch에 기본 기능조차 없다.

이 글은 그 간극을 메우는 구현을 다룬다. 분석기 3단계 파이프라인에서 시작해 Nori 형태소 분석의 실무 함정, 유니코드 자소분해를 이용한 초성·한영검색, 자동완성 네 가지 방식의 트레이드오프, 그리고 "결과가 0건일 때 무엇을 보여줄 것인가"라는 정책 설계까지 이어진다. 한국어 서비스에서 검색 품질을 직접 책임져야 하는 엔지니어를 위한 글이다. 그 아래에서 도는 내부 자료구조는 Elasticsearch 아키텍처에서 다룬다.

용어 정리

용어
Analyzer(분석기)텍스트를 검색 가능한 토큰으로 변환하는 파이프라인
Tokenizer문장을 토큰(단어)으로 쪼개는 핵심 단계. 분석기당 1개만
Token filter토큰을 가공(소문자·불용어·동의어·어간 등). 여러 개 연결
NoriElasticsearch 공식 한글 형태소 분석기(mecab-ko-dic 기반)
자소분해한글을 초성·중성·종성으로 분해. 초성검색·자동완성의 토대
edge n-gram앞에서부터 부분 토큰 생성(cat→c,ca,cat). 자동완성용
fuzzy편집거리(오타) 허용 검색
BM25ES 기본 유사도 알고리즘. TF-IDF에 문서 길이 보정을 더함
무결과(Zero-result)검색 0건 상황과 그 후처리 정책

분석기 파이프라인 — 모든 것의 출발점

한글이든 영문이든 검색 품질은 텍스트를 어떻게 토큰으로 쪼개는가에서 갈린다. Elasticsearch의 분석기는 3단계 파이프라인이다.

도식을 탭하면 확대해서 볼 수 있습니다
  • 핵심 제약: char filter와 token filter는 여러 개를 연결할 수 있지만, tokenizer는 딱 1개다.
  • 디버깅은 _analyze API로 한다. analyzer/tokenizer/filter/char_filter/field/explain을 지정하면 각 토큰의 token·start_offset·end_offset·type·position을 볼 수 있다. 분석기 튜닝의 1차 도구다.

순서가 중요한 실무 예가 있다. 복합명사 사전은 소문자로만 등록되므로, 대문자 입력("MUSINSA")을 분리하려면 tokenizer 이전(char filter 단계)에서 소문자화해야 한다. 순서가 틀리면 사전이 매칭되지 않는다.

text vs keyword — 분석하느냐 마느냐

textkeyword
분석기적용(토큰화)없음(원문 그대로 1토큰)
"볼보XC60" → [볼보, XC60]"볼보XC60" → [볼보XC60]
용도전문검색(match)정확일치(term)·정렬·집계
정렬/집계fielddata 필요(메모리↑)doc_values로 기본 지원
주의cafe/Cafe/Café를 별개로 인식
  • 동적 매핑 시 문자열은 자동으로 text + fields.keyword(ignore_above:256) 멀티필드가 된다.
  • keyword의 대소문자 문제normalizer(keyword 전용 간이 분석기)로 해결한다. lowercase·asciifolding(à→a)으로 Café/Cafe/cafe를 통일한다.

한글 형태소 분석 — Nori

한국어는 조사·복합어 때문에 공백 분리로는 검색이 안 된다. "서울대학교병원에서"를 그대로 색인하면 "서울대학교"로 검색해도 안 잡힌다. 형태소 분석기 Nori가 이걸 푼다.

  • 설치: bin/elasticsearch-plugin install analysis-nori (Elastic Cloud는 Extensions에서 체크).
  • 구성: nori_tokenizer + 토큰 필터(nori_part_of_speech, nori_readingform, nori_number).

decompound_mode — 복합어를 어떻게 분해할까

mode"가곡" 처리용도
none가곡 (분해 안 함)복합어를 통째로
discard가 + 곡 (분해만)분해된 형태만 검색
mixed가곡 + 가 + 곡 (둘 다)원본·분해 모두 매칭

주요 토큰 필터

필터하는 일
nori_part_of_speech불필요 품사 제거(stoptags: E 어미, J 조사, MAG 부사 등)"에서" 같은 조사 제거
nori_readingform한자 → 한글韓國 → 한국
nori_number한글 숫자 정규화삼천2백2십삼 → 3223
user_dictionary사용자 사전으로 분해 규칙 지정"싼타페TM" → "TM" 검색되게

Nori 실무 함정(gotcha) — 상용엔진에는 없던, 직접 부딪히게 되는 문제들이다.

  • C++: 사전에 없고 Nori는 영어 대소문자를 구분 못 해 분석에 실패한다. char filter로 소문자화가 필요하다.
  • 강남콩: 사용자 사전에 '강남'이 있으면 최우선으로 잡혀 "강남+콩"으로 오분석된다. 짧은 단어를 사전에 등록하면 문장 전체에서 우선 분석되는 부작용이 있다.
  • 근본 원인은 사전이다. Nori는 mecab-ko-dic(1998 세종 말뭉치) 기반이라 신조어·외래어에 취약하다. 사전을 지속 업데이트해야 한다.

자소분해와 초성검색 — 기본 API가 없는 영역

상용 검색엔진에서는 규격으로 주어지지만 Elasticsearch에서는 직접 구현해야 하는 대표 기능이다.

한글 유니코드 구조

  • 한글 = 초성(19) × 중성(21) × 종성(27+1) = 11,172 음절. 유니코드 AC00(가)~D7A3(힣).
  • 분해 공식: 음절코드 = (초성×21 + 중성)×28 + 종성 + 0xAC00.
  • 역산: 종성 = v%28; 중성 = ((v-종성)/28)%21; 초성 = (((v-종성)/28)-중성)/21.
  • 예: '가' → ㄱ+ㅏ, '갈' → ㄱ+ㅏ+ㄹ.

왜 필요한가

  • 한글은 1글자 차이로 완전히 다른 단어가 된다(경기대 ≠ 경가대 ≠ 경원대). 철자 오류 대응에 자소 단위가 유리한 이유다.
  • 초성검색: "ㅊㅅ" 입력으로 "초성"을 찾는다. 전화번호부 앱에서 "아이유 → ㅇㅇㅇ"로 찾는 그 기능이다.

Elasticsearch에는 자소분해 기본 API가 없다

이것이 핵심이다. ES에는 자소분해·초성 추출 기능이 기본 제공되지 않아 커스텀 플러그인(Java·jar)을 직접 만들어야 한다. 공개 플러그인 starstory-analyzer가 이 필터들을 제공한다.

기능필터명입력 → 토큰
초성 추출chosung_filter스타벅스 → ㅅㅌㅂㅅ
자소(자모) 분해jamo_filter스타벅스 → ㅅㅡㅌㅏㅂㅓㄱㅅㅡ
soundex(유사발음)soundex_filterㅏ/ㅓ 등 통일(내셔널=네셔널)
  • 구현 단계 선택: 자소분해는 tokenizer 단계에서도 되지만, tokenizer 단계에서 하면 동의어·불용어 필터를 얹기 어렵다('스벅'→'스타벅스' 동의어가 필요하다). 그래서 token filter 단계가 권장된다.
  • 플러그인은 ES·Lucene 버전에 종속이라, gradle.properties의 버전(pluginVersion·elasticsearchVersion·luceneVersion)을 맞춰 빌드하고 각 노드에 설치한 뒤 재시작해야 한다.

상용엔진에서는 "초성검색"이 규격서 한 줄이지만, Elasticsearch에서는 유니코드 분해식부터 Java 플러그인 빌드·배포까지 직접 책임져야 한다. 상용에서 오픈소스로 넘어올 때 가장 크게 체감되는 지점이고, 그 전환 과정은 상용 검색엔진에서 Elasticsearch로에 정리했다.

한영검색 — 자판 오타 교정

"tjfmf"를 치면 "설을"을 찾아주는, 한/영 자판 전환 실수를 교정하는 기능이다. 이것도 ES 기본 기능이 아니라 커스텀 플러그인 필터로 구현한다.

기능필터명입력 → 변환
영타 → 한글eng2kor_filtertmxkqjrtm → 스타벅스
한타 → 영문kor2eng_filterㄴㅅㅁㄱㅠ… → starbucks
  • 색인 시 이 필터들로 만든 서브필드를 함께 색인해 두고, 검색 시 multi_match로 여러 변환 필드를 동시에 질의한다(아래 자동완성 절의 멀티필드 패턴).
  • 오타 대응 자체는 fuzzy·동의어·자소분해 수준까지 순수 ES 기능으로 커버되지만, 자판 한영변환은 별도 커스텀 플러그인(starstory) 영역이다. 즉 "한영검색"은 ES 기본 기능이 아니라 직접 구현 영역이다.

자동완성 — 4가지 방식과 선택 기준

자동완성은 100ms 이내 응답이 요구되는 기능이다. Elasticsearch는 여러 방식을 제공하며 각각 트레이드오프가 있다.

방식원리prefix중간일치오타비고
ngram글자 단위 전부 분해OO토큰 폭발 주의
edge_ngram앞에서부터만 분해(cat→c,ca,cat)OX색인 토큰 적음
search_as_you_type전용 타입, _2gram/_3gram 자동생성OOXmulti_match+bool_prefix
completion suggesterFST를 메모리 적재OXO(fuzzy)최고속, weight·context

색인용/검색용 분석기 분리 — 자동완성의 핵심 원리

  • 색인 시에만 edge_ngram을 붙이고, 검색 시엔 빼는 것이 정석이다.
  • 이유는 이렇다. "커피"를 색인하면 [커, 커피]로 저장되는데, 검색어 "커"에도 edge_ngram을 적용하면 [커]가 되어 원하는 대로 부분 매칭된다. 검색어까지 잘게 쪼개면 오히려 오작동한다.
"index_completion":  { "type":"custom", "tokenizer":"standard",
  "filter":["lowercase","trim","autocomplete_edge"] },   // edge_ngram 포함
"search_completion": { "type":"custom", "tokenizer":"standard",
  "filter":["lowercase","trim"] }                          // edge_ngram 제외

멀티필드 병렬 색인 패턴 — 한글 자동완성의 실전 구조다.

하나의 필드(brand, model 등)에 fields초성·자모·영타·한타 서브필드를 병렬로 색인하고, 검색 시 multi_match(cross_fields, operator:and)로 한 번에 질의한다.

서브필드색인 analyzer검색 analyzer목적
completion_chosungindex_completion_chosungsearch_completion_chosung초성 자동완성
completion_jamoindex_completion_jamosearch_completion_jamo자소 자동완성
completion_eng2korindex_completionsearch_completion_eng2kor영타 자동완성
completion_kor2engindex_completionsearch_completion_kor2eng한타 자동완성
  • 클라이언트 최적화: "아이폰" 한 단어에도 자소 포함 최대 7회 API 호출이 발생한다. debouncing(입력 멈춤 후 요청)·throttling(빈도 제한)으로 서버 부하를 줄인다. 최근검색어는 localStorage로 서버 부하를 던다.

오타·유사도

Fuzzy 검색 (편집거리)

ES는 Damerau-Levenshtein distance(변경·삭제·추가 + 인접문자 전치를 1연산으로) 기반으로 오타를 허용한다.

파라미터의미
fuzziness: AUTO텀 길이별 편집거리: 02자=완전매칭, 35자=거리1, >5자=거리2
prefix_length시작 N글자는 고정(클수록 크게 빨라짐, 단 앞부분 오타는 못 잡음)
max_expansions생성 변형본 수 상한(줄일수록 빠름)
transpositions인접 두 문자 위치 교환 허용
  • gotcha: 퍼지 대상은 간단한 분석기만 써야 한다(ngram·동의어와 조합하면 이상 결과가 나온다). 일반 match보다 느리다.

BM25 vs TF-IDF (유사도 알고리즘)

TF-IDFBM25 (ES 7.0+ 기본)
개념TF × IDFTF-IDF 확장
문서 길이 보정없음(긴 문서 유리)있음(k1·b로 정규화)
TF 포화무한 증가포화(단어 도배 방어)
  • BM25 식: IDF × ((k1+1)·TF / (k1·(1-b+b·docLen/avgLen) + TF)).
  • 커스텀 유사도: "test"를 검색했는데 "test_test"가 먼저 뜨거나 "아이폰 아이폰 아이폰" 도배 문서가 상위에 오면, scripted similarity로 TF 영향을 줄인다(예: 1/sqrt(docLen)만 사용). explain:true로 점수를 분해해 확인한다.

랭킹·스코어 제어

기본 점수(BM25)만으로 부족할 때 function_score로 비즈니스 로직을 얹는다.

함수용도
script_score스크립트로 커스텀 점수
field_value_factor필드 값(평점·판매량)을 점수에 반영
random_score추천 리스트 셔플(seed로 재현)
decay(gauss/exp/linear)거리·최신성 가중(가까울수록·최근일수록↑)
rank_feature(saturation/log/sigmoid)숫자 신호를 랭킹에 결합
  • 결합 방식 2축: score_mode(함수들 간: multiply/sum/avg/max...) × boost_mode(쿼리 점수와: multiply/replace/sum...).
  • 맛집 검색 예: geo_point + gauss decay(origin=위치, scale=5km) + 카테고리 가중을 조합하면 가깝고 취향 맞는 곳이 상위로 온다.

무결과 처리 — "결과가 없을 때 무엇을 보여줄 것인가"

상용엔진에는 무결과 처리 규격이 있지만 Elasticsearch에서는 정책을 직접 설계해야 한다. 검색 0건은 이탈로 직결되므로, 단계적 폴백(fallback)으로 "뭐라도" 보여준다.

도식을 탭하면 확대해서 볼 수 있습니다

무결과를 줄이는 ES 수단들

수단하는 일
동의어(synonym)오타·이표기를 정타로 매핑("횬대"→"현대"). 0건을 살리는 1차 수단
significant_terms연관 검색어 제안. 전경/배경 빈도 비교로 특이도 산정(단순 빈도 아님)
큐레이션(Promoted)운영자가 특정 결과를 수동 상단 고정(관련성 무시)
큐레이션(Hidden)특정 문서를 검색에서 제외
존재 확인 팁size=0&terminate_after=1로 첫 매칭에서 샤드 종료 → 필터 UI 활성화 판단 빠르게
  • 선택값만 있고 검색어 없을 때(필터 브라우징): must를 비우고 filterterms만 넣어 점수 없이 결과만 좁힌다(캐시 이득).
  • 주의: Promoted 큐레이션은 관련성 점수를 무시하고 무조건 상단이라, 남용하면 검색 품질이 왜곡된다.

동의어·불용어·사용자사전 — 색인 시 vs 검색 시

같은 사전이라도 언제 적용하느냐가 트레이드오프를 가른다.

색인 시 적용검색 시 적용
장점검색 성능↑(확장 리소스 불필요)실시간 업데이트 가능
단점인덱스 커짐·term 통계 왜곡·Reindex 필요검색 시 확장 비용
권장안정적 사전은 색인 시가 관리 유리자주 바뀌면 검색 시
  • updatable synonym: search_analyzersynonym(updateable:true, 파일 기반)을 두고, 사전 변경 시 _reload_search_analyzers + _cache/clear?request=true재색인 없이 실시간 반영한다.
  • 동의어 표현 2방식: 동등관계 "A, B"(양방향) / 치환 "A => B"(단방향). synonym_graph가 권장된다.
  • 불용어(stop): 검색 노이즈·금칙어 제거(type:stop, stopwords_path).
  • 사용자사전(user_dictionary): "싼타페TM"을 등록하면 "TM" 검색에 노출되게 하는 등 도메인 어휘를 보강한다.

이 글에서 다룬 판단들 — Nori 오분해, 초성·한영검색, 자동완성 분석기 분리, 무결과 폴백, 동의어 적용 시점 — 은 검색 엔지니어링 Q&A에도 문답 형태로 정리해 두었다.