[ELK Deep Dive] Lucene — 텍스트 분석
PART 1. Lucene과 Elasticsearch의 관계
1. Lucene이란 무엇인가
PART 2. 텍스트 분석의 구조
2. Analyzer의 3단계 파이프라인
3. 내장 Analyzer 비교
PART 3. 필드 타입과 Analyzer 전략
4. text vs keyword -- 필드 타입과 Analyzer의 관계
5. 한국어 형태소 분석: Nori Analyzer
6. 고급 패턴: Index Analyzer vs Search Analyzer
2편에서 Shard 안에 Segment가 있고, Segment가 역인덱스를 품고 있다는 것까지 살펴봤습니다. 그런데 이 Segment를 실제로 만들고 검색하는 주체는 누구일까요? Elasticsearch 자체가 아닙니다. 그 아래에서 묵묵히 일하는 Apache Lucene입니다.
Lucene은 Java로 작성된 전문 검색(Full-text Search) 라이브러리입니다. 라이브러리라는 점이 중요합니다. Lucene은 독립적으로 실행되는 서버가 아니라, 다른 애플리케이션이 가져다 쓰는 .jar 파일입니다.
Elasticsearch는 이 Lucene을 Shard당 하나씩 내장하고, 그 위에 분산/복제/REST API 레이어를 얹은 것입니다.
Shard 하나 = Lucene Index 하나
2편에서 "Shard 안에 여러 Segment가 있다"고 했는데, 정확히 말하면 Shard 안의 Lucene Index가 여러 Segment를 관리하는 것입니다.
Elasticsearch와 Lucene의 경계를 명확히 해두겠습니다. 각자 하는 일이 다릅니다.
| 계층 | 담당 영역 |
|---|---|
| Elasticsearch | 클러스터 관리, Shard 라우팅, 복제, REST API, 분산 검색 조율, ILM |
| Lucene | Analyzer로 텍스트 분석, 역인덱스 생성, Segment 생성/검색/Merge, 파일 I/O |
Elasticsearch는 "어느 Shard에 보낼까"를 결정하고, Lucene은 "이 텍스트를 어떻게 쪼개서 저장할까"를 처리합니다. 분산 시스템의 복잡성은 Elasticsearch가, 검색 엔진의 핵심 로직은 Lucene이 담당하는 구조입니다.
문서를 저장하고 검색하는 흐름에서 두 계층이 어떻게 나뉘는지 살펴보겠습니다.
문서 저장 흐름:
[Client]
│
│ PUT /orders/_doc/1 { "orderId": "ORD-12345", ... }
│
▼
[Elasticsearch]
│ 1. HTTP 요청 파싱
│ 2. hash(_id) % shard_count → Shard 결정
│ 3. Primary Shard가 있는 노드로 라우팅
│
▼
[Lucene] ← 여기서부터 Lucene의 영역
│ 4. IndexWriter.addDocument() 호출
│ 5. Analyzer로 텍스트 분석 → 토큰 생성
│ 6. 역인덱스에 토큰 등록
│ 7. Segment 파일로 기록
검색 흐름:
[Client]
│
│ GET /orders/_search { "query": { "match": { "status": "배송완료" } } }
│
▼
[Elasticsearch]
│ 1. HTTP 요청 파싱
│ 2. 관련 Shard 목록 결정
│ 3. 각 Shard가 있는 노드에 브로드캐스트
│
▼
[Lucene] ← 각 Shard에서 독립 실행
│ 4. IndexSearcher.search() 호출
│ 5. 검색어를 Analyzer로 분석
│ 6. 역인덱스에서 토큰 조회
│ 7. 매칭 문서 + 점수 반환
│
▼
[Elasticsearch]
│ 8. 각 Shard의 결과를 수집
│ 9. 점수 기준으로 병합/정렬
│ 10. 클라이언트에 응답
Elasticsearch가 하는 일은 "라우팅"과 "병합"입니다. 실제 텍스트를 분석하고, 역인덱스를 만들고, 검색하는 핵심 작업은 전부 Lucene이 수행합니다.
Lucene이 하는 일은 크게 두 가지로 나눌 수 있습니다.
이번 3편에서는 이 중 Indexing의 핵심인 텍스트 분석(Analysis)을 집중적으로 다룹니다. "텍스트를 어떻게 쪼개서 역인덱스에 넣는가"가 이 글의 핵심 질문입니다. Searching의 상세 동작은 4편에서 다룹니다.
1편에서 역인덱스의 개념을 소개했습니다. "단어 → 문서 번호" 매핑이라고 했는데, 여기서 "단어"는 어떻게 만들어질까요? 원본 텍스트에서 의미 있는 단위를 추출하는 과정, 그것이 바로 Analysis이고, 이를 수행하는 것이 Analyzer입니다.
Analyzer는 세 단계의 파이프라인으로 구성됩니다.
┌──────────────────────────────────────────────────────────────────────┐
│ Analyzer │
│ │
│ 원본 텍스트 │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Char Filter │ 0개 이상. 문자 단위 전처리 │
│ │ (선택적) │ 예: HTML 태그 제거, 특수문자 치환 │
│ └──────┬───────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Tokenizer │ 정확히 1개. 텍스트를 토큰으로 분리 │
│ │ (필수) │ 예: 공백 기준, 문법 기준, 형태소 기준 │
│ └──────┬───────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Token Filter │ 0개 이상. 토큰 단위 후처리 │
│ │ (선택적) │ 예: 소문자 변환, 불용어 제거, 어간 추출 │
│ └──────┬───────┘ │
│ ▼ │
│ 토큰들 │
│ │
└──────────────────────────────────────────────────────────────────────┘
"텍스트를 받아서 바로 토큰을 뽑으면 되지, 왜 굳이 3단계로 나눠놨을까?"
핵심은 조합 가능성입니다. 각 단계를 독립적인 컴포넌트로 분리하면, 서로 다른 컴포넌트를 자유롭게 교체하거나 조합할 수 있습니다.
질문: 이 패턴이 어디서 본 것 같지 않나요?
├─ Spring Boot의 Filter Chain과 같은 패턴
├─ Unix의 파이프라인 (cat | grep | sort)과 같은 철학
└─ 각 단계가 하나의 책임만 가지고, 체이닝으로 결합
특히 Token Filter는 여러 개를 체이닝할 수 있습니다.
Token Filter 체이닝 예시:
토큰들 → [Lowercase] → [Stop Words] → [Stemmer] → 최종 토큰들
소문자 변환 불용어 제거 어간 추출
이 조합으로 커스텀 Analyzer를 만드는 것이 Elasticsearch의 텍스트 분석 전략입니다.
주문 로그 메시지 하나를 분석하는 과정을 따라가 보겠습니다.
입력: "<b>주문</b> ORD-12345 생성 Completed!"
Char Filter (HTML Strip Char Filter):
"<b>주문</b> ORD-12345 생성 Completed!"
↓
"주문 ORD-12345 생성 Completed!" ← HTML 태그 제거
Tokenizer (Standard Tokenizer):
"주문 ORD-12345 생성 Completed!"
↓
["주문", "ORD", "12345", "생성", "Completed"] ← 단어 단위로 분리
Token Filter (Lowercase Filter):
["주문", "ORD", "12345", "생성", "Completed"]
↓
["주문", "ord", "12345", "생성", "completed"] ← 소문자 변환
각 단계가 하나의 변환만 수행하고, 그 결과를 다음 단계에 넘기는 구조입니다.
Analyzer가 만들어낸 토큰들은 역인덱스의 Posting List에 등록됩니다.
문서 1: "주문 ORD-12345 생성 completed"
문서 2: "주문 ORD-67890 취소 completed"
Analyzer 적용 후 역인덱스:
┌──────────────┬──────────────────────────────────────────────┐
│ 토큰 │ Posting List │
├──────────────┼──────────────────────────────────────────────┤
│ "주문" │ Doc1 (pos:0, freq:1), Doc2 (pos:0, freq:1) │
│ "ord" │ Doc1 (pos:1, freq:1), Doc2 (pos:1, freq:1) │
│ "12345" │ Doc1 (pos:2, freq:1) │
│ "67890" │ Doc2 (pos:2, freq:1) │
│ "생성" │ Doc1 (pos:3, freq:1) │
│ "취소" │ Doc2 (pos:3, freq:1) │
│ "completed" │ Doc1 (pos:4, freq:1), Doc2 (pos:4, freq:1) │
└──────────────┴──────────────────────────────────────────────┘
pos = 토큰 위치 (Position)
freq = 해당 문서에서의 출현 빈도 (Frequency)
Posting List에는 단순히 "어떤 문서에 있다"뿐만 아니라, 위치(position)와 빈도(frequency)까지 저장됩니다. 위치 정보는 4편에서 다룰 Phrase Query에서 사용되고, 빈도 정보는 BM25 스코어링에 활용됩니다.
여기서 중요한 점이 있습니다. Analyzer는 문서를 저장할 때만 쓰이는 게 아닙니다. 검색할 때도 동일한 Analyzer가 검색어에 적용됩니다.
검색어: "재고가 부족합니다"
│
▼
Analyzer 적용 (Nori 가정)
│
▼
["재고", "부족"]
│
▼
역인덱스에서 "재고"와 "부족"이 포함된 문서 조회
왜 이렇게 해야 할까요? 저장 시에 "재고가" → "재고"로 변환해서 역인덱스에 넣었다면, 검색 시에도 "재고가" → "재고"로 변환해야 역인덱스에서 찾을 수 있기 때문입니다. 저장과 검색에 같은 Analyzer를 쓰기 때문에, 원본 형태가 달라도 매칭이 가능합니다.
이 원칙을 기억해두면 됩니다. 6장에서 이 원칙을 의도적으로 깨는 고급 패턴을 다룹니다.
Elasticsearch는 여러 내장 Analyzer를 제공합니다. 같은 입력이라도 어떤 Analyzer를 쓰느냐에 따라 결과가 완전히 달라집니다. 실무에서 흔히 다루는 로그 메시지 하나를 가지고 각 Analyzer의 동작을 비교해보겠습니다.
"[ERROR] OrderService: 주문-12345 생성 Failed!"
이 한 줄에는 로그 레벨, 클래스명, 한국어, 숫자, 특수문자가 모두 포함되어 있습니다. 각 Analyzer가 이것을 어떻게 처리하는지 보겠습니다.
| Analyzer | 결과 토큰 |
|---|---|
| Standard | ["error", "orderservice", "주문", "12345", "생성", "failed"] |
| Simple | ["error", "orderservice", "주문", "생성", "failed"] |
| Whitespace | ["[ERROR]", "OrderService:", "주문-12345", "생성", "Failed!"] |
| Keyword | ["[ERROR] OrderService: 주문-12345 생성 Failed!"] |
같은 텍스트인데 토큰이 6개, 5개, 5개, 1개로 다릅니다. 각각의 동작 원리를 살펴보겠습니다.
Elasticsearch의 기본 Analyzer입니다. 필드에 별도 설정을 하지 않으면 이것이 적용됩니다.
구성:
Char Filter : 없음
Tokenizer : Standard Tokenizer (유니코드 텍스트 세그멘테이션 기반 분리)
Token Filter : Lowercase Filter
동작:
"[ERROR] OrderService: 주문-12345 생성 Failed!"
↓ Standard Tokenizer (특수문자/공백 기준 분리)
["ERROR", "OrderService", "주문", "12345", "생성", "Failed"]
↓ Lowercase Filter
["error", "orderservice", "주문", "12345", "생성", "failed"]
범용적으로 괜찮은 결과입니다. 다만 OrderService가 "orderservice" 하나의 토큰이 됩니다. CamelCase를 분리하지 못하기 때문에, "order"로 검색하면 이 문서가 매칭되지 않습니다.
구성:
Char Filter : 없음
Tokenizer : Letter Tokenizer (문자(letter)가 아닌 것 기준으로 분리)
Token Filter : Lowercase Filter
동작:
"[ERROR] OrderService: 주문-12345 생성 Failed!"
↓ Letter Tokenizer (문자가 아닌 모든 것에서 분리, 숫자도 문자가 아님)
["ERROR", "OrderService", "주문", "생성", "Failed"]
↓ Lowercase Filter
["error", "orderservice", "주문", "생성", "failed"]
"12345"가 사라졌습니다. Letter Tokenizer는 숫자를 문자(letter)로 취급하지 않기 때문에, 숫자를 구분자로 처리하고 버립니다.
로그 분석에서는 주문번호, 에러코드 같은 숫자 정보가 중요합니다. "12345"로 검색해서 특정 주문의 로그를 찾아야 하는 경우, Simple Analyzer로는 해당 토큰이 존재하지 않으므로 검색이 불가능합니다.
구성:
Char Filter : 없음
Tokenizer : Whitespace Tokenizer (공백으로만 분리)
Token Filter : 없음
동작:
"[ERROR] OrderService: 주문-12345 생성 Failed!"
↓ Whitespace Tokenizer
["[ERROR]", "OrderService:", "주문-12345", "생성", "Failed!"]
↓ (Token Filter 없음)
["[ERROR]", "OrderService:", "주문-12345", "생성", "Failed!"]
원본을 가장 많이 보존합니다. 하지만 두 가지 문제가 있습니다.
"error"로 검색하면 "[ERROR]"와 매칭되지 않습니다."ERROR"로 검색해도 "[ERROR]"와 매칭되지 않습니다.원본 보존과 검색 편의성은 트레이드오프입니다.
구성:
Char Filter : 없음
Tokenizer : Keyword Tokenizer (분리하지 않음, 전체 텍스트가 하나의 토큰)
Token Filter : 없음
동작:
"[ERROR] OrderService: 주문-12345 생성 Failed!"
↓ Keyword Tokenizer
["[ERROR] OrderService: 주문-12345 생성 Failed!"]
텍스트 전체를 하나의 토큰으로 취급합니다. 전체 문자열이 정확히 일치해야만 검색됩니다. 로그 원문에 이것을 쓰면 사실상 검색이 불가능합니다.
그렇다면 Keyword Analyzer는 언제 쓸까요? status, enum, 코드값처럼 분리하면 안 되는 값에 적합합니다.
✅ Keyword Analyzer가 적합한 필드:
- 주문 상태: "PAYMENT_COMPLETED"
- 에러 코드: "ERR_INVENTORY_001"
- 카테고리: "electronics/mobile/samsung"
❌ Keyword Analyzer가 부적합한 필드:
- 로그 메시지 원문
- 상품 설명
- 사용자 리뷰
질문 1: 필드 값이 분리되면 안 되는 코드/상태값인가?
├─ Yes → Keyword Analyzer (또는 keyword 필드 타입)
└─ No ↓
질문 2: 한국어 텍스트가 포함되어 있는가?
├─ Yes → Nori Analyzer (5장에서 설명)
└─ No ↓
질문 3: 숫자 정보가 중요한가? (주문번호, 에러코드 등)
├─ Yes → Standard Analyzer ⭐ (Simple은 숫자 유실)
└─ No → Standard 또는 Simple
대부분의 경우 Standard Analyzer가 합리적인 시작점입니다.
3장에서 Analyzer의 종류를 살펴봤는데, 실무에서는 Analyzer를 직접 고르는 것보다 필드 타입을 text로 할지 keyword로 할지 결정하는 경우가 더 많습니다. 이 두 타입의 차이는 무엇이고, 왜 구분이 필요한지 살펴보겠습니다.
"keyword 타입은 역인덱스를 만들지 않는다"고 생각하기 쉽지만, 그렇지 않습니다.
틀렸습니다. text와 keyword 모두 역인덱스를 만듭니다. 차이는 역인덱스에 넣기 전에 Analyzer를 거치느냐 아니냐입니다.
필드값: "ORD-12345"
text 타입 (Standard Analyzer 적용):
"ORD-12345" → Analyzer → ["ord", "12345"] → 역인덱스에 2개의 토큰 등록
keyword 타입 (Keyword Analyzer 적용):
"ORD-12345" → Analyzer → ["ORD-12345"] → 역인덱스에 1개의 토큰 등록
keyword 타입도 내부적으로 Keyword Analyzer를 거칩니다. 다만 그 Analyzer가 "아무것도 하지 않고 원본 그대로 토큰 하나로 내보내는" 동작을 할 뿐입니다. 결국 역인덱스에는 등록됩니다.
| 구분 | text | keyword |
|---|---|---|
| Analyzer 적용 | 설정된 Analyzer (기본: Standard) | Keyword Analyzer (원본 유지) |
| 역인덱스 토큰 수 | 여러 개 | 1개 (원본 전체) |
| 매칭 방식 | 부분 매칭 가능 | 정확 일치만 |
| 대소문자 구분 | 보통 무시 (Lowercase Filter) | 구분함 |
| 주요 쿼리 | Match Query | Term Query |
| 적합한 용도 | 로그 메시지, 상품 설명, 리뷰 | 상태값, 코드, ID, 태그 |
keyword 타입은 원본 전체가 하나의 토큰입니다. 그래서 필드가 적절히 분리되어 있을 때만 의미가 있습니다.
이것을 로그 데이터로 생각해보겠습니다.
비정형 로그 원문을 keyword로 저장한 경우:
필드: message (keyword)
값: "[ERROR] OrderService: 주문-12345 생성 Failed!"
역인덱스:
"[ERROR] OrderService: 주문-12345 생성 Failed!" → Doc1
이 역인덱스로 할 수 있는 검색은 전체 문자열이 정확히 일치하는 경우뿐입니다. "ERROR"로 검색? 안 됩니다. "12345"로 검색? 안 됩니다. 쓸모가 없습니다.
Logstash로 파싱한 후 keyword로 저장한 경우:
파싱 결과:
level: "ERROR" (keyword)
service: "OrderService" (keyword)
orderId: "주문-12345" (keyword)
action: "생성" (keyword)
result: "Failed" (keyword)
역인덱스:
level 필드: "ERROR" → Doc1
service 필드: "OrderService" → Doc1
orderId 필드: "주문-12345" → Doc1
action 필드: "생성" → Doc1
result 필드: "Failed" → Doc1
이제 level: "ERROR"로 검색하면 바로 찾을 수 있습니다. orderId: "주문-12345"로 정확히 검색할 수도 있습니다.
이것이 Logstash의 파싱(비정형 → 구조화)이 중요한 이유입니다. keyword 타입의 효과는 데이터가 잘 분리되어 있을 때만 발휘됩니다.
비정형 로그 원문
│
▼
┌──────────┐
│ Logstash │ grok 패턴으로 파싱
│ (파싱) │ 비정형 → 구조화
└─────┬────┘
▼
┌─────────────────────────────┐
│ 구조화된 필드들 │
│ level: "ERROR" (keyword)│
│ service: "OrderService" │
│ orderId: "주문-12345" │
│ ... │
└─────────────────────────────┘
│
▼
각 필드별로 역인덱스 생성 → 정확 일치 검색 가능
실무에서는 같은 필드를 text와 keyword로 동시에 저장하는 경우가 많습니다. Elasticsearch에서는 이를 Multi-field라고 부릅니다.
{
"mappings": {
"properties": {
"orderId": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
}
}
}
}
}
이렇게 하면 orderId 필드 하나에 대해 두 개의 역인덱스가 만들어집니다.
필드값: "ORD-12345"
orderId (text, Standard Analyzer):
역인덱스: "ord" → Doc1, "12345" → Doc1
orderId.keyword (keyword):
역인덱스: "ORD-12345" → Doc1
검색 시 용도에 따라 골라 쓸 수 있습니다:
// 부분 검색: "12345"가 포함된 주문 찾기
{ "match": { "orderId": "12345" } }
// 정확 일치: 정확히 "ORD-12345"인 주문 찾기
{ "term": { "orderId.keyword": "ORD-12345" } }
// 정렬: keyword 필드로 정렬 (text 필드로는 정렬 불가)
{ "sort": [ { "orderId.keyword": "asc" } ] }
// 집계: keyword 필드로 그룹핑
{ "aggs": { "by_order": { "terms": { "field": "orderId.keyword" } } } }
이 패턴은 PostgreSQL에서 같은 컬럼에 GIN 인덱스(전문 검색용)와 B-Tree 인덱스(정확 일치/정렬용)를 동시에 거는 것과 유사한 개념입니다.
-- PostgreSQL 비유
CREATE INDEX idx_order_gin ON orders USING GIN(to_tsvector('korean', order_id)); -- 전문 검색용
CREATE INDEX idx_order_btree ON orders USING BTREE(order_id); -- 정확 일치/정렬용
트레이드오프: 저장 공간이 두 배 가까이 들지만, 검색 유연성을 얻습니다. Elasticsearch의 기본 Dynamic Mapping에서 문자열 필드가 자동으로 text + keyword Multi-field로 생성되는 이유가 바로 이 유연성 때문입니다.
한국어 텍스트를 다루는 서비스라면 Standard Analyzer만으로는 부족합니다. 왜 그런지, 그리고 어떻게 해결하는지 살펴보겠습니다.
Standard Analyzer는 유니코드 텍스트 세그멘테이션 규칙에 따라 토큰을 분리합니다. 영어처럼 단어 사이에 공백이 있는 언어에서는 잘 동작하지만, 한국어에서는 문제가 생깁니다.
입력: "재고가 부족합니다"
Standard Analyzer 적용:
["재고가", "부족합니다"]
"재고"로 검색하면 어떻게 될까요? 역인덱스에는 "재고가"가 저장되어 있고, 검색어는 "재고"로 분석됩니다. "재고" ≠ "재고가"이므로 매칭되지 않습니다.
왜 이런 일이 발생할까요? 한국어는 교착어이기 때문입니다. 어근에 조사, 어미, 접미사가 결합해서 하나의 어절을 이룹니다.
영어: "stock" + "is" + "insufficient" ← 단어가 분리되어 있음
한국어: "재고" + "가" "부족" + "합니다" ← 어근 + 조사/어미가 붙어 있음
어근 조사 어근 어미
Standard Analyzer는 공백을 기준으로 분리하므로, "재고가"를 하나의 토큰으로 취급합니다. 어근과 조사를 분리하려면 형태소 분석이 필요합니다.
Nori는 Elasticsearch에서 제공하는 한국어 형태소 분석 플러그인입니다.
입력: "재고가 부족합니다"
Nori Analyzer 적용:
1. Nori Tokenizer (형태소 단위 분리)
"재고가" → "재고" + "가"
"부족합니다" → "부족" + "하" + "ㅂ니다"
2. Nori Part-of-Speech Token Filter (조사/어미 제거)
"가" (조사) → 제거
"하" (어미) → 제거
"ㅂ니다" (어미) → 제거
최종 결과: ["재고", "부족"]
이제 "재고"로 검색하면 역인덱스에서 "재고" 토큰을 찾을 수 있습니다.
Standard vs Nori 비교:
| 입력 | Standard Analyzer | Nori Analyzer |
|---|---|---|
| "재고가 부족합니다" | ["재고가", "부족합니다"] | ["재고", "부족"] |
| "주문이 생성되었습니다" | ["주문이", "생성되었습니다"] | ["주문", "생성"] |
| "결제가 완료되었습니다" | ["결제가", "완료되었습니다"] | ["결제", "완료"] |
Standard Analyzer에서는 "재고"로 검색해도 "재고가"와 매칭되지 않지만, Nori Analyzer에서는 저장/검색 모두에서 "재고"로 통일되므로 매칭됩니다.
한국어에는 복합어가 많습니다. 두 개 이상의 어근이 결합된 단어인데, Nori Tokenizer는 이것도 분리할 수 있습니다.
입력: "냉장배송으로 보내주세요"
Standard Analyzer:
["냉장배송으로", "보내주세요"]
→ "냉장"으로 검색? 매칭 안 됨
→ "배송"으로 검색? 매칭 안 됨
Nori Analyzer (decompound_mode: mixed):
"냉장배송으로" → "냉장배송" + "냉장" + "배송" + "으로"(제거)
"보내주세요" → "보내" + "주" + "세요"(제거)
→ ["냉장배송", "냉장", "배송", "보내", "주"]
Nori Tokenizer의 decompound_mode 설정에 따라 복합어 처리 방식이 달라집니다.
| decompound_mode | "냉장배송" 처리 결과 | 용도 |
|---|---|---|
none | ["냉장배송"] | 복합어 그대로 유지 |
discard | ["냉장", "배송"] | 복합어를 분리하고 원본 버림 |
mixed | ["냉장배송", "냉장", "배송"] | 원본 + 분리 결과 모두 유지 |
mixed 모드를 쓰면 "냉장배송", "냉장", "배송" 어느 것으로 검색해도 매칭됩니다. 검색 재현율(Recall)은 높아지지만 역인덱스 크기가 커지는 트레이드오프가 있습니다.
Elasticsearch에서 Nori Analyzer를 적용하는 실제 매핑 설정입니다.
{
"settings": {
"analysis": {
"analyzer": {
"korean_analyzer": {
"type": "custom",
"tokenizer": "nori_tokenizer",
"filter": [
"nori_part_of_speech"
]
}
},
"tokenizer": {
"nori_tokenizer": {
"type": "nori_tokenizer",
"decompound_mode": "mixed"
}
},
"filter": {
"nori_part_of_speech": {
"type": "nori_part_of_speech",
"stoptags": [
"E", "IC", "J", "MAG", "MAJ",
"MM", "SP", "SSC", "SSO", "SC",
"SE", "XPN", "XSA", "XSN", "XSV",
"UNA", "NA", "VSV"
]
}
}
}
},
"mappings": {
"properties": {
"message": {
"type": "text",
"analyzer": "korean_analyzer"
}
}
}
}
stoptags는 제거할 품사 태그 목록입니다. J(조사), E(어미) 등을 제거하면 어근만 남습니다.
2장에서 "저장 시와 검색 시에 같은 Analyzer를 쓴다"고 했습니다. 그런데 의도적으로 다른 Analyzer를 쓰는 패턴이 있습니다. 대표적인 사례가 자동완성(Autocomplete) 구현입니다.
쇼핑몰의 검색창을 떠올려 보겠습니다. 사용자가 "주문"까지만 타이핑하면 "주문생성", "주문취소", "주문조회" 같은 후보를 보여주는 기능입니다.
이것을 일반적인 Match Query로 구현할 수 있을까요?
역인덱스에 저장된 토큰: "주문생성", "주문취소", "주문조회"
검색어: "주문"
→ Standard Analyzer → ["주문"]
→ 역인덱스에서 "주문" 조회
→ 매칭 없음 (정확히 "주문"인 토큰이 없으므로)
"주문"으로 "주문생성"을 찾으려면, "주문생성"이 저장될 때 "주문"이라는 토큰도 함께 만들어져야 합니다.
Edge N-gram은 텍스트의 앞에서부터 한 글자씩 늘려가며 토큰을 생성합니다.
"주문생성" → Edge N-gram (min_gram=1, max_gram=4)
"주"
"주문"
"주문생"
"주문생성"
역인덱스:
┌────────────┬──────────────┐
│ 토큰 │ Posting List │
├────────────┼──────────────┤
│ "주" │ Doc1 │
│ "주문" │ Doc1 │
│ "주문생" │ Doc1 │
│ "주문생성" │ Doc1 │
└────────────┴──────────────┘
이제 "주문"으로 검색하면 역인덱스에서 "주문" 토큰을 찾을 수 있습니다.
그런데 검색할 때도 Edge N-gram을 적용하면 어떻게 될까요?
❌ 검색 시에도 Edge N-gram 적용:
검색어: "주문" → Edge N-gram → ["주", "주문"]
"주"로 역인덱스 조회 → "주문생성", "주소변경", "주차장안내" ... 전부 매칭
"주문"으로 역인덱스 조회 → "주문생성", "주문취소" ... 매칭
결과: "주소변경", "주차장안내" 등 의도하지 않은 문서까지 포함
→ 노이즈 발생
"주"라는 한 글자 토큰이 만들어지면서, "주"로 시작하는 모든 문서가 매칭됩니다. 이것은 사용자가 의도한 결과가 아닙니다.
✅ 검색 시에는 Standard Analyzer 적용:
검색어: "주문" → Standard Analyzer → ["주문"]
"주문"으로 역인덱스 조회 → "주문생성", "주문취소" ... 매칭
결과: "주문"으로 시작하는 문서만 정확히 매칭
저장할 때는 Edge N-gram으로 토큰을 잘게 쪼개고, 검색할 때는 Standard로 검색어를 그대로 유지하면 깔끔한 자동완성이 됩니다.
[Indexing 시]
"주문생성" → Edge N-gram Analyzer
→ ["주", "주문", "주문생", "주문생성"]
→ 역인덱스에 4개 토큰 등록
[Searching 시]
"주문" → Standard Analyzer
→ ["주문"]
→ 역인덱스에서 "주문" 조회 → Doc1 매칭 ✅
"주문생" → Standard Analyzer
→ ["주문생"]
→ 역인덱스에서 "주문생" 조회 → Doc1 매칭 ✅
"주차" → Standard Analyzer
→ ["주차"]
→ 역인덱스에서 "주차" 조회 → 매칭 없음 ❌ (의도대로 필터링)
{
"settings": {
"analysis": {
"analyzer": {
"autocomplete_index": {
"type": "custom",
"tokenizer": "autocomplete_tokenizer",
"filter": ["lowercase"]
},
"autocomplete_search": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase"]
}
},
"tokenizer": {
"autocomplete_tokenizer": {
"type": "edge_ngram",
"min_gram": 1,
"max_gram": 20,
"token_chars": ["letter", "digit"]
}
}
}
},
"mappings": {
"properties": {
"suggest": {
"type": "text",
"analyzer": "autocomplete_index",
"search_analyzer": "autocomplete_search"
}
}
}
}
analyzer는 저장 시에 사용되고, search_analyzer는 검색 시에 사용됩니다. 이 두 설정을 분리할 수 있다는 것이 Elasticsearch의 Analyzer 아키텍처가 유연한 이유 중 하나입니다.
질문 1: 저장 시와 검색 시에 같은 형태의 매칭이 필요한가?
├─ Yes → 같은 Analyzer 사용 (기본 패턴) ⭐
└─ No ↓
질문 2: Prefix 매칭(자동완성)이 필요한가?
├─ Yes → Index: Edge N-gram / Search: Standard
└─ No ↓
질문 3: 동의어 확장이 저장 시에만 필요한가?
├─ Yes → Index: Synonym Filter 포함 / Search: 일반 Analyzer
└─ No → 같은 Analyzer로 충분한지 재검토
대부분의 경우 같은 Analyzer를 쓰는 것이 맞습니다. Index/Search Analyzer를 분리하는 것은 특수한 요구사항이 있을 때의 패턴이지, 기본 권장사항이 아닙니다.
이번 글에서 다룬 내용을 되짚어 보면, 핵심은 하나입니다. Elasticsearch의 검색 품질은 Analyzer에서 시작됩니다.
역인덱스가 아무리 효율적이어도, 토큰이 잘못 만들어지면 검색 자체가 안 됩니다. "재고"로 검색했는데 "재고가"를 못 찾는 것은 역인덱스의 문제가 아니라 Analyzer의 문제입니다. Lucene이 아무리 빠르게 역인덱스를 탐색해도, 찾아야 할 토큰이 애초에 없으면 의미가 없습니다.
Analyzer를 선택할 때 스스로에게 물어봐야 할 질문은 이것입니다.
"이 필드를 검색하는 사용자는 어떤 단어를 입력할 것인가? 그리고 그 단어가 역인덱스의 토큰과 매칭되려면 텍스트를 어떻게 쪼개야 하는가?"
다음 4편에서는 이렇게 만들어진 역인덱스를 어떤 방식으로 조회하는지, 즉 Term Query, Match Query, Boolean Query 등 다양한 검색 쿼리의 내부 동작을 살펴봅니다.