Elasticsearch 입문 10편 Analyzer Deep. Char Filter·Tokenizer·Token Filter 3단계·Built-in·Custom·_analyze.
이 글은 Elasticsearch 입문에서 운영까지 시리즈 38편 중 10편이에요. 9편(Field Types) 까지 mapping 을 잡았다면, 이제 한 발 더 들어가서 text 필드가 색인될 때 실제로 어떤 변환을 거치는가 를 풀어 봅니다. 검색 품질의 90% 가 여기서 갈려요 — mapping 이 그릇 이라면, analyzer 는 그 그릇에 무엇을 담을지 정하는 손 이에요.
한국어 처리(Nori·mecab-ko·사용자 사전) 는 다음 편인 11편(Korean Analyzer) 에서 별도로 깊이 들어갑니다. 이 글은 언어 중립적인 analyzer 구조 자체 에 집중해요.
이 글은 Elasticsearch 8.x 공식 docs 의 Text analysis 챕터를 한국어 학습 노트로 풀어쓴 자료예요.
본문에 나오는 모든 JSON 예시는 POST _analyze 또는 PUT /index_name 로 직접 던져 볼 수 있어요. 한 번이라도 실행해 보면 분석 결과가 머리에 훨씬 잘 박혀요.
왜 analyzer 가 검색 품질을 결정하는가
처음 Elasticsearch 를 만지면 "색인이 잘 됐는데 검색이 왜 안 잡히지" 하는 순간이 거의 항상 와요. 색인된 문서 본문에는 분명히 "애플 아이폰 15 Pro Max" 가 들어 있는데, "아이폰" 으로 검색하면 잡히고 "아이폰 프로맥스" 로는 안 잡혀요. 이 차이가 전부 analyzer 에서 나와요.
Elasticsearch 의 풀텍스트 검색은 문자열 그대로 비교 가 아니에요. 색인할 때 본문을 토큰(token) 단위로 쪼개서 역색인(inverted index) 에 저장해 두고, 검색할 때도 똑같이 토큰 으로 쪼개서 매칭해요. 즉 어떤 규칙으로 쪼개느냐 가 무엇이 검색되느냐 와 동의어예요.
여기서 흔히 빠지는 함정 하나 — 색인 시 analyzer 와 검색 시 analyzer 는 같아야 한다는 점이에요. 둘이 다르면 색인엔 iphone 으로 들어갔는데 검색은 iPhone 으로 찾는 사태가 벌어져요. 9편에서 다룬 text vs keyword 의 분기도 결국 이 토큰 단위로 쪼개느냐 vs 통째로 두느냐 의 분기예요.
Analyzer 의 3단계 구조
Elasticsearch 의 모든 analyzer 는 정확히 세 단계 로 작동해요. 외워 두면 디버깅이 훨씬 빨라져요.
원문 텍스트
│
▼
┌─────────────────────┐
│ Character Filter │ ← 0개 이상, 문자 수준 전처리
│ (선택, 다중 가능) │ 예: HTML 태그 제거
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Tokenizer │ ← 정확히 1개, 토큰으로 쪼개기
│ (필수, 단일) │ 예: 공백·구두점 기준 분리
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Token Filter │ ← 0개 이상, 토큰 수준 후처리
│ (선택, 다중 가능) │ 예: 소문자화·불용어 제거
└─────────────────────┘
│
▼
역색인에 저장될 토큰 리스트
각 단계의 역할을 한 줄씩 정리하면 — Character Filter 는 토크나이즈 전에 원문 문자열 자체를 손봅니다(예: HTML 태그를 떼내거나 "&" 을 "and" 로 치환). Tokenizer 는 문자열을 토큰 배열로 쪼개요(예: 공백·구두점·이모지 경계). Token Filter 는 쪼개진 토큰 각각을 변환·삭제·추가합니다(예: 소문자화·불용어 제거·동의어 확장).
핵심 규칙 두 개 — Character Filter 와 Token Filter 는 0개 이상 여러 개 chain 으로 걸 수 있고, Tokenizer 는 정확히 한 개 만 와요. 한 analyzer 안에 tokenizer 가 두 개일 수 없어요.
Built-in Analyzers — 자주 만나는 7가지
Elasticsearch 가 기본 제공하는 analyzer 부터 짚어요. 대부분의 입문자 첫 실수 는 "standard 만 쓰는 것" 인데, 어떤 자리에 어떤 빌트인이 어울리는지 알아야 custom 으로 갈지 빌트인으로 갈지 가 잡혀요.
| Analyzer | 동작 | 자주 어울리는 자리 |
|---|---|---|
| standard | Unicode 텍스트 분할 + 소문자화 + 일부 구두점 제거 | 영어·다국어 일반 텍스트의 기본값 |
| simple | 비문자(non-letter) 기준 분할 + 소문자화 | 숫자·구두점이 없는 단순 텍스트 |
| whitespace | 공백 기준 만 분할, 소문자화 X | 로그처럼 원문 보존 이 중요한 자리 |
| stop | simple + 영어 불용어(the·a·is) 제거 | 영어 짧은 텍스트의 검색 정확도 향상 |
| keyword | 분할하지 않고 통째로 한 토큰 | 카테고리·태그·ID 처럼 정확 매칭 자리 |
| pattern | 정규식 기준 분할(기본 \W+), 소문자화 |
특수 구분자 기반 텍스트 |
| language | 언어별 형태소·불용어·stemming 묶음(english · french · german · spanish · korean…) | 단일 언어 본격 풀텍스트 |
여기서 language analyzer 의 korean 옵션은 실용성이 낮아서 한국어 환경에선 거의 안 써요. 한국어는 11편에서 다룰 Nori 가 사실상 표준이에요.
standard 가 기본값 인 이유는 예측 가능한 동작 + 대부분의 라틴 문자 자리에서 무난 한 안전한 선택이기 때문이에요. 하지만 "이걸로 모든 풀텍스트가 잡힌다" 는 착각이 입문자 사고 1번 으로 직결돼요(아래 사고 섹션 참고).
Character Filter — 문자 수준 전처리
Character Filter 는 Tokenizer 가 보기 전에 원문 문자열을 한 번 손보는 자리예요. 빌트인은 세 가지.
html_strip 은 HTML 태그를 모두 떼내요. <p>hello <b>world</b></p> 를 hello world 로 바꿔 줘요. 블로그·CMS 본문을 색인할 때 표준 패턴.
POST _analyze
{
"char_filter": ["html_strip"],
"tokenizer": "standard",
"text": "<p>I'm so <b>happy</b>!</p>"
}
결과 토큰은 ["i'm", "so", "happy"] 가 돼요. HTML entity(' → ') 도 같이 풀어 줘요.
mapping 은 문자·문자열 → 문자·문자열 1:1 치환이에요. "& → and" 같은 도메인 룰을 박을 때 씁니다.
PUT /demo
{
"settings": {
"analysis": {
"char_filter": {
"amp_filter": {
"type": "mapping",
"mappings": ["& => and", "$ => dollar"]
}
}
}
}
}
pattern_replace 는 정규식 기반 치환이에요. 전화번호 하이픈 제거, URL 도메인 추출 같은 자리에 적합.
{
"type": "pattern_replace",
"pattern": "(\\d{3})-(\\d{4})-(\\d{4})",
"replacement": "$1$2$3"
}
세 가지 모두 복수 chain 이 가능해요 — ["html_strip", "amp_filter"] 처럼 배열로 주면 순서대로 실행돼요. 순서가 결과를 바꾸니까 주의.
Tokenizer — 토큰으로 쪼개기
Tokenizer 는 analyzer 의 심장 이에요. 정확히 한 개만 올 수 있고, 나머지 단계는 생략 가능 하지만 tokenizer 는 반드시 있어야 해요.
자주 만나는 빌트인 일곱 가지.
standard 는 Unicode Text Segmentation (UAX #29) 알고리즘 기준으로 자연어 경계를 잡아요. 대부분의 라틴·CJK 텍스트에서 어절 단위 로 적절히 쪼개 줘요. 기본 tokenizer.
whitespace 는 공백만 기준이에요. "hello,world" 를 standard 는 ["hello", "world"] 로 쪼개지만, whitespace 는 ["hello,world"] 한 토큰으로 둬요.
letter 는 문자(letter) 가 아닌 모든 것 을 경계로 봐요. 숫자·구두점이 모두 분리 기준이 돼요.
ngram 은 문자 단위 n-gram 으로 쪼개요. "abc" 를 min_gram=2, max_gram=3 으로 돌리면 ["ab", "bc", "abc"] 가 나와요. 부분 일치(contains 검색) 자리에 자주 쓰지만 인덱스 크기 폭증 위험.
edge_ngram 은 앞쪽 prefix 만 n-gram. "iphone" 을 min=2, max=5 로 돌리면 ["ip", "iph", "ipho", "iphon"] 이 나와요. 자동완성(autocomplete) 의 정석 자리.
PUT /products
{
"settings": {
"analysis": {
"tokenizer": {
"autocomplete_tokenizer": {
"type": "edge_ngram",
"min_gram": 2,
"max_gram": 10,
"token_chars": ["letter", "digit"]
}
}
}
}
}
pattern 은 정규식 경계 기반. 기본은 \W+ (단어 문자 아닌 것) 라서 사실상 standard 와 비슷하지만, pattern: "," 식으로 CSV 라인을 쪼개 거나 도메인 구분자에 맞춰 커스텀 가능.
path_hierarchy 는 경로 토큰화에 특화돼 있어요. /usr/local/bin 을 ["/usr", "/usr/local", "/usr/local/bin"] 으로 쪼개 줘요. 디렉터리 구조·카테고리 hierarchy 검색에 적합.
Token Filter — 토큰 후처리
Token Filter 는 Tokenizer 가 쪼개 놓은 토큰 을 받아서 변환·삭제·추가 해요. 빌트인이 가장 많은 단계인데, 자주 쓰는 여섯 가지만 추려요.
lowercase — 모든 토큰을 소문자로. "iPhone" → "iphone". 사실상 모든 영어 풀텍스트 자리에 기본으로 들어가요.
stop — 불용어 제거. 영어 기본 stoplist 는 a · an · the · is · of · in · and …. 검색 정확도와 인덱스 크기 둘 다 잡아 줘요.
{
"type": "stop",
"stopwords": "_english_"
}
stemmer — 어간 추출. running · runs · ran 을 모두 run 으로 정규화. 영어는 porter · porter2 · english · light_english 등 강도가 다른 옵션이 여러 개 있어요. 가벼울수록 정확, 강할수록 재현율 높음.
synonym — 동의어 확장. "iPhone, 아이폰" 같은 표를 박아 두면 "iPhone" 색인 시 "아이폰" 도 같은 자리에 들어가요. index time 에 박을지 query time 에 풀지의 선택이 운영 함정(아래 사고 3 참고).
{
"type": "synonym",
"synonyms": [
"iphone, 아이폰, 애플폰",
"tv, 텔레비전, 티비"
]
}
asciifolding — é · ü · ñ 같은 문자를 e · u · n 으로 정규화. 다국어 검색에서 "café" 와 "cafe" 를 같이 잡아야 하는 자리에 필수.
ngram (filter 버전) — Tokenizer 의 ngram 과 비슷하지만 이미 쪼개진 토큰을 다시 n-gram 으로 자르는 자리예요. 예를 들어 standard 로 어절을 쪼갠 뒤 각 어절을 다시 부분 일치용 n-gram 으로 더 잘게 자르고 싶을 때.
이 외에도 length · reverse · trim · truncate · unique · phonetic 같은 빌트인이 수십 개 있어요. 자주 쓰는 셋만 외우고 나머진 docs 검색.
Custom Analyzer 만들기
빌트인으로 부족하면 Custom Analyzer 를 만들어요. settings.analysis 에서 char_filter · tokenizer · filter 를 조립하면 됩니다. 실제 예시 — HTML 본문 + 영문 + 동의어 가 섞인 e-commerce 상품 설명.
PUT /products
{
"settings": {
"analysis": {
"char_filter": {
"amp_to_and": {
"type": "mapping",
"mappings": ["& => and"]
}
},
"filter": {
"english_stop": {
"type": "stop",
"stopwords": "_english_"
},
"english_stemmer": {
"type": "stemmer",
"language": "english"
},
"product_synonyms": {
"type": "synonym",
"synonyms": [
"tv, television",
"phone, smartphone, mobile"
]
}
},
"analyzer": {
"product_text": {
"type": "custom",
"char_filter": ["html_strip", "amp_to_and"],
"tokenizer": "standard",
"filter": [
"lowercase",
"english_stop",
"english_stemmer",
"product_synonyms"
]
}
}
}
},
"mappings": {
"properties": {
"description": {
"type": "text",
"analyzer": "product_text"
}
}
}
}
조립 순서를 풀어 보면 — 원문 HTML → html_strip 으로 태그 제거 → & 를 and 로 치환 → standard 로 토큰화 → 소문자화 → 영어 불용어 제거 → 어간 추출 → 동의어 확장. 각 단계가 일렬로 흐른다 는 감만 잡으면 어떤 조합이든 같은 패턴 으로 만들 수 있어요.
_analyze API — 디버깅 도구
custom analyzer 를 만들었으면 반드시 검증해야 해요. _analyze API 가 그 자리예요 — 임의 텍스트를 analyzer 에 통과시켜 결과 토큰을 돌려줘요.
가장 단순한 호출.
POST _analyze
{
"analyzer": "standard",
"text": "The quick brown fox"
}
응답.
{
"tokens": [
{"token": "the", "start_offset": 0, "end_offset": 3, "position": 0},
{"token": "quick", "start_offset": 4, "end_offset": 9, "position": 1},
{"token": "brown", "start_offset": 10, "end_offset": 15, "position": 2},
{"token": "fox", "start_offset": 16, "end_offset": 19, "position": 3}
]
}
이미 만든 인덱스의 custom analyzer 를 테스트할 땐 인덱스를 명시해요.
POST /products/_analyze
{
"analyzer": "product_text",
"text": "<p>Apple iPhone & Pro Max</p>"
}
특정 필드 의 analyzer 를 그대로 쓰고 싶으면 field 파라미터.
POST /products/_analyze
{
"field": "description",
"text": "running quickly"
}
그리고 가장 강력한 자리 — char_filter · tokenizer · filter 를 한 번씩 즉석 조립 해서 결과를 보는 디버깅 모드.
POST _analyze
{
"char_filter": ["html_strip"],
"tokenizer": "standard",
"filter": ["lowercase", "stop"],
"text": "<p>The quick brown fox</p>",
"explain": true
}
explain: true 를 주면 각 단계별로 어떤 토큰이 살아남고 어떤 변환이 일어났는지 를 단계별로 보여 줘요. "왜 내 검색이 안 잡히지" 가 90% 여기서 풀려요.
디버깅 패턴 한 줄 요약 — 검색이 이상하면 (1) 색인 시 analyzer 결과 와 (2) 검색 시 analyzer 결과 를 _analyze 로 각각 뽑아서 토큰 리스트를 눈으로 비교 해요. 거의 모든 분석 사고가 이 한 줄에서 해결돼요.
자주 만나는 사고
사고 1 — Index time vs Query time analyzer 불일치
원인 — 매핑에 analyzer 만 지정하고 search_analyzer 를 따로 지정했는데, 둘 사이 토큰 규칙이 달라서 색인된 토큰 과 검색 토큰 이 매칭이 안 돼요. 흔한 패턴 — edge_ngram 으로 색인 했는데 검색에선 standard 로 검색 → 자동완성이 원본 단어를 통째로 찾으니 안 잡혀요.
해결 — 자동완성처럼 비대칭이 필요한 자리 만 의도적으로 둘을 다르게 두고, 나머지는 기본 정책으로 단일 analyzer 만 둬요. 명시하지 않으면 검색 analyzer = 색인 analyzer. _analyze 로 색인·검색 양쪽을 각각 돌려서 토큰 리스트를 눈으로 비교 하는 게 표준 디버깅.
사고 2 — standard 만 쓰고 풀텍스트 검색 망함
원인 — 모든 text 필드를 기본 standard 로 두면 어간·동의어·불용어 가 처리되지 않아요. "running" 으로 색인되고 "run" 으로 검색하면 안 잡혀요. 영어조차 이 자리에서 사고가 나요.
해결 — 풀텍스트 자리는 language analyzer (예: english) 또는 standard + stemmer + stop + synonym 의 custom analyzer 를 깔아요. 한국어는 11편의 Nori 기반 custom 으로 갑니다.
사고 3 — Synonym 을 index time 에 박아서 못 빼는 사고
원인 — synonym 을 색인 시점 token filter 로 박으면, 동의어 표를 바꿀 때마다 전체 재색인 이 필요해요. 운영 중에 동의어 한 줄 바꾸는 데 며칠짜리 reindex 가 따라오는 사고.
해결 — 동의어는 search-time 으로 두는 게 표준이에요. synonym_graph token filter 를 search_analyzer 에 박고, 색인은 동의어 없이 둬요. 동의어 표가 바뀌어도 settings 만 update 하면 끝. 예외 — 동의어가 극단적으로 정적 이고 검색 응답 ms 가 결정적 인 자리만 index-time.
사고 4 — edge_ngram 폭주
원인 — 자동완성용으로 min_gram=1, max_gram=20 식으로 너무 넓게 잡으면 한 단어가 토큰 수십 개 로 폭증해서 인덱스 크기 3~5배, 색인 속도 절반. 디스크 풀 사고로 직결.
해결 — min_gram=2~3, max_gram=10 안쪽으로 좁히고, prefix 가 의미 있는 자리 (상품명·이름) 에만 적용해요. 본문 같은 긴 텍스트엔 edge_ngram 을 절대 박지 않아요. 자동완성은 별도 필드(multi-field) 로 분리하는 게 정석.
사고 5 — html_strip 빠뜨려서 태그가 토큰으로 들어감
원인 — CMS·블로그 본문을 char_filter 없이 색인하면 <p> · </div> · 같은 HTML 잔재가 토큰으로 들어가요. 검색 결과에 이상한 매칭이 섞이고 집계(aggregation) 도 더러워져요.
해결 — HTML 이 섞일 수 있는 모든 text 필드에 char_filter: ["html_strip"] 을 기본으로 깔아요. 운영 초기에 한 번 더 점검 하는 항목.
사고 6 — lowercase 빠뜨려서 대소문자 사고
원인 — whitespace tokenizer 만 쓰고 lowercase token filter 를 빠뜨리면 "iPhone" 으로 색인되고 "iphone" 으로 검색하면 매칭이 안 돼요. standard analyzer 는 lowercase 가 내장 이라 잘 모르고 지나가요.
해결 — custom analyzer 를 만들 때 lowercase 는 거의 항상 첫 token filter 로 박는 게 표준이에요. 일부러 대소문자 구분이 필요한 자리(코드명·약어) 만 예외.
사고 7 — Stop word 가 검색까지 잡아먹는 사고
원인 — the · a · is 같은 영어 불용어를 빼면 검색 품질이 좋아지는데, 하필 "the who" (밴드 이름) 같은 불용어만으로 이루어진 검색어 가 빈 결과로 나와요. 사용자는 "검색이 망가졌다" 고 인지.
해결 — 불용어는 기본 stoplist 를 그대로 쓰지 말고 도메인에 맞춰 맞춤 stoplist 를 만들어요. 또는 고유명사 필드 에는 stop 을 빼고, 본문 필드 에만 stop 을 깔아요. multi-field 로 분리하는 게 정석.
운영 권장 패턴 4가지
운영에서 analyzer 사고가 거의 사라지는 얇은 표준 네 가지.
첫째, 모든 text 필드는 명시적 analyzer. 기본값에 의존하지 않아요. "analyzer": "..." 를 매핑에 박아 두면 나중에 보는 사람 이 의도 를 바로 읽어요.
둘째, custom analyzer 는 _analyze 로 검증 후 운영 투입. 정의만 하고 운영에 박으면 수만 건 색인 후 사고를 발견해요. 정의 직후 대표 샘플 10건 으로 _analyze 를 돌려 토큰 리스트를 사람 눈으로 확인 하는 한 단계.
셋째, 자동완성·정확 매칭·풀텍스트 는 multi-field 로 분리. 한 필드를 세 가지로 동시에 쓰지 않아요 — description (풀텍스트), description.autocomplete (edge_ngram), description.raw (keyword) 셋으로 가는 게 정석. 9편(Field Types) 의 multi-field 패턴이 여기서 빛을 발해요.
넷째, 동의어 표는 별도 파일로. synonyms 배열을 인라인으로 박지 말고 config/analysis/synonyms.txt 파일 또는 synonym set API 로 분리해요. 운영 중 동의어 표만 갈아끼울 수 있도록 settings update + close/open index 로 한 번에.
시험 직전 한 번 더 — 압축 노트
- Analyzer 3단계 = Character Filter → Tokenizer → Token Filter
- Character Filter · Token Filter = 0개 이상 chain, Tokenizer = 정확히 1개
- Built-in Analyzers: standard · simple · whitespace · stop · keyword · pattern · language
- standard = Unicode 분할 + 소문자화, 기본값. 풀텍스트엔 부족.
- Character Filter: html_strip · mapping · pattern_replace
- Tokenizer: standard · whitespace · letter · ngram · edge_ngram · pattern · path_hierarchy
- Token Filter: lowercase · stop · stemmer · synonym · asciifolding · ngram
- edge_ngram = 앞쪽 prefix n-gram, 자동완성의 정석. 범위 좁게.
- synonym = 동의어 확장, search-time 이 표준 (index-time 은 재색인 비용 큼)
- Custom Analyzer =
settings.analysis에서 char_filter · tokenizer · filter 조립 - _analyze API = 결과 토큰 검증,
explain: true면 단계별 추적 - 디버깅 한 줄 — 색인 시 토큰 vs 검색 시 토큰 을
_analyze로 각각 뽑아 비교 - 7대 사고: index/query analyzer 불일치 · standard 만 사용 · synonym index-time · edge_ngram 폭주 · html_strip 누락 · lowercase 누락 · stop 과잉
- 운영 4표준: 명시적 analyzer · _analyze 검증 · multi-field 분리 · 동의어 외부 파일
- 한국어는 다음 편 11편(Nori·mecab-ko·사용자 사전) 에서.
시리즈 다른 편
- 이전 글 = 9편 Field Types — text·keyword·numeric·date·boolean·object·nested·dense_vector
- 다음 글 = 11편 Korean Analyzer — Nori·mecab-ko·사용자 사전
- 5편 = Index 관리 — Create·Settings·Alias·Reindex
- 7편 = Document CRUD — Index·Get·Update·Delete·Bulk
- 8편 = Mapping Deep — Static·Dynamic·Multi-field·Runtime
- 13편 = Full-text 쿼리 — match·match_phrase·multi_match
- 19편 = Search Features — search_after·scroll·highlight
- 32편 = Spring Data Elasticsearch — Repository·Template·POJO
한 줄 정리 — Analyzer = Character Filter → Tokenizer → Token Filter 3단계 파이프라인. 어떻게 쪼개느냐 가 무엇이 검색되느냐 와 동의어라서, standard 만 쓰지 말고 custom 으로 도메인에 맞춰 조립하고, 반드시 _analyze 로 검증 후 운영 투입.