Elasticsearch 입문 11편 Korean Analyzer. Nori·mecab-ko·사용자 사전·동의어·자모 분리·복합명사. 한국 검색 표준.
이 글은 Elasticsearch 입문에서 운영까지 시리즈 38편 중 11편이에요. 앞 10편이 standard analyzer 와 whitespace tokenizer 같은 영어권 기본기를 다뤘다면, 이번 11편은 한국어를 진짜로 잘 쪼개는 자리 예요. e-commerce 상품 검색 · 사내 위키 · 콜센터 로그 분석 — 한국 회사에서 검색이 제대로 돌게 만들려면 이 한 편이 사실상 분기점.
이 글은 Elasticsearch 8.x 공식 docs 의 analysis-nori 플러그인 페이지와 mecab-ko-dic GitHub 를 참고해 한국어 학습 노트로 풀어쓴 자료예요.
로컬 Docker 컨테이너에 analysis-nori 를 깔고 _analyze API 로 직접 토큰을 찍어 보면 본문이 훨씬 잘 박혀요.
한국어는 왜 골치인가
영어는 공백 한 번 으로 단어가 잘려요. "I love Elasticsearch" 는 그대로 세 토큰이라 standard tokenizer 만으로 검색이 어느 정도 돌아가요. 한국어는 그렇지 않아요.
세 가지 결정적 차이가 있어요. 첫째, 띄어쓰기가 약속이 약해요. "오늘 점심 뭐먹지" 와 "오늘점심뭐먹지" 둘 다 같은 의미인데 standard tokenizer 는 후자를 한 덩어리 로 잡아요. 둘째, 조사·어미가 어절에 붙어 있어요. "신촌에서" 의 "신촌" 을 검색해도 "신촌에서" 가 안 잡혀요. 같은 어절에 "신촌" 과 "에서" 가 한 토큰으로 묶여 있어서 — 부분 일치 가 아니라 전체 일치 로 들어가서 그래요. 셋째, 복합명사가 흔해요. "가정용공기청정기" 한 단어는 "가정용 + 공기 + 청정기" 로 분해할 수 있어야 "공기청정기" 검색에서 잡혀요.
이 세 가지를 잡으려면 형태소 분석기 (morphological analyzer) 가 필요하고, Elasticsearch 에서 한국어 형태소 분석기 표준은 두 갈래예요. Nori 와 mecab-ko. 2026 년 5월 현재 8.x 환경에서는 Nori 가 사실상 1순위 고, mecab-ko 는 사내 자산이 이미 있는 환경에서 유지하는 자리.
Nori 분석기 — 8.x 기본
Nori = Elastic 사가 Lucene 의 한국어 분석기 로 공식 채택한 기본 분석기예요. mecab-ko-dic 의 사전 데이터를 Lucene 인메모리 자료구조로 재가공해서 별도 프로세스 없이 JVM 안에서 형태소 분석이 돌게 만든 게 핵심이에요. 별도 데몬을 띄울 필요가 없어서 운영이 가볍고, Elasticsearch 와 같은 라이프사이클을 따라가서 플러그인 업데이트만 하면 끝 이에요.
설치는 한 줄.
# 단일 노드
$ bin/elasticsearch-plugin install analysis-nori
# Docker 컨테이너 안에서
$ docker exec -it es01 bin/elasticsearch-plugin install analysis-nori
# 설치 후 노드 재시작 필수
$ systemctl restart elasticsearch
설치 후 가장 먼저 해 볼 게 _analyze API 로 토큰을 직접 찍어 보는 일이에요. 인덱스 만들지 않고도 어떤 분석기가 어떻게 자르는지 즉시 확인할 수 있어요.
$ curl -X POST "localhost:9200/_analyze?pretty" -H 'Content-Type: application/json' -d '
{
"analyzer": "nori",
"text": "오늘 점심으로 가정용 공기청정기를 검색했어요"
}'
응답을 줄여 보면 토큰이 오늘 · 점심 · 가정 · 용 · 공기 · 청정기 · 검색 처럼 분해돼서 나와요. standard tokenizer 라면 오늘 · 점심으로 · 가정용 · 공기청정기를 · 검색했어요 같이 조사·어미가 붙은 어절 로만 잡혔을 거예요. 차이가 명확해요.
인덱스를 만들 때 분석기를 지정하면 이렇게.
$ curl -X PUT "localhost:9200/products" -H 'Content-Type: application/json' -d '
{
"settings": {
"analysis": {
"analyzer": {
"ko_analyzer": {
"type": "nori"
}
}
}
},
"mappings": {
"properties": {
"name": { "type": "text", "analyzer": "ko_analyzer" },
"name_raw": { "type": "keyword" }
}
}
}'
상품명을 text + ko_analyzer 로 색인하면 풀텍스트 검색이 돌고, keyword 멀티필드를 같이 박아 두면 정확 일치 · 정렬 · 집계 가 동시에 가능해요. 8편(Mapping Deep) 에서 본 멀티필드 패턴을 한국어에 그대로 적용하는 자리.
Nori 세 가지 핵심 옵션
Nori 는 기본값이 대체로 무난 하지만, 운영에 들어가면 세 가지 옵션을 항상 만지게 돼요. decompound_mode · user_dictionary · stoptags. 셋이 한국어 검색 품질의 80% 를 결정해요.
decompound_mode 는 복합명사를 어떻게 분해할지 정해요. 세 가지 값이 있어요. none 은 분해 안 함 — "공기청정기" 가 한 덩어리. discard (기본값) 는 분해된 조각만 남기고 원본을 버려요 — "공기 · 청정기" 만 인덱스에 들어가요. mixed 는 원본 + 분해 조각 을 모두 색인 — "공기청정기 · 공기 · 청정기" 셋 다.
e-commerce 상품 검색은 mixed 가 거의 항상 정답이에요. 사용자가 "공기청정기" 로도 "공기" 로도 "청정기" 로도 검색해서 모두 잡혀야 하니까. 다만 mixed 는 인덱스 사이즈가 30~50% 늘어요. 로그 검색처럼 분해 조각 만 필요한 자리는 discard 로 시작해도 충분.
user_dictionary 는 Nori 가 모르는 단어 를 사전에 추가하는 자리예요. 브랜드명 · 신상품 · 인명 · 사내 코드명 같이 일반 형태소 사전에 없는 단어 가 들어오면 Nori 가 잘못 쪼개거나 의미를 잃어요. "카카오뱅크" 가 "카카 · 오 · 뱅크" 로 쪼개지는 사고가 한 예.
stoptags 는 불필요한 품사를 색인에서 빼는 필터예요. 한국어 형태소 태그(POS tag) 중 조사(JK) 나 어미(EP) 같은 건 검색에 의미가 없어서 인덱스에서 빼면 인덱스 크기 감소 + 검색 품질 향상 둘 다 잡혀요.
다 합치면 운영용 표준 셋업은 이렇게.
$ curl -X PUT "localhost:9200/products" -H 'Content-Type: application/json' -d '
{
"settings": {
"analysis": {
"tokenizer": {
"nori_user_dict": {
"type": "nori_tokenizer",
"decompound_mode": "mixed",
"user_dictionary": "userdict_ko.txt"
}
},
"filter": {
"ko_stop": {
"type": "nori_part_of_speech",
"stoptags": ["E", "IC", "J", "MAG", "MAJ", "MM", "SP", "SSC", "SSO", "SC", "SE", "XPN", "XSA", "XSN", "XSV", "UNA", "NA", "VSV"]
}
},
"analyzer": {
"ko_analyzer": {
"type": "custom",
"tokenizer": "nori_user_dict",
"filter": ["ko_stop", "lowercase", "nori_readingform"]
}
}
}
}
}'
이 한 덩어리가 한국 e-commerce 검색 인덱스의 표준 셋업 이에요. 거의 모든 한국 회사 인덱스 설정이 이 모양에서 한두 줄만 바꾼 형태로 굳어 있어요.
mecab-ko-ko-dic — 이전 표준
mecab-ko = 일본어 형태소 분석기 mecab 의 한국어 fork 예요. 2010년대 한국 회사에서 mecab-ko + mecab-ko-dic 조합이 표준이었고, Elasticsearch 환경에서는 seunjeon (mecab-ko 자바 포팅) 플러그인이 가장 많이 깔려 있었어요.
Nori 와의 큰 차이는 두 가지. 첫째, 별도 사전 데이터·바이너리가 필요해요. mecab-ko-dic 을 다운받아 컴파일 한 뒤 Elasticsearch 노드의 특정 경로에 배치해야 해서 설치·업데이트 비용 이 Nori 보다 커요. 둘째, 사전 갱신 주기와 품질이 GitHub 기여자 풀에 의존해요. mecab-ko-dic 의 마지막 메이저 업데이트는 2020년대 초반에서 멈춰서 신조어 · 신상품 · 신인물 반영이 약해요.
Elastic 사가 Nori 를 공식 채택한 게 2018년이고, 이후 Lucene · Elasticsearch 둘 다 Nori 위에서 신기능을 빌드 해 왔어요. 그 결과 2026년 현재 신규 한국 검색 시스템 = Nori 가 사실상 결정이고, mecab-ko 는 기존 인덱스 유지보수 자리만 남았어요. 이 글도 Nori 위주로 설명해요.
다만 mecab-ko 가 더 잘 자르는 자리 가 일부 존재해요. 사전을 직접 다 손본 회사가 "우리 도메인은 mecab-ko 가 더 정확해요" 라고 말하는 케이스 — 법률 문서 · 의학 용어 · 학술 논문 처럼 전문 용어 사전 을 깊게 가공한 자리. 신규로 시작하지 않는 한 Nori → mecab-ko 마이그레이션 은 거의 일어나지 않아요.
사용자 사전 — 도메인 단어 추가
사용자 사전이 한국어 검색 품질에 가장 직접적인 영향 을 줘요. Nori 의 기본 사전은 일반어 위주 라서, e-commerce 카탈로그의 브랜드 · 상품군 · 모델명 이나 콘텐츠 플랫폼의 작품명 · 인명 은 거의 다 잘못 잡혀요. 이걸 사용자 사전으로 메우는 자리.
파일 포맷은 plain text 한 줄에 단어 하나, 옵션으로 분해 형태 를 탭으로 붙여요.
# userdict_ko.txt — 사용자 사전 예시
카카오뱅크
토스뱅크
스타벅스
공기청정기
가정용공기청정기 가정 용 공기 청정기
다이슨V15
LG디오스
삼성비스포크
신촌역
연세대학교
한 줄짜리 항목은 "이 단어를 하나의 명사로 인식해라" 라는 뜻이에요. 탭으로 분해 형태가 붙은 줄은 "이 단어가 들어오면 이런 조각으로 분해해라" 라는 명령. 예를 들어 "가정용공기청정기" 는 한 덩어리로 잡으면서 동시에 "가정 · 용 · 공기 · 청정기" 로도 색인되도록 강제해요.
파일 배치는 각 노드의 config/analysis/ 경로 에 떨어뜨리고, 인덱스 설정의 user_dictionary 옵션 에 파일 이름만 적어요. 모든 노드에 같은 파일이 있어야 해서 Ansible · Helm chart 같은 구성 관리 도구로 배포하는 게 표준이에요.
중요 — 사용자 사전은 인덱스 생성 시점에 로딩돼요. 운영 중에 사전 파일만 바꾸면 기존 인덱스에는 반영이 안 됩니다. 재색인 (reindex) 을 하거나 인덱스 재오픈 까지 가야 적용돼요. 그래서 사용자 사전 갱신 + reindex 가 한 묶음으로 도는 주간 배치 작업 이 현업의 표준 패턴.
대안으로 8.x 에는 user_dictionary_rules 라는 옵션이 있어서 인덱스 설정에 사전 항목을 직접 넣을 수 도 있어요. 사전 파일을 배포할 수 없는 매니지드 클러스터 환경에서 자주 쓰는 우회로.
동의어 — 검색 품질의 마지막 한 방
사용자 사전이 "이 단어를 한 덩어리로 인식해라" 라면, 동의어 (synonym) 는 "이 단어들은 같은 의미다" 를 시스템에 알려주는 자리예요. 검색 품질에 압도적으로 큰 영향을 줘요.
예를 들어 "신촌" 으로 검색한 사용자가 "신촌역" 결과도 같이 보고 싶을 거예요. "애플" 로 검색하면 "Apple" 도, "아이폰" 으로 검색하면 "iPhone" 도 잡혀야 자연스럽고요. 이걸 형태소 분석기로 해결하려 하면 끝이 없어요 — 동의어 필터로 매핑을 거는 게 정답.
$ curl -X PUT "localhost:9200/products" -H 'Content-Type: application/json' -d '
{
"settings": {
"analysis": {
"filter": {
"ko_synonym": {
"type": "synonym_graph",
"synonyms": [
"신촌, 신촌역",
"애플, apple, 아이폰, iphone",
"스타벅스, starbucks, 스벅"
]
}
},
"analyzer": {
"ko_analyzer": {
"type": "custom",
"tokenizer": "nori_user_dict",
"filter": ["ko_stop", "lowercase", "ko_synonym"]
}
}
}
}
}'
동의어 필터에는 두 종류가 있어요. synonym 은 단순 치환 — "애플" 이 들어오면 "apple" 로 바꿔요. synonym_graph 는 멀티토큰 동의어 까지 처리 — "뉴욕 양키스" 를 "NYY" 로 매핑 같은 식이 가능. 운영 환경에서는 synonym_graph 를 기본으로 권장.
동의어 적용 시점도 결정이 필요해요. 색인 시점 에 적용하면 빠르지만 동의어 갱신 시 재색인 이 필요해요. 검색 시점 (search_analyzer) 에만 적용하면 사전 갱신이 가볍지만 매 쿼리마다 분석 오버헤드 가 발생. 작은 사전(< 1만 항목)은 검색 시점, 큰 사전·정밀 매칭은 색인 시점 이 일반 가이드.
자모 분리·오타 허용
한국어 검색의 마지막 난관이 오타 예요. "공기청정기" 를 "공기철정기" 로 잘못 쳤을 때도 결과가 나와야 진짜 사용자 친화적인 검색. 영어권 Levenshtein 거리 기반 fuzzy query 는 한국어에 그대로 쓰면 잘 안 맞아요. 완성형 한글 한 글자가 바뀌면 사실 자모 3개 중 1개만 바뀐 셈 인데 fuzzy 가 글자 단위 로만 거리를 잰다는 게 문제.
해법이 자모 분리 (jamo decomposition). "공기" 를 "ㄱㅗㅇㄱㅣ" 처럼 자모 시퀀스로 펼친 뒤 fuzzy query 를 자모 단위로 걸어요. "공기철정기" 와 "공기청정기" 가 자모 시퀀스 거리 1 로 잡혀서 fuzzy 가 깔끔하게 매칭.
$ curl -X PUT "localhost:9200/products" -H 'Content-Type: application/json' -d '
{
"settings": {
"analysis": {
"tokenizer": {
"jamo_tokenizer": {
"type": "hangul_jamo"
}
},
"analyzer": {
"jamo_analyzer": {
"type": "custom",
"tokenizer": "jamo_tokenizer",
"filter": ["lowercase"]
}
}
}
}
}'
자모 분리 토큰화는 별도 플러그인 (예 — analysis-hangul-jamo) 으로 깔거나, custom tokenizer 를 직접 구현하는 자리예요. 기본 Nori 에는 들어 있지 않아요.
운영 환경에서는 멀티필드 패턴이 표준 — 원본 필드 (Nori 분석) 와 자모 필드 (오타 허용) 를 같이 두고, 검색 시 should 절 로 둘 다 묶어서 점수가 더 높은 쪽이 이기게 합니다. 오타 없는 검색 은 Nori 필드에서 1.0 점수로, 오타 있는 검색 은 자모 필드에서 0.6 점수로 — 이런 식으로 자연스럽게 fall-through.
자주 만나는 사고
사고 1 — Nori 플러그인 미설치
증상 — 인덱스를 만들 때 Unknown analyzer [nori] 에러가 떨어져요. 또는 인덱스는 만들어졌는데 한글 검색이 standard tokenizer 처럼 어절 단위로만 잡혀 요.
해결 — bin/elasticsearch-plugin install analysis-nori 를 모든 노드 에서 실행하고 노드 재시작. 한 노드라도 빠지면 그 노드에 떨어진 샤드만 분석이 안 돌아서 간헐적 검색 결과 누락 사고로 이어져요. _cat/plugins?v 로 모든 노드에 깔렸는지 반드시 확인.
사고 2 — 사용자 사전 reload 안 함
증상 — 사용자 사전에 "카카오뱅크" 를 추가했는데 검색이 여전히 "카카 · 오 · 뱅크" 로 잡혀요.
해결 — 사용자 사전은 인덱스 생성 시 로딩 이라 기존 인덱스에는 자동 반영 X. _close → 설정 변경 → _open 사이클을 돌리거나, reindex 로 새 인덱스를 만들어 alias 를 갈아끼우는 게 표준. 매니지드 환경에서는 재오픈 이 막혀 있는 경우가 많아서 reindex 가 사실상 유일 한 자리.
사고 3 — decompound_mode 잘못 잡음
증상 — e-commerce 상품 검색 인데 "공기청정기" 로 검색하면 잡히는데 "청정기" 로 검색하면 안 잡혀요. 또는 그 반대.
해결 — decompound_mode: discard (기본값) 는 분해 조각만 색인 해서 복합명사 원본 검색 이 안 됩니다. mixed 로 바꾸면 원본 + 조각 둘 다 들어가서 양쪽 검색이 모두 잡혀요. e-commerce 는 mixed 가 거의 항상 정답.
사고 4 — standard 로 색인 후 한글 검색 망함
증상 — 인덱스 생성 시 analyzer 옵션을 빠뜨려서 기본값 standard 로 색인됐는데, 한글 검색 결과가 조사 붙은 어절 단위 로만 잡혀요. "신촌" 검색에 "신촌에서" 가 안 나와요.
해결 — Mapping 은 한 번 박으면 변경 불가 라 reindex 가 유일. 새 인덱스 + Nori analyzer + alias swap 으로 가야 해요. 5편(Index 관리) 의 reindex + alias 패턴 을 그대로 적용.
사고 5 — 자모 분리 미적용
증상 — 오타 허용 검색 을 만들었는데 "공기철정기" 같은 한 글자 오타가 잡히지 않아요. fuzzy query 의 fuzziness: 2 를 줘도 똑같이 안 잡힘.
해결 — 완성형 한글 한 글자 차이는 fuzzy 의 글자 거리 기준에서 거리 1 또는 2 로 계산되지만, 자모 단위로는 거리가 더 작거나 큼 이라 매칭이 어긋나요. 자모 분리 멀티필드 + fuzzy query 조합으로만 안정적으로 잡혀요.
사고 6 — 사용자 사전이 너무 큼
증상 — 사용자 사전이 10만 줄 이 넘어가니까 인덱스 open 시간이 분 단위 로 길어지고, 클러스터 restart 가 느려져요.
해결 — 사용자 사전은 JVM 메모리 안에 다 로딩 되는 자료구조라 큰 사전 = 큰 메모리 + 긴 로딩 시간. 진짜 도메인 단어만 추리고, 일반어 (이미 Nori 가 잘 자르는 단어) 는 사전에서 빼요. 5천~1만 항목 이 거친 상한선.
사고 7 — 동의어 갱신 시 무중단 X
증상 — 동의어 사전을 바꿨는데 운영 인덱스가 그대로라 새 동의어가 안 먹혀요. _close → reload → _open 을 시도하면 다운타임이 발생.
해결 — Elasticsearch 7.3+ 부터는 reloadable: true 옵션이 synonym_graph 필터 에 추가돼서 _reload_search_analyzers API 로 무중단 갱신 이 가능해요. 단, 검색 시점 분석 (search_analyzer) 에만 적용되고 색인 분석 에는 안 됩니다. 색인 분석에 들어간 동의어는 여전히 reindex 필요.
운영 권장 패턴
운영 환경에서 한국어 검색 인덱스 를 새로 설계할 때 따라가면 안전한 표준 패턴 다섯 가지.
(1) Nori + decompound_mode mixed 가 기본 — e-commerce · 콘텐츠 · 사내 위키 거의 모든 한국어 풀텍스트 검색은 type: nori_tokenizer + decompound_mode: mixed 셋업으로 시작해요. 인덱스 사이즈 30~50% 가 늘지만 검색 품질 의 이득이 압도적.
(2) 사용자 사전은 작게, 자주 갱신 — 사전을 수만 줄 까지 키우지 말고 진짜 도메인 단어 5천~1만 으로 추리고, 주간 reindex 배치 로 신규 단어를 반영해요. 사용자 사전이 클수록 open 시간 · 메모리 비용이 커져서 작고 자주 가 안전.
(3) 동의어는 검색 시점 적용 + reloadable — 동의어 사전 갱신이 주 단위 로 일어나면 search_analyzer 의 reloadable synonym_graph 로 무중단 갱신을 가져가요. 갱신 빈도가 월 단위 이하 면 색인 시점에 박아도 OK.
(4) 멀티필드로 정확 검색 + 풀텍스트 + 자모 셋 제공 — 같은 상품명을 name (Nori 분석) + name.keyword (정확 일치 · 정렬 · 집계) + name.jamo (자모 분리, 오타 허용) 셋으로 색인. 검색 시점에 multi_match 로 셋을 묶어요.
(5) _analyze API 로 매번 검증 — 새 인덱스 설정을 적용하기 전에 반드시 _analyze 로 대표 쿼리 10~20개 의 토큰을 찍어 보고 예상한 분해 결과 가 나오는지 확인하는 게 표준 워크플로. 이 단계를 빼면 3개월 뒤 검색 품질 사고 가 거의 100% 터집니다.
시험 직전 한 번 더 — 압축 노트
- 한국어 골치 = 띄어쓰기 약속 약함 · 조사·어미 어절 부착 · 복합명사 흔함. standard tokenizer 만으로 실무 불가.
- Nori = Elastic 공식 한국어 분석기 (2018+). mecab-ko-dic 사전을 Lucene 인메모리로 재가공. 별도 데몬 X.
- 설치 =
bin/elasticsearch-plugin install analysis-nori+ 모든 노드 재시작. - decompound_mode = none (분해 X) · discard (조각만, 기본값) · mixed (원본+조각). e-commerce → mixed 표준.
- user_dictionary = 도메인 단어 (브랜드 · 상품 · 인명) 사전. 한 줄 = 한 단어, 탭 = 분해 형태. 인덱스 open 시 로딩 — 갱신 후 reindex 필요.
- stoptags = 조사 · 어미 · 부호 등 검색에 무의미한 품사 필터. 인덱스 크기·검색 품질 동시 개선.
- mecab-ko = 일본어 mecab 의 한국어 fork. 2010년대 표준 이었으나 2026 신규는 Nori 1순위. 기존 자산 유지 자리만 남음.
- 동의어 =
synonym_graph필터 + 매핑 "신촌, 신촌역" 또는 "애플, apple, 아이폰". 7.3+reloadable: true무중단 갱신. - 자모 분리 = Hangul Jamo Tokenizer + fuzzy query. 한 글자 한국어 오타 허용의 사실상 유일 해법. 별도 플러그인 또는 custom 구현.
- 7대 사고 = 플러그인 미설치 · 사용자 사전 reload 안 함 · decompound_mode 잘못 · standard 로 색인 · 자모 분리 미적용 · 사용자 사전 비대 · 동의어 무중단 X.
- 운영 표준 = Nori + mixed + 작은 사전 + 멀티필드 (Nori · keyword · jamo) +
_analyze검증. - 인덱스 한 번 박으면 분석기 변경 = reindex 필수. alias swap 패턴이 사실상 유일한 안전한 길.
시리즈 다른 편
- 이전 글 = 10편 Analyzer 기본 — Character Filter · Tokenizer · Token Filter 3단계 파이프
- 다음 글 = 12편 Search API 기본 — Query DSL · URI Search · Filter Context
- 8편 = Mapping Deep — Static·Dynamic·Multi-field·Runtime
- 9편 = Field Types — text · keyword · numeric · date · object · nested
- 13편 = Full-text 쿼리 — match · multi_match · query_string · match_phrase
- 14편 = Term-level 쿼리 — term · terms · range · exists · wildcard
- 19편 = Search Features — highlight · sort · paging · search_after
- 21편 = Vector Search — dense_vector · kNN · semantic search
- 32편 = Spring Data Elasticsearch — Repository · Template · @Document
한 줄 정리 — Elasticsearch 한국어 검색 = Nori + decompound_mode mixed + 작은 사용자 사전 + reloadable 동의어 + 자모 분리 멀티필드. 인덱스 한 번 박으면 분석기 변경이 reindex 필수 라, 초기 설계가 검색 품질을 결정해요.