[ELK Deep Dive] Lucene — 검색 쿼리
3편에서 Lucene이 텍스트를 Analyzer로 분석하고 역인덱스에 저장하는 과정을 살펴봤습니다. 역인덱스라는 자료구조가 만들어졌으니, 이제 남은 질문은 하나입니다. 이 역인덱스를 어떻게 조회하는가?
Elasticsearch의 쿼리 DSL은 수십 가지가 넘지만, 내부적으로 Lucene이 역인덱스를 활용하는 방식은 몇 가지 패턴으로 수렴합니다. 이 글에서는 각 쿼리가 역인덱스를 어떤 방식으로 조회하는지, 왜 특정 쿼리를 특정 필드에 써야 하는지, 그리고 Boolean Query에서 must와 filter의 차이가 성능에 어떤 영향을 미치는지까지 다룹니다.
PART 1. 쿼리의 분류
1. Term-level vs Full-text — 두 가지 쿼리 세계
PART 2. 기본 쿼리
2. Term Query
3. Match Query
PART 3. 조합과 정밀 검색
4. Boolean Query — 실무의 핵심
5. Phrase Query
PART 4. 범위 검색과 스코어링
6. Range Query
7. Fuzzy Query와 BM25 스코어링
Elasticsearch의 쿼리를 처음 접하면, 비슷해 보이는 쿼리가 왜 이렇게 많은지 혼란스럽습니다. Term Query, Match Query, Phrase Query... 이름만 봐서는 차이를 알기 어렵습니다. 하지만 구분 기준은 의외로 단순합니다.
3편에서 Analyzer가 텍스트를 토큰으로 분해하는 과정을 다뤘습니다. 이 Analyzer를 검색어에도 적용하느냐, 안 하느냐가 두 쿼리 세계를 가르는 유일한 기준입니다.
┌─────────────────────────────────────────────────────────────────┐
│ 검색어 처리 방식의 차이 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Term-level Query │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 검색어 입력 │ ──────→ │ 역인덱스 조회 │ │
│ │ "OrderService"│ │ (그대로 매칭) │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ Full-text Query │
│ ┌──────────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ 검색어 입력 │ → │ Analyzer │ → │ 역인덱스 조회 │ │
│ │ "OrderService"│ │ 토큰 분해 │ │ (토큰별 매칭) │ │
│ └──────────────┘ └──────────┘ └──────────────┘ │
│ ↓ │
│ ["orderservice"] │
│ (Standard Analyzer 기준) │
│ │
└─────────────────────────────────────────────────────────────────┘
| 구분 | Analyzer 적용 | 대표 쿼리 | 주 사용 필드 |
|---|---|---|---|
| Term-level | ❌ 검색어를 그대로 사용 | Term, Range, Fuzzy, Exists, Prefix | keyword, long, date |
| Full-text | ✅ 검색어를 Analyzer로 분석 | Match, Phrase, Multi-match | text |
이 구분에서 자연스럽게 도출되는 기본 원칙이 있습니다.
질문: 이 필드는 저장 시 Analyzer가 토큰을 분해했는가?
├─ Yes (text 필드) → Full-text Query (Match, Phrase 등)
│ 검색어도 같은 Analyzer로 분해해야 매칭됨
└─ No (keyword 필드) → Term-level Query (Term, Range 등)
원본 그대로 매칭해야 함
왜 이런 원칙이 나오는지는 다음 섹션에서 Term Query를 text 필드에 쓰면 어떤 일이 벌어지는지 보면 명확해집니다.
Term Query는 가장 단순하고 가장 빠른 쿼리입니다. 검색어를 Analyzer에 통과시키지 않고, 입력한 그대로 역인덱스에서 찾습니다.
Term Query의 내부 동작은 해시맵 조회와 비슷합니다. 역인덱스의 Term Dictionary에서 해당 토큰을 찾고, 연결된 Posting List(문서 목록)를 반환합니다.
Term Query: "COMPLETED"
Term Dictionary (역인덱스)
┌─────────────────┬────────────────┐
│ Token │ Posting List │
├─────────────────┼────────────────┤
│ "CANCELLED" │ [3, 7] │
│ "COMPLETED" │ [1, 2, 5, 8] │ ← 바로 여기
│ "PENDING" │ [4, 6, 9] │
│ "PROCESSING" │ [10] │
└─────────────────┴────────────────┘
결과: 문서 1, 2, 5, 8
실제로는 5편에서 다룰 FST(Finite State Transducer) 자료구조를 통해 토큰을 찾고, 해당 Posting List의 디스크 오프셋을 얻습니다. 핵심은 전체 문서를 스캔하지 않는다는 것입니다.
주문 상태가 정확히 "COMPLETED"인 문서를 찾는 쿼리입니다.
// status 필드가 keyword 타입일 때
GET /orders/_search
{
"query": {
"term": {
"status": {
"value": "COMPLETED"
}
}
}
}
이 쿼리에서 "COMPLETED"라는 문자열은 Analyzer를 거치지 않고 그대로 역인덱스에서 조회됩니다. keyword 필드는 저장 시에도 원본 그대로 하나의 토큰으로 역인덱스에 들어가므로, 양쪽이 정확히 일치합니다.
text 필드에 Term Query를 사용하면 결과가 나오지 않는 경우가 많습니다. 이유를 이해하려면, 저장 시점과 검색 시점에서 일어나는 일을 나란히 봐야 합니다.
message라는 text 필드에 "Order Created Successfully"라는 값이 저장되었다고 가정합니다.
┌─────────────────────────────────────────────────────────────────┐
│ 저장 시점 (Indexing) │
│ │
│ 원본: "Order Created Successfully" │
│ │ │
│ ▼ │
│ Standard Analyzer │
│ │ │
│ ▼ │
│ 토큰: ["order", "created", "successfully"] │
│ │ │
│ ▼ │
│ 역인덱스에 저장: │
│ "order" → [doc1] │
│ "created" → [doc1] │
│ "successfully" → [doc1] │
│ │
│ ⭐ 원본 "Order Created Successfully"는 역인덱스에 없음 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 검색 시점 (Term Query) │
│ │
│ 검색어: "Order Created Successfully" │
│ │ │
│ ▼ │
│ Analyzer 적용 안 함 (Term Query이므로) │
│ │ │
│ ▼ │
│ 역인덱스에서 "Order Created Successfully" 조회 │
│ │ │
│ ▼ │
│ ❌ 매칭 없음 │
│ │
│ 역인덱스에는 "order", "created", "successfully"만 있고 │
│ "Order Created Successfully"라는 토큰은 존재하지 않음 │
└─────────────────────────────────────────────────────────────────┘
text 필드는 저장 시 Analyzer가 토큰을 분해합니다. 그런데 Term Query는 검색어를 분해하지 않습니다. 저장된 형태와 검색하는 형태가 다르니 매칭이 될 수 없습니다.
심지어 소문자로 "order created successfully"를 검색해도 매칭되지 않습니다. 역인덱스에는 "order", "created", "successfully"가 개별 토큰으로 존재하지, 공백으로 연결된 하나의 문자열로 존재하지 않기 때문입니다.
// ❌ text 필드에 Term Query — 결과 없음
GET /logs/_search
{
"query": {
"term": {
"message": "Order Created Successfully"
}
}
}
// ✅ text 필드에는 Match Query
GET /logs/_search
{
"query": {
"match": {
"message": "Order Created Successfully"
}
}
}
정리하면, Term Query는 keyword 필드처럼 저장 시에도 토큰을 분해하지 않은 필드에 사용해야 합니다. text 필드에는 검색어도 같은 Analyzer로 분석하는 Match Query를 사용해야 합니다. 그렇다면 Match Query는 내부적으로 어떻게 동작할까요?
Match Query는 Elasticsearch에서 가장 자주 사용하는 Full-text 쿼리입니다. Term Query와의 핵심 차이는 검색어에 Analyzer를 적용한다는 것입니다.
검색어가 역인덱스에 도달하기까지의 과정을 따라가 봅니다.
Match Query: "주문 생성 실패"
│
▼
Analyzer 적용
(해당 필드에 설정된 Analyzer)
│
▼
토큰: ["주문", "생성", "실패"]
│
▼
┌───────────┼───────────┐
│ │ │
▼ ▼ ▼
"주문" 조회 "생성" 조회 "실패" 조회
│ │ │
▼ ▼ ▼
[1,3,5] [1,2,5] [3,5,7]
│ │ │
└───────────┼───────────┘
│
▼
OR 연산 (기본)
[1, 2, 3, 5, 7]
Match Query는 검색어를 Analyzer로 분석하여 토큰을 생성하고, 각 토큰으로 역인덱스를 조회한 뒤, 결과를 합칩니다. 결국 내부적으로는 여러 번의 Term Query를 실행하고 결과를 조합하는 것입니다.
Match Query의 기본 동작은 OR입니다. 생성된 토큰 중 하나라도 역인덱스에서 매칭되면 결과에 포함됩니다.
// "주문 생성 실패"로 검색하면
// "주문" OR "생성" OR "실패" 중 하나라도 포함된 문서가 매칭
GET /logs/_search
{
"query": {
"match": {
"message": "주문 생성 실패"
}
}
}
이 쿼리는 "주문 접수 완료"라는 문서도 매칭합니다. "주문"이라는 토큰이 존재하기 때문입니다. 관련도 점수(BM25)가 다르게 매겨지므로, 세 토큰 모두 포함된 문서가 더 상위에 올라오지만, "주문"만 포함된 문서도 결과에 나타납니다.
OR가 너무 느슨하다면 operator를 "and"로 바꿀 수 있습니다.
// "주문" AND "생성" AND "실패" 모두 포함된 문서만 매칭
GET /logs/_search
{
"query": {
"match": {
"message": {
"query": "주문 생성 실패",
"operator": "and"
}
}
}
}
AND 연산:
"주문" → [1, 3, 5]
"생성" → [1, 2, 5]
"실패" → [3, 5, 7]
교집합: [5] ← 세 토큰 모두 포함된 문서만
| 옵션 | 동작 | 매칭 범위 | 사용 시점 |
|---|---|---|---|
operator: "or" (기본) | 토큰 중 하나라도 매칭 | 넓음 | 관련 문서를 폭넓게 찾고 싶을 때 |
operator: "and" | 모든 토큰이 매칭 | 좁음 | 검색 결과를 정밀하게 좁히고 싶을 때 |
OR는 너무 넓고, AND는 너무 좁을 때 minimum_should_match로 최소 매칭 토큰 수를 지정할 수 있습니다.
// 3개 토큰 중 최소 2개 이상 매칭되는 문서
GET /logs/_search
{
"query": {
"match": {
"message": {
"query": "주문 생성 실패",
"minimum_should_match": 2
}
}
}
}
실무에서는 검색 결과의 정밀도와 재현율 사이에서 이 값을 조정하게 됩니다. 다만, Match Query만으로는 복잡한 실무 검색 조건을 표현하기 어렵습니다. "status가 FAILED이면서 message에 '주문'이 포함된 로그"처럼 여러 조건을 조합하려면 Boolean Query가 필요합니다.
실무에서 단일 필드, 단일 조건으로 검색하는 경우는 거의 없습니다. "어제 발생한 주문 서비스의 ERROR 로그 중에서 '타임아웃'이 포함된 것"처럼, 여러 조건을 조합하는 것이 보통입니다. Boolean Query는 이 조합을 담당합니다.
Boolean Query는 must, filter, should, must_not 네 가지 절로 구성됩니다.
| 절 | 매칭 조건 | 스코어 영향 | SQL 대응 |
|---|---|---|---|
must | 반드시 매칭 | ✅ 점수 계산에 반영 | WHERE ... AND ... |
filter | 반드시 매칭 | ❌ 점수 계산 안 함 | WHERE ... AND ... (필터) |
should | 매칭되면 점수 가산 | ✅ 점수 계산에 반영 | 직접 대응 없음 (점수 부스팅) |
must_not | 매칭되면 제외 | ❌ 점수 계산 안 함 | WHERE ... AND NOT ... |
// 실무 예시: 주문 서비스 에러 로그 검색
GET /order-logs-*/_search
{
"query": {
"bool": {
"must": [
{ "match": { "message": "타임아웃" } }
],
"filter": [
{ "term": { "level": "ERROR" } },
{ "term": { "service": "order-service" } },
{ "range": { "@timestamp": { "gte": "2025-02-20", "lt": "2025-02-21" } } }
],
"must_not": [
{ "term": { "environment": "staging" } }
]
}
}
}
이 쿼리를 SQL로 표현하면 아래와 비슷합니다.
SELECT *
FROM order_logs
WHERE message LIKE '%타임아웃%' -- must (관련도 점수 계산)
AND level = 'ERROR' -- filter
AND service = 'order-service' -- filter
AND timestamp >= '2025-02-20' -- filter
AND timestamp < '2025-02-21' -- filter
AND environment != 'staging' -- must_not
ORDER BY _score DESC; -- 관련도 순 정렬
그런데 여기서 질문이 생깁니다. must와 filter는 둘 다 "반드시 매칭"입니다. 결과적으로 같은 문서를 걸러내는데, 왜 둘로 나눴을까요?
둘의 차이는 BM25 점수 계산 여부입니다.
┌─────────────────────────────────────────────────────────────────┐
│ must: 매칭 + 점수 계산 │
│ │
│ "이 문서가 조건에 맞나?" + "얼마나 관련이 있나?" │
│ │
│ 예: message에 "타임아웃"이 포함된 문서를 찾되, │
│ "타임아웃"이 3번 나온 문서를 1번 나온 문서보다 위에 배치 │
├─────────────────────────────────────────────────────────────────┤
│ filter: 매칭만 (점수 계산 안 함) │
│ │
│ "이 문서가 조건에 맞나?" 만 확인. 끝. │
│ │
│ 예: level이 "ERROR"인가? → Yes or No │
│ "ERROR"가 얼마나 관련이 있는지는 의미 없음 │
└─────────────────────────────────────────────────────────────────┘
level이 "ERROR"인지 아닌지는 Yes/No 질문입니다. "얼마나 ERROR인가?"라는 관련도는 의미가 없습니다. 이런 조건은 filter에 넣습니다.
반면, message에 "타임아웃"이 포함된 문서를 찾을 때는 관련도가 의미 있습니다. "타임아웃"이 여러 번 등장하거나 문서가 짧을수록 더 관련성이 높다고 볼 수 있습니다. 이런 조건은 must에 넣습니다.
filter가 점수 계산을 안 한다는 것은 알겠는데, 그것만으로 성능 차이가 클까요? 점수 계산은 분명히 비용이지만, filter가 빠른 진짜 이유는 따로 있습니다.
Elasticsearch는 filter 절의 결과를 Bitset으로 만들어 캐시에 저장합니다.
첫 번째 실행: filter → { "term": { "level": "ERROR" } }
1) 역인덱스에서 "ERROR" 조회 → Posting List [1, 3, 5, 7, 8, 10]
2) Bitset 생성: [1,0,1,0,1,0,1,1,0,1]
doc1 doc3 doc5 doc7 doc8 doc10
3) 이 Bitset을 캐시에 저장
두 번째 실행: 같은 filter
1) 캐시에서 Bitset 조회 → [1,0,1,0,1,0,1,1,0,1]
2) 역인덱스 조회를 건너뜀 ⭐
두 번째 실행부터는 역인덱스를 조회하지 않습니다. 캐시에서 Bitset을 꺼내오는 것으로 끝입니다. 여러 filter 조건이 있을 때는 더 극적입니다.
filter 1: level = "ERROR" → Bitset: [1,0,1,0,1,0,1,1,0,1]
filter 2: service = "order" → Bitset: [1,1,0,0,1,0,0,1,0,0]
filter 3: date >= 2025-02-20 → Bitset: [0,0,0,0,1,0,1,1,0,1]
AND 연산 (비트 연산):
[1,0,1,0,1,0,1,1,0,1]
& [1,1,0,0,1,0,0,1,0,0]
& [0,0,0,0,1,0,1,1,0,1]
= [0,0,0,0,1,0,0,1,0,0]
결과: 문서 5, 8
세 개의 filter 조건을 조합하는 데 필요한 연산은 비트 AND 두 번입니다. 문서가 100만 건이든 1,000만 건이든, 비트 연산은 CPU가 극도로 빠르게 처리합니다. 이것이 filter가 must보다 빠른 근본적인 이유입니다.
must는 이 캐싱 메커니즘을 사용하지 않습니다. 점수가 쿼리마다 달라질 수 있고, 문서가 추가되면 점수도 바뀌기 때문에 결과를 캐시하기 어렵습니다.
| 측면 | must | filter |
|---|---|---|
| 매칭 | ✅ | ✅ |
| BM25 점수 계산 | ✅ | ❌ |
| Bitset 캐싱 | ❌ | ✅ |
| 두 번째 실행 속도 | 동일 | 훨씬 빠름 (캐시 히트 시) |
| 여러 조건 조합 | Posting List 교집합 연산 | 비트 연산 |
질문: 이 조건의 매칭 결과가 검색 순위(랭킹)에 영향을 줘야 하는가?
├─ Yes → must
│ "이 키워드가 얼마나 관련 있는가"가 중요한 경우
│ 예: 사용자가 입력한 검색어 매칭
└─ No → filter ⭐
"있다/없다"만 확인하면 되는 경우
예: 상태값, 날짜 범위, 카테고리, 환경 구분
실무에서 대부분의 조건은 filter에 해당합니다. status = "COMPLETED", date >= "2025-02-01", category = "electronics" 같은 조건은 모두 Yes/No 필터링입니다. must는 사용자가 직접 입력한 검색어를 텍스트 필드에서 매칭할 때 주로 사용합니다.
// ✅ 실무 권장 패턴: 대부분 filter, 검색어만 must
{
"query": {
"bool": {
"must": [
{ "match": { "message": "사용자 검색어" } }
],
"filter": [
{ "term": { "status": "ACTIVE" } },
{ "term": { "category": "electronics" } },
{ "range": { "price": { "gte": 10000, "lte": 50000 } } },
{ "range": { "@timestamp": { "gte": "now-7d" } } }
]
}
}
}
// ❌ 비효율적인 패턴: 모든 조건을 must에 넣기
{
"query": {
"bool": {
"must": [
{ "match": { "message": "사용자 검색어" } },
{ "term": { "status": "ACTIVE" } },
{ "term": { "category": "electronics" } },
{ "range": { "price": { "gte": 10000, "lte": 50000 } } }
]
}
}
}
아래 쿼리도 같은 문서를 반환합니다. 하지만 status, category, price 같은 조건에 대해 불필요한 점수 계산을 수행하고, Bitset 캐싱도 활용하지 못합니다.
should는 매칭되지 않아도 문서를 제외하지 않지만, 매칭되면 점수를 올려줍니다. 검색 결과의 순위를 미세 조정할 때 사용합니다.
// "주문"이 포함된 ERROR 로그를 찾되,
// "타임아웃"이 포함된 문서를 더 위에 올림
{
"query": {
"bool": {
"must": [
{ "match": { "message": "주문" } }
],
"filter": [
{ "term": { "level": "ERROR" } }
],
"should": [
{ "match": { "message": "타임아웃" } }
]
}
}
}
단, must나 filter가 없는 Boolean Query에서 should만 있으면, should 절 중 최소 하나는 매칭되어야 결과에 포함됩니다. 이 동작은 minimum_should_match 파라미터로 제어할 수 있습니다.
Boolean Query는 Elasticsearch 쿼리의 뼈대입니다. 이제 Boolean Query 안에서 사용할 수 있는 더 정밀한 쿼리를 살펴봅니다.
Match Query는 각 토큰이 문서에 존재하는지만 확인합니다. 토큰의 순서나 위치는 신경 쓰지 않습니다. 그런데 "주문 생성"을 검색했는데 "생성된 주문"도 매칭되어야 할까요? 경우에 따라 다르지만, 정확한 문구를 찾고 싶다면 Phrase Query가 필요합니다.
문서 1: "주문 생성에 실패했습니다"
문서 2: "생성된 주문을 확인합니다"
문서 3: "주문을 확인하고 생성합니다"
Match Query: "주문 생성" → 토큰 ["주문", "생성"]
문서 1: "주문" ✅ "생성" ✅ → 매칭 ✅
문서 2: "주문" ✅ "생성" ✅ → 매칭 ✅
문서 3: "주문" ✅ "생성" ✅ → 매칭 ✅
Phrase Query: "주문 생성" → 토큰 ["주문", "생성"] + 순서와 연속성 확인
문서 1: "주문"(pos 0) → "생성"(pos 1) → 연속 ✅ → 매칭 ✅
문서 2: "생성"(pos 0) → "주문"(pos 1) → 순서 반대 ❌ → 매칭 ❌
문서 3: "주문"(pos 0) → "생성"(pos 3) → 연속 아님 ❌ → 매칭 ❌
Phrase Query가 토큰의 순서와 연속성을 확인할 수 있는 이유는 Posting List에 문서 번호뿐 아니라 Position(위치) 정보도 저장되어 있기 때문입니다.
3편에서 Analyzer가 토큰을 생성할 때 각 토큰의 위치도 함께 기록한다고 했습니다. 이 위치 정보가 Phrase Query에서 활용됩니다.
문서 1: "주문 생성에 실패했습니다"
분석 결과: "주문"(pos:0), "생성"(pos:1), "실패"(pos:2)
Posting List (Position 포함):
┌──────────┬─────────────────────────────────────────┐
│ Token │ Posting List │
├──────────┼─────────────────────────────────────────┤
│ "주문" │ doc1(pos:0), doc2(pos:1), doc3(pos:0) │
│ "생성" │ doc1(pos:1), doc2(pos:0), doc3(pos:3) │
└──────────┴─────────────────────────────────────────┘
Phrase Query: "주문 생성"
Step 1: "주문"이 있는 문서 → doc1, doc2, doc3
Step 2: "생성"이 있는 문서 → doc1, doc2, doc3
Step 3: 교집합 → doc1, doc2, doc3
Step 4: Position 확인
doc1: "주문"(pos:0), "생성"(pos:1) → 차이 1, 연속 ✅
doc2: "주문"(pos:1), "생성"(pos:0) → 순서 역전 ❌
doc3: "주문"(pos:0), "생성"(pos:3) → 차이 3, 연속 아님 ❌
결과: doc1만 매칭
Position 정보는 Segment 파일 중 .pos 파일에 저장됩니다. Phrase Query가 실행되면 .doc 파일에서 Posting List를 가져온 뒤, .pos 파일에서 위치 정보를 추가로 읽어 순서와 연속성을 검증합니다.
// 정확한 문구 "주문 생성"을 포함한 문서 검색
GET /logs/_search
{
"query": {
"match_phrase": {
"message": "주문 생성"
}
}
}
완벽한 연속성까지는 필요 없고, "주문"과 "생성"이 가까이 있기만 하면 되는 경우 slop 파라미터로 허용 거리를 지정합니다.
// "주문"과 "생성" 사이에 최대 2개의 토큰이 끼어들어도 매칭
GET /logs/_search
{
"query": {
"match_phrase": {
"message": {
"query": "주문 생성",
"slop": 2
}
}
}
}
slop: 2
"주문 생성" → position 차이 1 → 매칭 ✅ (slop 0으로도 충분)
"주문을 생성" → position 차이 2 → 매칭 ✅ (slop 1 필요)
"주문 건을 새로 생성" → position 차이 3 → 매칭 ❌ (slop 2로는 부족)
Phrase Query는 Match Query보다 비용이 큽니다. 토큰의 존재를 확인한 뒤, 추가로 .pos 파일까지 읽어야 하기 때문입니다. 정확한 문구 매칭이 필요한 경우에만 사용하고, 단순한 키워드 검색에는 Match Query를 쓰는 것이 합리적입니다.
지금까지 살펴본 쿼리는 모두 역인덱스(Inverted Index)를 조회합니다. 그런데 "가격이 10,000원 이상 50,000원 이하인 상품"이나 "어제 발생한 로그"처럼 범위를 검색하는 경우, 역인덱스는 적합한 자료구조가 아닙니다.
역인덱스는 "이 토큰이 어느 문서에 있는가?"라는 질문에 최적화되어 있습니다. 정확한 값을 찾는 데는 탁월하지만, "10,000 이상 50,000 이하"처럼 범위를 표현하려면 해당 범위에 해당하는 모든 값을 하나씩 조회해야 합니다.
역인덱스로 price >= 10000 AND price <= 50000 을 찾으려면:
Term Dictionary
┌──────────┬───────────────┐
│ Token │ Posting List │
├──────────┼───────────────┤
│ 5000 │ [3, 8] │
│ 10000 │ [1, 4] │ ← 여기서부터
│ 15000 │ [2, 7] │
│ 20000 │ [5] │
│ 30000 │ [6, 9] │
│ 50000 │ [10] │ ← 여기까지
│ 80000 │ [11] │
└──────────┴───────────────┘
10000, 15000, 20000, 30000, 50000을 각각 조회해서 합쳐야 함
→ 값의 종류가 많으면 비효율적
Lucene은 숫자, 날짜, IP 주소 같은 필드에 역인덱스 대신 BKD Tree(Block K-Dimensional Tree)를 사용합니다. BKD Tree는 다차원 공간의 점(point) 데이터를 효율적으로 저장하고 범위 검색하는 자료구조입니다.
BKD Tree로 price >= 10000 AND price <= 50000 을 찾으면:
[30000]
/ \
[15000] [60000]
/ \ / \
[10000] [20000] [50000] [80000]
doc1 doc5 doc10 doc11
doc4 doc6
doc2 doc9
doc7
트리 탐색:
루트(30000): 10000~50000은 양쪽 모두에 걸침 → 좌/우 모두 탐색
좌측(15000): 10000~50000 범위 안 → 하위 노드 탐색
우측(60000): 50000 이하만 필요 → 좌측만 탐색
전체 스캔 없이 트리를 타고 내려가며 해당 범위의 문서만 수집
핵심은 전체 값을 하나씩 확인하지 않고, 트리를 탐색하면서 범위에 해당하지 않는 부분을 건너뛴다는 것입니다. 이것은 RDB의 B-Tree 인덱스가 범위 검색을 효율적으로 처리하는 원리와 유사합니다.
// 날짜 범위 검색
GET /order-logs-*/_search
{
"query": {
"range": {
"@timestamp": {
"gte": "2025-02-20T00:00:00",
"lt": "2025-02-21T00:00:00",
"format": "strict_date_optional_time"
}
}
}
}
// 숫자 범위 검색
GET /products/_search
{
"query": {
"range": {
"price": {
"gte": 10000,
"lte": 50000
}
}
}
}
Range Query는 Boolean Query의 filter 절에 넣는 것이 일반적입니다. "어제 날짜"라는 범위 조건에 관련도 점수를 매기는 것은 의미가 없기 때문입니다.
Lucene은 필드 타입에 따라 서로 다른 자료구조를 사용합니다. 하나의 필드가 여러 자료구조에 동시에 저장될 수도 있습니다.
| 필드 타입 | Inverted Index | BKD Tree | Doc Values |
|---|---|---|---|
text | ✅ (토큰 분해 후 저장) | ❌ | ❌ |
keyword | ✅ (원본 통째로 저장) | ❌ | ✅ |
long, integer | ❌ | ✅ | ✅ |
date | ❌ | ✅ | ✅ |
ip | ❌ | ✅ | ✅ |
boolean | ✅ | ❌ | ✅ |
┌─────────────────────────────────────────────────────────────────┐
│ 자료구조별 역할 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Inverted Index (역인덱스) │
│ ├─ 질문: "이 단어가 어느 문서에 있나?" │
│ ├─ 대상: text, keyword │
│ └─ 용도: 텍스트 검색, 정확 매칭 │
│ │
│ BKD Tree │
│ ├─ 질문: "이 범위에 어떤 값이 있나?" │
│ ├─ 대상: long, date, ip │
│ └─ 용도: 범위 검색 │
│ │
│ Doc Values │
│ ├─ 질문: "이 문서의 이 필드 값이 뭔가?" │
│ ├─ 대상: keyword, long, date 등 (text 제외) │
│ └─ 용도: 정렬, 집계 (GROUP BY, ORDER BY에 해당) │
│ │
└─────────────────────────────────────────────────────────────────┘
왜 이렇게 자료구조를 나눴을까요? 모든 것을 하나의 자료구조로 처리할 수 있다면 단순할 것입니다. 하지만 각 자료구조는 특정 유형의 질문에 최적화되어 있고, 다른 유형의 질문에는 비효율적입니다. 역인덱스는 텍스트 검색에 탁월하지만 범위 검색에 약하고, BKD Tree는 범위 검색에 탁월하지만 텍스트 검색을 할 수 없습니다. Lucene은 필드 타입에 따라 가장 적합한 자료구조를 자동으로 선택합니다.
이 섹션에서는 두 가지 주제를 다룹니다. 오타를 허용하는 Fuzzy Query와, Lucene이 검색 결과의 순위를 매기는 BM25 스코어링입니다. 이 두 주제를 함께 다루는 이유는, Fuzzy Query가 BM25 점수와 결합되어 동작하기 때문이기도 하지만, 더 중요한 이유는 이 두 개념이 Elasticsearch와 RDB의 근본적인 차이를 보여주기 때문입니다.
사용자가 "OrdeService"라고 입력했을 때, "OrderService"를 찾아줄 수 있을까요? Fuzzy Query는 Levenshtein Distance(편집 거리)를 기반으로 오타를 허용합니다.
편집 거리란 한 문자열을 다른 문자열로 바꾸는 데 필요한 최소 편집 횟수입니다. 편집 연산은 삽입, 삭제, 치환 세 가지입니다.
"OrdeService" → "OrderService"
OrdeService
Orde_Service ← 'r' 삽입 (1회)
OrderService
편집 거리: 1
"OrdService" → "OrderService"
OrdService
OrdeService ← 'e' 삽입 (1회)
OrderService ← 'r' 삽입 (2회)
편집 거리: 2
// Fuzzy Query 예시
GET /logs/_search
{
"query": {
"fuzzy": {
"class_name": {
"value": "OrdeService",
"fuzziness": "AUTO"
}
}
}
}
fuzziness를 숫자로 지정할 수도 있지만, "AUTO"를 쓰면 단어 길이에 따라 허용 오차를 자동으로 조절합니다.
| 단어 길이 | 허용 편집 거리 | 이유 |
|---|---|---|
| 0~2 글자 | 0 (정확 매칭) | 짧은 단어에서 1글자만 바뀌면 완전히 다른 단어 |
| 3~5 글자 | 1 | 예: "cat" → "car" |
| 6 글자 이상 | 2 | 예: "OrderService" → "OrdeService" |
"AUTO"가 합리적인 기본값인 이유는, "OK"를 Fuzzy 검색했을 때 "NO"가 매칭되는 것은 의미가 없기 때문입니다. 짧은 단어일수록 편집 거리 1이 전혀 다른 단어를 의미할 확률이 높습니다.
Fuzzy Query는 검색어와 편집 거리 내에 있는 모든 토큰을 역인덱스의 Term Dictionary에서 찾습니다. Lucene은 FST(Finite State Transducer) 위에서 Levenshtein Automaton을 실행하여, 편집 거리 내의 토큰을 효율적으로 탐색합니다.
검색어: "OrdeService", fuzziness: 1
Term Dictionary (FST)에서 편집 거리 1 이내의 토큰 탐색:
"OrderService" → 편집 거리 1 ✅ → Posting List [1, 3, 5]
"OrdService" → 편집 거리 1 ✅ → Posting List [2]
"OrdeServices" → 편집 거리 1 ✅ → Posting List [7]
"PaymentService" → 편집 거리 6 ❌ → 건너뜀
결과: 문서 1, 2, 3, 5, 7
모든 토큰을 일일이 비교하는 것이 아니라, FST의 구조를 활용해서 가지치기(pruning)를 하기 때문에 전체 토큰 수에 비해 효율적입니다. 다만, 편집 거리가 클수록 탐색해야 할 후보가 기하급수적으로 늘어나므로 fuzziness는 최대 2로 제한됩니다.
지금까지 살펴본 쿼리 중 must에 들어가는 쿼리는 매칭 여부뿐 아니라 관련도 점수(relevance score)를 계산합니다. 이 점수를 매기는 알고리즘이 BM25(Best Matching 25)입니다.
RDB의 WHERE 절은 조건에 맞는 행을 있다/없다로 구분합니다. 검색 엔진은 다릅니다. 조건에 맞는 문서를 찾은 뒤, 얼마나 관련이 있는가에 따라 점수를 매기고 순위를 정합니다.
RDB:
SELECT * FROM logs WHERE message LIKE '%주문%'
→ 결과: 행 A, 행 B, 행 C (순서는 삽입 순서나 PK 순)
Elasticsearch:
{ "match": { "message": "주문" } }
→ 결과: 문서 B (score: 3.2), 문서 A (score: 1.8), 문서 C (score: 0.9)
→ 점수가 높은 순으로 정렬
BM25는 세 가지 요소를 고려합니다.
1) TF (Term Frequency) — 해당 문서에서의 출현 빈도
검색어 토큰이 문서에 많이 등장할수록 점수가 높습니다.
검색어: "주문"
문서 A: "주문이 생성되었습니다" → "주문" 1회 → TF 낮음
문서 B: "주문 생성 후 주문 확인 필요" → "주문" 2회 → TF 높음
단, TF는 비선형적으로 증가합니다. "주문"이 10번 나온 문서가 1번 나온 문서보다 10배 관련이 있지는 않습니다. BM25는 이 포화(saturation) 효과를 반영합니다.
2) IDF (Inverse Document Frequency) — 전체 문서에서의 희귀도
전체 문서에서 드물게 등장하는 토큰일수록 점수가 높습니다. 직관적으로, "the"같이 모든 문서에 등장하는 단어는 검색에 별 도움이 안 되고, "OutOfMemoryError"처럼 특정 문서에만 등장하는 단어가 더 유의미합니다.
전체 문서 1,000건 중:
"에러" → 800건에 등장 → IDF 낮음 (흔한 단어)
"OOM" → 5건에 등장 → IDF 높음 (희귀한 단어) ⭐
3) 문서 길이 — 짧은 문서일수록 높은 점수
같은 토큰이 1회 등장했더라도, 10단어짜리 문서에 나온 것이 1,000단어짜리 문서에 나온 것보다 관련성이 높다고 봅니다. 짧은 문서에서의 1회 등장은 그 문서의 주제와 관련이 있을 가능성이 높지만, 긴 문서에서의 1회 등장은 우연일 수 있기 때문입니다.
검색어: "OOM"
문서 A (10 토큰): "OOM 에러 발생"
→ 문서의 10%가 검색어 → 점수 높음
문서 B (500 토큰): "... 중략 ... OOM이 한 번 발생했지만 ... 중략 ..."
→ 문서의 0.2%가 검색어 → 점수 낮음
수식 자체를 외울 필요는 없지만, 각 요소가 어떻게 결합되는지 직관적으로 이해하면 검색 결과의 순위를 예측하는 데 도움이 됩니다.
BM25(문서, 쿼리) = 각 토큰에 대해 합산:
IDF(토큰) × ───────────────── TF(토큰, 문서) × (k1 + 1) ─────────────────
TF(토큰, 문서) + k1 × (1 - b + b × 문서길이 / 평균문서길이)
- k1 (기본값 1.2): TF 포화 속도 조절. 클수록 TF의 영향이 오래 지속
- b (기본값 0.75): 문서 길이 보정 강도. 0이면 길이 무시, 1이면 강하게 보정
| 요소 | 높을 때 점수 | 낮을 때 점수 |
|---|---|---|
| TF (출현 빈도) | 높음 (포화 있음) | 낮음 |
| IDF (희귀도) | 높음 | 낮음 |
| 문서 길이 | 낮음 | 높음 |
실무에서 BM25 파라미터를 직접 조정하는 일은 드뭅니다. 하지만 "왜 이 문서가 저 문서보다 위에 올라왔는가?"를 디버깅할 때 _explain API를 쓰면, BM25의 각 요소별 점수를 확인할 수 있습니다.
// 특정 문서의 점수가 왜 이렇게 나왔는지 확인
GET /logs/_explain/doc1
{
"query": {
"match": {
"message": "주문 타임아웃"
}
}
}
이 차이를 정리하면 Elasticsearch가 왜 "검색 엔진"인지가 명확해집니다.
| 측면 | RDB (WHERE 절) | Elasticsearch (BM25) |
|---|---|---|
| 결과 판단 | 있다/없다 (Boolean) | 점수 기반 순위 (Ranking) |
| 정렬 기본값 | PK 또는 지정 컬럼 | 관련도 점수 (_score) |
| "주문" 검색 시 | "주문"이 포함된 행 전부, 순서 없음 | "주문"과 가장 관련 높은 문서부터 |
| 오타 처리 | 없음 (정확 매칭만) | Fuzzy로 편집 거리 내 매칭 |
| 설계 목적 | 데이터 정합성과 트랜잭션 | 텍스트 관련도와 검색 속도 |
RDB가 "이 조건에 맞는 데이터가 있는가?"라는 질문에 답한다면, Elasticsearch는 "이 검색어와 가장 관련 있는 문서가 무엇인가?"라는 질문에 답합니다. 근본적으로 다른 질문이고, 그래서 내부 구조도 다릅니다.
이 글에서 다룬 쿼리들을 되돌아보면, 하나의 패턴이 보입니다.
┌────────────────────────────────────────────────────────────┐
│ 쿼리 → 자료구조 매핑 │
├────────────────────────────────────────────────────────────┤
│ │
│ Term Query, Match Query → Inverted Index (역인덱스) │
│ Phrase Query → Inverted Index + Position │
│ Range Query → BKD Tree │
│ Boolean Query (filter) → Bitset Cache │
│ 정렬, 집계 → Doc Values │
│ │
└────────────────────────────────────────────────────────────┘
쿼리를 작성하는 것은 단순히 검색 조건을 나열하는 것이 아닙니다. 어떤 쿼리를 쓰느냐에 따라 Lucene이 어떤 자료구조를 읽을지, 어떤 파일에 접근할지, 캐시를 활용할 수 있는지가 결정됩니다.
filter에 넣을 수 있는 조건을 must에 넣으면 Bitset 캐싱을 포기하는 것이고, keyword 필드에 Match Query를 쓰면 불필요하게 Analyzer를 거치는 것이며, text 필드에 Term Query를 쓰면 역인덱스에 없는 토큰을 찾는 것입니다.
결국 중요한 것은 쿼리 DSL의 문법이 아니라, 내가 검색하려는 필드가 어떤 자료구조로 저장되어 있고, 어떤 쿼리가 그 자료구조를 효율적으로 활용하는가를 이해하는 것입니다.
5편에서는 이 자료구조들의 물리적 형태를 다룹니다. FST가 어떻게 토큰을 메모리에서 O(토큰 길이)로 찾아내는지, Posting List가 어떻게 압축되는지, Bitset이 대규모 문서에서 어떻게 Roaring Bitmap으로 최적화되는지를 살펴봅니다.