[ELK Deep Dive] Lucene — 성능 최적화
이 글에서 제시하는 수치는 일반적인 운영 환경에서 관측되는 대략적인 범위입니다.
실제 성능은 하드웨어, 워크로드, 설정에 따라 달라질 수 있습니다.
PART 1. 물리적 구조
1. Segment 파일 전체 조감도
PART 2. 핵심 자료구조
2. FST (Finite State Transducer) — Term Dictionary
3. Posting List 최적화
PART 3. 필터링과 집합 연산
4. Bitset과 Filter Cache
5. Roaring Bitmap — 대규모 필터링
PART 4. 특수 목적 저장소
6. Doc Values — 컬럼 지향 저장소
7. BKD Tree — 범위 검색용 자료구조
PART 5. 동시성과 마무리
8. NRT Reader — 쓰기 중 검색 무중단
9. 마무리 — Lucene이 빠른 이유는 하나가 아니다
4편까지 역인덱스, Analyzer, 쿼리의 동작을 살펴봤습니다. 그런데 이 모든 것이 디스크에는 실제로 어떤 형태로 존재하는 걸까요? 이 섹션에서는 Segment를 구성하는 물리적 파일들을 하나씩 펼쳐 봅니다.
2편에서 Segment는 "불변의 검색 단위"라고 했습니다. 그 Segment 하나는 실제로 여러 개의 파일로 구성됩니다. 각 파일은 서로 다른 역할을 담당합니다.
┌─────────────────────────────────────────────────────────────────┐
│ Segment (하나의 불변 검색 단위) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ 역할: 토큰 → offset 매핑 (사전) │
│ │ .tip / .tim │ Term Dictionary (FST) │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ 역할: 토큰을 포함하는 문서 번호 목록 │
│ │ .doc │ Posting List │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ 역할: 토큰의 문서 내 위치 (구문 검색용) │
│ │ .pos │ Position 정보 │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ 역할: 원본 JSON 저장 (_source) │
│ │ .fdt / .fdx │ Stored Fields │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ 역할: 정렬/집계용 컬럼 스토어 │
│ │ .dvd / .dvm │ Doc Values │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ 역할: 숫자/날짜 범위 검색 (BKD Tree ) │
│ │ .kdd / .kdi │ Point Values │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ 역할: 삭제되지 않은 문서 비트셋 │
│ │ .liv │ Live Documents │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
파일 하나하나를 표로 정리합니다.
| 파일 | 이름 | 역할 | 저장 위치 |
|---|---|---|---|
.tip | Term Index | FST 자료구조. 토큰의 존재 확인 + .tim의 offset | 메모리 (힙) |
.tim | Term Dictionary | 토큰의 메타데이터 + Posting List offset | 디스크 |
.doc | Frequencies | 토큰을 포함하는 문서 번호 목록 (Posting List) | 디스크 |
.pos | Positions | 토큰이 문서 내 어디에 위치하는지 (Phrase Query용) | 디스크 |
.fdt | Field Data | Stored Fields 원본 값 (_source JSON) | 디스크 |
.fdx | Field Index | .fdt 내 문서별 offset 인덱스 | 디스크 |
.dvd | Doc Values Data | 필드별 값 (정렬/집계용 컬럼 데이터) | 디스크 |
.dvm | Doc Values Meta | Doc Values의 메타 정보 | 디스크 |
.kdd | Point Data | BKD Tree 리프 노드 데이터 | 디스크 |
.kdi | Point Index | BKD Tree 내부 노드 (인덱스) | 디스크 |
.liv | Live Docs | 삭제되지 않은 문서의 비트셋 | 메모리 |
왜 하나의 파일이 아니라 이렇게 많은 파일로 나누어져 있을까요? 그 답은 바로 다음에 나옵니다.
"status가 ERROR인 문서 중, message에 timeout이 포함된 것을 createdAt 역순으로 10건 가져와라." 이런 쿼리가 들어왔을 때, Lucene은 어떤 파일을 어떤 순서로 읽을까요?
┌─────────────────────────────────────────────────────────────────────┐
│ 검색 흐름: "timeout" 텍스트 검색 + status 필터 + 시간순 정렬 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Step 1. 토큰 조회 │
│ ┌──────────┐ │
│ │ .tip │ FST (메모리) │
│ │ │ "timeout" 토큰이 존재하는지 확인 │
│ │ │ → 존재하면 .tim의 offset 반환 │
│ └────┬─────┘ │
│ ▼ │
│ Step 2. Posting List 획득 │
│ ┌──────────┐ │
│ │ .doc │ 디스크 │
│ │ │ offset으로 점프하여 Posting List 읽기 │
│ │ │ → [Doc 12, Doc 47, Doc 103, Doc 298, ...] │
│ └────┬─────┘ │
│ ▼ │
│ Step 3. 삭제 문서 제외 │
│ ┌──────────┐ │
│ │ .liv │ 메모리 │
│ │ │ 비트셋으로 삭제된 문서를 제외 │
│ │ │ → Doc 47이 삭제됨 → [Doc 12, Doc 103, Doc 298, ...] │
│ └────┬─────┘ │
│ ▼ │
│ Step 4. 필터 / 정렬 │
│ ┌──────────┐ │
│ │ .dvd │ 디스크 │
│ │ │ status 컬럼에서 "ERROR"인 문서만 필터 │
│ │ │ createdAt 컬럼으로 역순 정렬 │
│ │ │ → 상위 10건 선택 │
│ └────┬─────┘ │
│ ▼ │
│ Step 5. 원본 반환 │
│ ┌──────────┐ │
│ │ .fdt │ 디스크 │
│ │ │ 최종 10건의 _source JSON을 읽어서 반환 │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
이 흐름에서 주목할 점이 있습니다. .pos 파일은 읽지 않았습니다. Phrase Query가 아니니까 토큰의 위치 정보가 필요 없기 때문입니다. .kdd / .kdi 파일도 읽지 않았습니다. Range Query가 아니니까 BKD Tree가 필요 없기 때문입니다.
왜 모든 데이터를 하나의 파일에 넣지 않고 이렇게 나눴을까요? 답은 I/O 최소화입니다.
하나의 거대한 파일에 모든 것을 담으면, 단순한 Term Query에서도 Position 정보, Stored Fields, Doc Values까지 전부 읽어야 합니다. 파일이 분리되어 있으면, 검색 조건에 따라 필요한 파일만 골라서 읽습니다.
| 쿼리 유형 | 읽는 파일 | 읽지 않는 파일 |
|---|---|---|
| Term Query (단순 매칭) | .tip, .doc, .liv | .pos, .dvd, .kdd |
| Phrase Query (구문 검색) | .tip, .doc, .pos, .liv | .dvd, .kdd |
| Range Query (범위 검색) | .kdd, .kdi, .liv | .tip, .doc, .pos |
| 정렬/집계 포함 검색 | .tip, .doc, .liv, .dvd | .pos, .kdd |
| 최종 결과 반환 시 | 위 결과 + .fdt | — |
디스크 I/O는 검색 성능에서 가장 비용이 큰 작업입니다. 파일을 역할별로 분리함으로써, 각 쿼리가 정말 필요한 데이터만 디스크에서 읽게 됩니다. 이것이 Lucene 파일 구조 설계의 핵심 철학입니다.
핵심 정리: Segment는 역할별로 분리된 여러 파일의 집합이며, 검색 조건에 따라 필요한 파일만 읽어서 I/O를 최소화합니다. 이제 각 파일 안에 어떤 자료구조가 들어 있는지 하나씩 살펴봅니다.
역인덱스에서 "토큰으로 문서를 찾는다"고 했습니다. 그런데 토큰 자체를 찾는 것은 어떻게 할까요? 수백만 개의 토큰 중에서 "timeout"이라는 토큰이 존재하는지, 존재한다면 Posting List가 디스크 어디에 있는지를 알아야 합니다. 이 역할을 하는 자료구조가 FST입니다.
.tip 파일에 저장되는 FST는 토큰 문자열을 입력으로 받아, 해당 토큰의 Posting List가 위치한 디스크 offset을 반환하는 자료구조입니다.
┌──────────────────────────────────────────────────────┐
│ FST의 역할 │
│ │
│ 입력: "timeout" (토큰 문자열) │
│ │
│ ┌─────────┐ │
│ │ FST │ │
│ │ (.tip) │ │
│ └────┬────┘ │
│ │ │
│ ▼ │
│ │
│ 출력: offset 48720 (.doc 파일에서 Posting List 시작)│
│ │
└──────────────────────────────────────────────────────┘
이 변환이 끝나면, Lucene은 .doc 파일의 48720번째 바이트로 바로 점프해서 Posting List를 읽습니다. 순차 탐색이 아니라 정확한 위치로의 직접 접근입니다.
FST를 이해하려면 먼저 Trie부터 봐야 합니다.
Trie: 접두사(prefix) 공유
Trie는 문자열의 공통 접두사를 공유하는 트리 자료구조입니다. "order", "orderId", "orderStatus"라는 세 토큰이 있다고 합니다.
Trie — 접두사 공유
(root)
│
o
│
r
│
d
│
e
│
r ──────── [order]
/ \
I S
│ │
d t
│ │
─ a
│
t
│
u
│
s ───── [orderStatus]
* orderId도 이어지지만 생략
"order"라는 접두사가 세 토큰에서 공유됩니다. 이 공유 덕분에 "order"를 세 번 저장하지 않고 한 번만 저장합니다.
하지만 Trie에는 한계가 있습니다. 접두사만 공유할 뿐, 접미사는 공유하지 못합니다. "monday"와 "sunday"는 "day"라는 공통 접미사가 있지만, Trie에서는 별도로 저장됩니다.
FST: 접두사 + 접미사 공유
FST는 접두사뿐 아니라 접미사까지 공유합니다. 그리고 각 경로에 output(값)을 부착할 수 있습니다.
"order", "orderId", "orderStatus", "timeout", "timestamp"라는 다섯 토큰이 있고, 각각의 Posting List offset이 다음과 같다고 합니다.
| 토큰 | Posting List offset |
|---|---|
| order | 100 |
| orderId | 200 |
| orderStatus | 300 |
| timeout | 400 |
| timestamp | 500 |
FST는 이 매핑을 다음과 같은 구조로 압축합니다.
FST — 접두사 + 접미사 공유 + output 부착
(start)
/ \
o/0 t/0
/ \
r/0 im/0
| / \
d/0 e/400 e/500
| | |
e/0 out/0 stamp/0
| │
r/100 ─┘
/ \
I/100 S/200
| |
d/0 tatus/0
| |
─┘ ─┘
"order" = 0+0+0+0+100 = 100 ✅
"orderId" = 0+0+0+0+100+100+0 = 200 ✅
"orderStatus" = 0+0+0+0+100+200+0 = 300 ✅
"timeout" = 0+0+400+0 = 400 ✅
"timestamp" = 0+0+500+0 = 500 ✅
핵심은 두 가지입니다.
첫째, 공유가 극대화됩니다. "order"라는 접두사는 한 번만 저장됩니다. "time"이라는 접두사도 "timeout"과 "timestamp"가 공유합니다. 접미사 방향에서도 공통 부분이 합쳐집니다.
둘째, 경로를 따라가면서 output을 누적하면 최종 offset을 알 수 있습니다. "order"를 조회하려면 o → r → d → e → r 경로를 따라가면서 각 간선의 output을 더하면 100이 나옵니다. 이 100이 .doc 파일에서 해당 토큰의 Posting List가 시작되는 위치입니다.
Trie와 FST의 차이를 정리합니다.
| 특성 | Trie | FST |
|---|---|---|
| 접두사 공유 | ✅ | ✅ |
| 접미사 공유 | ❌ | ✅ |
| 값(output) 부착 | ❌ (별도 저장 필요) | ✅ (경로에 누적) |
| 메모리 효율 | 보통 | 극도로 높음 |
| 조회 시간 | O(토큰 길이) | O(토큰 길이) |
FST의 가장 중요한 특성은 메모리에 올릴 수 있을 만큼 작다는 것입니다.
접두사와 접미사를 모두 공유하기 때문에, 수백만 개의 토큰이 있어도 FST의 크기는 수십 MB 수준에 머뭅니다. 이 정도면 JVM 힙 메모리에 충분히 올릴 수 있습니다.
이것이 왜 중요한가요? 토큰을 조회할 때 디스크 I/O가 전혀 발생하지 않기 때문입니다.
┌─────────────────────────────────────────────────────────────┐
│ 토큰 조회 과정 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 만약 FST가 디스크에 있다면: │
│ "timeout" 조회 → 디스크 I/O (수 ms) → offset 획득 │
│ │
│ FST가 메모리에 있으므로: │
│ "timeout" 조회 → 메모리 접근 (수 ns) → offset 획득 │
│ │
│ 차이: 약 1,000배 ~ 10,000배 │
│ │
└─────────────────────────────────────────────────────────────┘
그리고 조회 시간은 O(토큰 길이)입니다. "timeout"은 7글자이므로 7번의 상태 전이로 조회가 끝납니다. Segment에 토큰이 100만 개든 1억 개든, "timeout"이라는 토큰을 찾는 데 걸리는 시간은 동일합니다. 전체 토큰 수와 무관합니다.
이것이 1편에서 "역인덱스 조회가 O(1)에 가까운 이유는 5편에서 다룬다"고 했던 그 답입니다. FST 덕분에 토큰의 존재 확인과 디스크 위치 파악이 메모리에서 즉시 이루어집니다.
핵심 정리: FST는 접두사와 접미사를 모두 공유하여 메모리에 올릴 수 있을 만큼 작고, 토큰 조회를 디스크 I/O 없이 O(토큰 길이)로 처리합니다. 이제 FST가 가리킨 디스크 위치에서 읽어오는 Posting List가 어떻게 최적화되어 있는지 살펴봅니다.
FST로 토큰의 디스크 위치를 찾았습니다. 이제 .doc 파일에서 Posting List를 읽어야 합니다. Posting List는 해당 토큰을 포함하는 문서 번호의 목록입니다. 그런데 인기 있는 토큰은 수백만 건의 문서에 등장할 수 있습니다. 이 목록을 어떻게 효율적으로 저장하고 탐색할까요?
Posting List를 그대로 저장하면 낭비가 큽니다. 문서 번호가 [1000, 1003, 1005, 1010, 1015, 1100]이라면, 각 번호를 32비트 정수로 저장하면 6 x 4바이트 = 24바이트입니다. 문서가 100만 건이면 4MB입니다. 인기 토큰이 수천 개 있다면 Posting List만으로 GB 단위의 디스크 공간을 차지합니다.
Lucene은 FOR (Frame of Reference) 압축으로 이 문제를 해결합니다. 두 단계로 나뉩니다.
Step 1: Delta Encoding
절대값 대신, 이전 값과의 차이(delta)만 저장합니다.
원본 Posting List:
[1000, 1003, 1005, 1010, 1015, 1100]
Delta Encoding 적용:
[1000, 3, 2, 5, 5, 85]
↑ ↑ ↑ ↑ ↑
1003 1005 1010 1015 1100
-1000 -1003 -1005 -1010 -1015
Posting List는 항상 정렬되어 있으므로, 델타값은 항상 양수이고 대부분 작은 수입니다. 원본은 1000 이상의 숫자였지만, 델타로 바꾸면 대부분 한 자릿수 ~ 두 자릿수가 됩니다.
Step 2: Bit Packing
델타값에 필요한 비트 수만 사용합니다.
┌────────────────────────────────────────────────────────────┐
│ Bit Packing │
├────────────────────────────────────────────────────────────┤
│ │
│ 첫 번째 값(1000)은 별도 저장 │
│ │
│ 나머지 델타: [3, 2, 5, 5, 85] │
│ │
│ 최대 델타 = 85 → 이진수로 1010101 → 7비트 필요 │
│ │
│ 32비트 정수로 저장하면: │
│ 00000000 00000000 00000000 00000011 = 3 (25비트 낭비) │
│ 00000000 00000000 00000000 00000010 = 2 (25비트 낭비) │
│ │
│ 7비트로 패킹하면: │
│ 0000011 = 3 │
│ 0000010 = 2 │
│ 0000101 = 5 │
│ 0000101 = 5 │
│ 1010101 = 85 │
│ │
│ 공간: 5 x 7비트 = 35비트 (≈ 5바이트) │
│ vs 원본: 5 x 32비트 = 160비트 (20바이트) │
│ │
│ 절약률: 약 75% │
│ │
└────────────────────────────────────────────────────────────┘
실제로 Lucene은 Posting List를 128개 단위 블록으로 나누고, 각 블록 내에서 최대 델타값에 맞춰 비트 수를 결정합니다. 블록마다 비트 수가 다를 수 있습니다. 연속된 문서 번호가 많은 블록은 1~2비트만 필요하고, 듬성듬성한 블록은 더 많은 비트가 필요합니다.
FOR 압축의 효과는 두 가지입니다.
| 효과 | 설명 |
|---|---|
| 디스크 공간 절약 | 32비트 → 평균 6~8비트 수준으로 줄어들어 약 75~80% 절약 |
| 로딩 속도 향상 | 디스크에서 메모리로 읽어야 할 데이터 자체가 줄어들어 I/O 시간 감소 |
4편에서 Boolean Query의 must 절은 여러 토큰의 Posting List 교집합을 구한다고 했습니다. 두 Posting List의 교집합을 구하는 가장 단순한 방법은 순차 비교입니다.
Posting List A (짧음, "timeout"): [5, 12, 47, 103, 298]
Posting List B (긴, "error"): [1, 3, 5, 8, 12, 15, 20, 25, 30, ...중략... 290, 295, 298, 300]
순차 비교라면 A의 각 원소에 대해 B를 처음부터 순서대로 훑어야 합니다. B가 100만 건이면 최악의 경우 O(N+M)입니다. 작동은 하지만, B가 길수록 불필요한 비교가 많아집니다.
Skip List의 아이디어
Skip List는 Posting List에 건너뛰기 포인터를 추가합니다. 일정 간격마다 "여기부터 뛰어넘으면 어디로 간다"는 정보를 미리 넣어두는 것입니다.
┌─────────────────────────────────────────────────────────────────────┐
│ Skip List │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Posting List B (긴 리스트, "error"): │
│ │
│ Level 2: [1] ─────────────────────────▶ [100] ──────────▶ [290] │
│ │ │ │ │
│ Level 1: [1] ─────▶ [30] ─────▶ [60] ─▶ [100] ──▶ [200]─▶[290] │
│ │ │ │ │ │ │ │
│ Level 0: [1,3,5,8,12,15,20,25,30,35,... 100,... 200,... 290,...│
│ │
│ │
│ A에서 다음 값 = 103 │
│ → Level 2: 1 < 103, skip to 100. 100 < 103, skip to 290. │
│ 290 > 103이므로 한 단계 아래로 │
│ → Level 1: 100에서 시작, 200 > 103이므로 한 단계 아래로 │
│ → Level 0: 100, 101, 102, 103 발견! │
│ │
│ 중간의 수천 개를 건너뛰고 바로 근처로 점프 │
│ │
└─────────────────────────────────────────────────────────────────────┘
Lucene의 Skip 전략
Lucene은 교집합을 구할 때 항상 짧은 Posting List를 기준으로 순회합니다. 짧은 리스트의 각 문서 번호에 대해, 긴 리스트에서 Skip으로 점프하며 해당 번호가 있는지 확인합니다.
A (짧음, 5건): [5, 12, 47, 103, 298] ← 이걸 기준으로 순회
B (긴, 100만건): [1, 3, 5, 8, 12, ...] ← 이쪽에서 Skip
순서:
A의 5 → B에서 Skip으로 5를 찾음 → 교집합에 추가
A의 12 → B에서 5 이후부터 Skip → 12 발견 → 교집합에 추가
A의 47 → B에서 12 이후부터 Skip → 47 없음 → 건너뜀
A의 103 → B에서 Skip → 103 발견 → 교집합에 추가
A의 298 → B에서 Skip → 298 발견 → 교집합에 추가
결과: [5, 12, 103, 298]
A가 5건이면, B가 100만 건이더라도 Skip을 통해 실제로 비교하는 횟수는 수십 회 수준에 그칩니다. 긴 리스트의 대부분을 건너뛰기 때문입니다.
| 방식 | 시간 복잡도 | B가 100만 건일 때 |
|---|---|---|
| 순차 비교 | O(N + M) | 약 100만 번 비교 |
| Skip List | O(N x log M) | 약 5 x 20 = 100번 비교 |
핵심 정리: Posting List는 FOR 압축으로 디스크 공간과 I/O를 줄이고, Skip List로 교집합 연산을 수십 배 빠르게 만듭니다. 이제 Posting List의 결과를 더 빠르게 조합하는 Bitset 연산을 살펴봅니다.
4편에서 Boolean Query의 filter 절은 점수를 계산하지 않고, 결과를 캐싱한다고 했습니다. 이 캐싱의 핵심이 Bitset입니다.
Segment에 8개의 문서가 있고, filter: { term: { status: "ERROR" } } 조건을 적용했더니 Doc 0, Doc 3, Doc 7이 매칭되었다고 합니다. 이 결과를 Bitset으로 표현하면 이렇습니다.
┌────────────────────────────────────────────────────────────┐
│ Bitset 표현 │
├────────────────────────────────────────────────────────────┤
│ │
│ 문서: Doc0 Doc1 Doc2 Doc3 Doc4 Doc5 Doc6 Doc7 │
│ Bitset: [1, 0, 0, 1, 0, 0, 0, 1] │
│ ↑ ↑ ↑ │
│ 매칭 매칭 매칭 │
│ │
│ 1 = 조건에 매칭하는 문서 │
│ 0 = 조건에 매칭하지 않는 문서 │
│ │
└────────────────────────────────────────────────────────────┘
문서 하나당 1비트만 사용합니다. 100만 건의 문서라면 Bitset은 약 122KB입니다.
Bitset의 진짜 위력은 여러 필터 조건을 결합할 때 나옵니다.
필터 A: status = "ERROR" → [1, 0, 0, 1, 0, 0, 0, 1]
필터 B: level >= "WARN" → [1, 1, 0, 1, 0, 1, 0, 1]
AND (둘 다 만족):
[1, 0, 0, 1, 0, 0, 0, 1]
& [1, 1, 0, 1, 0, 1, 0, 1]
= [1, 0, 0, 1, 0, 0, 0, 1]
OR (하나라도 만족):
[1, 0, 0, 1, 0, 0, 0, 1]
| [1, 1, 0, 1, 0, 1, 0, 1]
= [1, 1, 0, 1, 0, 1, 0, 1]
CPU의 비트 연산(AND, OR, NOT)은 하나의 명령어로 64비트를 동시에 처리합니다. 문서가 100만 건이어도 Bitset은 약 15,625개의 long(64비트)으로 표현되고, AND 연산은 15,625번의 CPU 명령어로 끝납니다. 나노초 단위입니다.
이것이 4편에서 "filter가 must보다 빠른 이유: Bitset 캐싱"이라고 했던 것의 실체입니다. 한번 계산한 filter 결과를 Bitset으로 캐싱해두면, 다음 요청에서는 역인덱스 조회 자체를 건너뛰고 Bitset 비트 연산만으로 결과를 얻습니다.
그런데 Bitset이 항상 효율적인 것은 아닙니다. 문서 100만 건 중 매칭되는 것이 10건뿐이라면? Bitset은 여전히 122KB를 사용하지만, 실제 의미 있는 정보는 10개뿐입니다. 999,990개의 0을 저장하는 것은 낭비입니다.
Lucene은 매칭 문서의 밀도에 따라 자료구조를 전환합니다.
| 밀도 | 자료구조 | 이유 |
|---|---|---|
| 낮음 (매칭 문서가 적음) | Integer Array | 매칭된 문서 번호만 저장. [3, 47, 298] 같은 배열. 메모리 절약 |
| 높음 (매칭 문서가 많음) | Bitset | 비트 연산으로 AND/OR을 한 번에 처리. 연산 속도 우선 |
전환 기준은 대략 전체 문서의 1/64 (약 1.5%) 정도입니다. 매칭 문서가 이보다 적으면 Integer Array가 메모리 효율적이고, 이보다 많으면 Bitset이 연산 효율적입니다.
핵심 정리: Filter 결과는 Bitset으로 캐싱되어, 여러 조건의 AND/OR을 CPU 비트 연산 한 번으로 처리합니다. 하지만 문서 수가 아주 많아지면 Bitset 자체의 메모리가 문제됩니다. 이 한계를 극복하는 것이 Roaring Bitmap입니다.
Segment당 문서 수가 수천만~수억 건에 이르면 Bitset의 크기가 부담됩니다.
문서 1억 건일 때:
Bitset 크기 = 100,000,000 비트 = 12.5 MB
그런데 매칭 문서가 100건이면?
12.5 MB 중 실제 의미 있는 비트 = 100개
나머지 99,999,900개의 비트 = 전부 0
Integer Array를 쓰면? 100 x 4바이트 = 400바이트로 충분합니다. 하지만 Integer Array는 비트 연산이 불가능하므로, 여러 필터의 AND/OR을 할 때 느려집니다.
이 딜레마를 해결하는 것이 Roaring Bitmap입니다.
Roaring Bitmap의 핵심 아이디어는 간단합니다. 전체 문서를 65,536(2^16)개 단위 블록으로 나누고, 각 블록의 밀도에 따라 저장 방식을 다르게 선택합니다.
┌──────────────────────────────────────────────────────────────────┐
│ Roaring Bitmap — 블록별 적응적 저장 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 문서 번호 범위를 65,536개씩 블록으로 분할 │
│ │
│ Block 0: Doc 0 ~ 65,535 │
│ Block 1: Doc 65,536 ~ 131,071 │
│ Block 2: Doc 131,072 ~ 196,607 │
│ ... │
│ │
│ ┌──────────────────────────────────────────────────────────────┐│
│ │ Block 0: 매칭 문서 30건 ││
│ │ → 밀도 낮음 → Integer Array [12, 47, 103, 298, ...] ││
│ │ → 30 x 2바이트 = 60바이트 ││
│ └──────────────────────────────────────────────────────────────┘│
│ │
│ ┌──────────────────────────────────────────────────────────────┐│
│ │ Block 1: 매칭 문서 50,000건 ││
│ │ → 밀도 높음 → Bitset (65,536비트) ││
│ │ → 8 KB (비트 연산 가능) ││
│ └──────────────────────────────────────────────────────────────┘│
│ │
│ ┌──────────────────────────────────────────────────────────────┐│
│ │ Block 2: 매칭 문서 0건 ││
│ │ → 빈 블록 → 저장 자체를 생략 ││
│ │ → 0바이트 ││
│ └──────────────────────────────────────────────────────────────┘│
│ │
└──────────────────────────────────────────────────────────────────┘
블록별 저장 방식을 정리합니다.
| 블록 내 매칭 문서 수 | 저장 방식 | 이유 |
|---|---|---|
| 4,096개 미만 | Integer Array (16비트 정수 배열) | 문서 번호만 저장하면 메모리 절약 |
| 4,096개 이상 | Bitset (65,536비트 = 8KB) | 비트 연산으로 AND/OR 속도 확보 |
| 0개 | 저장 생략 | 메모리 0 사용 |
왜 4,096이 기준일까요? Integer Array에서 각 원소는 16비트(블록 내 offset이므로 0~65,535)입니다. 4,096개 x 2바이트 = 8,192바이트이고, Bitset은 항상 8,192바이트(65,536비트)입니다. 두 방식의 크기가 같아지는 지점이 4,096개입니다. 이보다 적으면 Array가 작고, 많으면 Bitset이 더 효율적입니다.
문서 1억 건, 매칭 100건인 시나리오를 비교합니다.
일반 Bitset:
100,000,000 비트 = 12.5 MB
(매칭이 100건이든 5000만 건이든 항상 12.5 MB)
Roaring Bitmap:
총 블록 수 = 100,000,000 / 65,536 ≈ 1,526개
매칭 100건 → 대부분의 블록은 비어 있음 → 저장 생략
매칭이 있는 블록 수 ≈ 수 개 ~ 수십 개
각 블록은 Integer Array로 수십 바이트
총 크기 ≈ 수 KB 이하
절약: 12.5 MB → 수 KB (약 1,000배 이상 절약)
반대로 매칭이 5,000만 건이면? 대부분의 블록이 밀도 높은 Bitset으로 저장되므로, Bitset과 비슷한 크기가 됩니다. 하지만 비트 연산은 그대로 가능하므로 연산 속도는 유지됩니다.
Roaring Bitmap은 "밀도가 균일하지 않은 현실 데이터"에 특화된 자료구조입니다. 특정 시간대에 에러가 집중되거나, 특정 카테고리에 문서가 몰리는 실무 패턴에서 높은 효율을 발휘합니다.
핵심 정리: Roaring Bitmap은 블록 단위로 밀도를 판단하여 Array 또는 Bitset을 선택합니다. 희소한 데이터에서는 메모리를 극단적으로 절약하고, 밀집한 데이터에서는 비트 연산 속도를 유지합니다.
여기까지 검색(토큰 → 문서를 찾는 과정)에 쓰이는 자료구조를 살펴봤습니다. 하지만 검색 결과를 정렬하거나 집계할 때는 역인덱스가 아닌 다른 자료구조가 필요합니다. 그것이 Doc Values입니다.
"최근 주문 로그를 amount 내림차순으로 10건 보여줘."
이 요청을 처리하려면, 검색 결과의 모든 문서에서 amount 필드의 값을 읽어야 합니다. 역인덱스는 "토큰 → 문서" 방향이므로, "문서 → 필드 값" 방향으로 접근하려면 역인덱스를 뒤집어야 합니다. 이것은 비효율적입니다.
Stored Fields(.fdt)에서 읽을 수도 있습니다. 하지만 Stored Fields는 문서 단위로 모든 필드를 묶어 저장하는 Row 지향 구조입니다. amount 하나를 읽으려면 해당 문서의 모든 필드를 디스크에서 읽어야 합니다.
┌────────────────────────────────────────────────────────────────────┐
│ Row 지향 저장 (Stored Fields, .fdt) │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 디스크 레이아웃: │
│ │
│ [Doc0: orderId="ORD-1", status="OK", amount=35000, created=...] │
│ [Doc1: orderId="ORD-2", status="ERR", amount=12000, created=...] │
│ [Doc2: orderId="ORD-3", status="OK", amount=58000, created=...] │
│ [Doc3: orderId="ORD-4", status="ERR", amount=9000, created=...] │
│ │
│ amount로 정렬하려면? │
│ → Doc0의 전체 JSON 읽기 → amount 추출 │
│ → Doc1의 전체 JSON 읽기 → amount 추출 │
│ → Doc2의 전체 JSON 읽기 → amount 추출 │
│ → Doc3의 전체 JSON 읽기 → amount 추출 │
│ → 불필요한 필드(orderId, status, created...)도 전부 읽힘 │
│ │
└────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────┐
│ Column 지향 저장 (Doc Values, .dvd) │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 디스크 레이아웃: │
│ │
│ orderId 컬럼: ["ORD-1", "ORD-2", "ORD-3", "ORD-4"] │
│ status 컬럼: ["OK", "ERR", "OK", "ERR"] │
│ amount 컬럼: [35000, 12000, 58000, 9000] │
│ created 컬럼: [...] │
│ │
│ amount로 정렬하려면? │
│ → amount 컬럼만 쭉 읽기: [35000, 12000, 58000, 9000] │
│ → 다른 컬럼은 읽지 않음 │
│ → 필요한 데이터만 디스크에서 읽어서 I/O 최소화 │
│ │
└────────────────────────────────────────────────────────────────────┘
| 특성 | Row 지향 (Stored Fields) | Column 지향 (Doc Values) |
|---|---|---|
| 저장 단위 | 문서별 (모든 필드 묶음) | 필드별 (모든 문서의 값 묶음) |
| 강점 | 특정 문서의 전체 필드를 한 번에 읽기 | 특정 필드의 모든 문서 값을 한 번에 읽기 |
| 적합한 작업 | _source 반환 (최종 결과 표시) | 정렬, 집계, 스크립트 필드 |
| I/O 패턴 | 랜덤 I/O (여러 문서를 뛰어다님) | 순차 I/O (한 컬럼을 쭉 읽음) |
이 구조는 ClickHouse, BigQuery, Apache Parquet 같은 분석용 시스템과 동일한 원리입니다. "전체 데이터 중 특정 컬럼만 빠르게 읽겠다"는 것은 OLAP(분석) 워크로드의 핵심 패턴이고, Lucene은 검색 엔진이면서도 이 패턴을 Doc Values로 지원합니다.
Elasticsearch에서 집계(aggregation) 쿼리가 빠른 이유가 여기에 있습니다. 100만 건의 주문에서 status별 건수를 세는 집계를 하면, Lucene은 status Doc Values 컬럼만 순차적으로 읽습니다. orderId, amount, message 같은 다른 필드는 전혀 건드리지 않습니다.
검색 쿼리 → 어떤 자료구조를 사용하나?
질문 1: 텍스트 토큰으로 문서를 찾는가?
├─ Yes → Inverted Index (.tip, .doc)
└─ No
│
질문 2: 결과를 정렬하거나 집계하는가?
├─ Yes → Doc Values (.dvd)
└─ No
│
질문 3: 숫자/날짜 범위 조건인가?
├─ Yes → BKD Tree (.kdd)
└─ No → 원본 반환만 → Stored Fields (.fdt)
핵심 정리: Doc Values는 필드 단위로 데이터를 묶어 저장하는 컬럼 지향 구조입니다. 정렬과 집계 시 필요한 컬럼만 순차적으로 읽어서 I/O를 최소화합니다.
4편에서 Range Query는 역인덱스가 아닌 BKD Tree를 사용한다고 했습니다. 왜 역인덱스로는 범위 검색이 어려운 걸까요? 그리고 BKD Tree는 어떻게 범위 검색을 효율적으로 처리할까요?
역인덱스는 "이 토큰이 어느 문서에 있는가?"에 최적화되어 있습니다. term: { amount: 35000 }처럼 정확한 값을 찾는 것은 빠릅니다. FST에서 "35000"을 찾아 Posting List를 반환하면 되니까요.
하지만 range: { amount: { gte: 10000, lte: 50000 } }은 다릅니다. 10000부터 50000까지의 모든 정수값을 하나씩 FST에서 찾아야 합니다. 40,001개의 토큰을 조회하고, 각각의 Posting List를 합쳐야 합니다. 현실적이지 않습니다.
BKD Tree는 Block KD-Tree의 약자로, KD-Tree(K-Dimensional Tree)를 디스크 친화적으로 변형한 자료구조입니다. .kdd / .kdi 파일에 저장됩니다.
┌─────────────────────────────────────────────────────────────────┐
│ BKD Tree — amount 필드 예시 │
│ │
│ 문서와 값: │
│ Doc0=9000, Doc1=12000, Doc2=35000, Doc3=42000, │
│ Doc4=58000, Doc5=70000, Doc6=85000, Doc7=99000 │
│ │
│ [중앙값: 42000] │
│ / \ │
│ ≤ 42000 > 42000 │
│ │ │ │
│ [중앙값: 12000] [중앙값: 85000] │
│ / \ / \ │
│ ≤ 12000 > 12000 ≤ 85000 > 85000 │
│ │ │ │ │ │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ 9000 │ │ 35000 │ │ 58000 │ │ 99000 │ │
│ │ 12000 │ │ 42000 │ │ 70000 │ │ │ │
│ │(Doc0,1)│ │(Doc2,3)│ │(Doc4,5)│ │ (Doc7) │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ 리프 블록 리프 블록 리프 블록 리프 블록 │
│ │
│ 범위 검색: amount 10000 ~ 50000 │
│ │
│ 루트: 42000 → 10000 ≤ 42000 이므로 왼쪽 탐색 │
│ → 50000 > 42000 이므로 오른쪽도 탐색 │
│ │
│ 왼쪽 서브트리: │
│ 12000 → 10000 ≤ 12000, 왼쪽: 9000 < 10000이므로 제외 │
│ 12000 → 포함 │
│ → 50000 > 12000, 오른쪽: 35000, 42000 → 둘 다 포함 │
│ │
│ 오른쪽 서브트리: │
│ 85000 → 50000 < 85000, 왼쪽: 58000 > 50000이므로 제외 │
│ → 오른쪽: 범위 밖이므로 탐색 중단 │
│ │
│ 결과: Doc1(12000), Doc2(35000), Doc3(42000) │
│ 탐색하지 않은 리프 블록: [99000] → I/O 절약 │
│ │
└─────────────────────────────────────────────────────────────────┘
BKD Tree의 핵심은 두 가지입니다.
첫째, 트리 탐색으로 범위 밖의 블록을 건너뜁니다. 위 예시에서 99000이 있는 리프 블록은 아예 읽지 않습니다. 루트에서 내려오면서 범위와 겹치지 않는 서브트리를 가지치기(pruning)하기 때문입니다.
둘째, 리프 블록 단위로 디스크 I/O를 합니다. 리프 블록은 여러 값을 묶어 저장하므로, 한 번의 디스크 읽기로 여러 문서를 처리할 수 있습니다. 이것은 B-Tree가 디스크 블록 단위로 읽는 것과 같은 원리입니다.
BKD Tree의 "K-Dimensional"은 여러 차원의 데이터를 처리할 수 있다는 의미입니다. Elasticsearch에서 가장 흔한 사용은 1차원(숫자, 날짜)이지만, IP 주소나 지리 좌표 같은 다차원 데이터에도 사용됩니다.
| 필드 타입 | 차원 | BKD Tree 사용 |
|---|---|---|
| long, integer | 1차원 | 숫자 범위 검색 |
| date | 1차원 | 날짜 범위 검색 |
| ip | 1차원 (128비트 정수) | IP 범위 검색 |
| geo_point | 2차원 (위도, 경도) | 지리적 거리/영역 검색 |
역인덱스는 "이 단어가 어느 문서에 있는가?"에 대한 답이고, BKD Tree는 "이 범위에 어떤 값이 있는가?"에 대한 답입니다. Lucene이 텍스트 검색과 범위 검색을 모두 빠르게 처리할 수 있는 이유는 각 목적에 최적화된 자료구조를 별도로 유지하기 때문입니다.
핵심 정리: BKD Tree는 트리 탐색으로 범위 밖 데이터를 건너뛰어, 전체 스캔 없이 해당 범위의 문서만 효율적으로 찾습니다. 숫자, 날짜, IP 주소 등 범위 검색이 필요한 필드에 사용됩니다.
지금까지 Lucene이 검색을 빠르게 만드는 자료구조들을 살펴봤습니다. 하지만 검색이 아무리 빨라도, 새 데이터를 쓰는 동안 기존 검색이 멈추면 실무에서는 쓸 수 없습니다. Lucene은 이 문제를 어떻게 해결할까요?
주문 로그가 초당 수천 건씩 들어오고 있습니다. 동시에 운영팀은 Kibana에서 실시간 대시보드를 보고 있습니다. 쓰기와 읽기가 동시에 발생합니다.
일반적인 데이터베이스에서는 이런 상황이 까다롭습니다. 쓰기 중인 데이터를 읽으면 불완전한 상태를 볼 수 있고, 이를 막으려면 락(lock)이 필요합니다. 락을 걸면 쓰기가 끝날 때까지 읽기가 대기해야 합니다.
Lucene은 Segment의 불변성과 NRT(Near Real-Time) Reader로 이 문제를 우아하게 해결합니다.
핵심은 검색 요청 시점에 존재하는 Segment들의 스냅샷을 잡는 것입니다.
┌─────────────────────────────────────────────────────────────────┐
│ NRT Reader — Segment 불변성 기반의 무중단 검색 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 시점 T1: Segment A, B 존재 │
│ ┌───────────┐ ┌───────────┐ │
│ │ Segment A │ │ Segment B │ │
│ └───────────┘ └───────────┘ │
│ ↑ ↑ │
│ 검색 요청 X 들어옴 → IndexReader 1 생성 │
│ IndexReader 1은 [A, B]를 참조 │
│ │
│ ───────────────────────────────────────── │
│ │
│ 시점 T2: 새 문서 도착 → refresh → Segment C 생성 │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Segment A │ │ Segment B │ │ Segment C │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ ↑ ↑ ↑ │
│ 검색 X: IndexReader 1 검색 Y: IndexReader 2 │
│ 여전히 [A, B]만 참조 새로 [A, B, C] 참조 │
│ Segment C는 보이지 않음 Segment C도 검색 대상 │
│ │
│ ───────────────────────────────────────── │
│ │
│ 시점 T3: 검색 X 완료 │
│ IndexReader 1 해제 │
│ (Segment A, B는 다른 Reader가 참조 중이면 유지) │
│ │
└─────────────────────────────────────────────────────────────────┘
흐름을 정리합니다.
| 시점 | 이벤트 | 검색 X (IndexReader 1) | 검색 Y (IndexReader 2) |
|---|---|---|---|
| T1 | 검색 X 시작 | [A, B] 참조 | - |
| T2 | Segment C 생성 + 검색 Y 시작 | 여전히 [A, B]만 참조 | [A, B, C] 참조 |
| T3 | 검색 X 완료 | Reader 해제 | 계속 진행 |
검색 X는 시작 시점의 스냅샷을 그대로 사용합니다. 도중에 Segment C가 생겨도 X에게는 보이지 않습니다. 검색 Y는 더 최신 스냅샷을 사용하므로 C까지 포함합니다.
이 메커니즘이 가능한 이유는 2편에서 다룬 Segment의 불변성 때문입니다.
Segment A가 한번 생성되면 그 내용은 절대 변하지 않습니다. 검색 X가 Segment A를 읽는 도중에 누군가 Segment A의 데이터를 수정할 일이 없습니다. 수정이 필요하면 기존 문서를 .liv에서 삭제 표시하고, 새 문서를 새 Segment에 추가하는 방식이기 때문입니다.
데이터가 바뀌지 않으므로 락이 필요 없습니다. 여러 Reader가 같은 Segment를 동시에 읽어도 안전합니다.
이 패턴은 PostgreSQL의 MVCC(Multi-Version Concurrency Control)와 철학적으로 유사합니다.
| 특성 | Lucene NRT | PostgreSQL MVCC |
|---|---|---|
| 읽기 단위 | Segment 스냅샷 (IndexReader) | 트랜잭션 스냅샷 (xmin/xmax) |
| 쓰기 방식 | 새 Segment 생성 (기존 불변) | 새 튜플 버전 생성 (기존 버전 유지) |
| 삭제 처리 | .liv 비트셋에 표시 | 튜플의 xmax에 트랜잭션 ID 기록 |
| 물리 삭제 시점 | Segment Merge | VACUUM |
| 읽기-쓰기 간섭 | 없음 (락 불필요) | 없음 (읽기는 쓰기를 블록하지 않음) |
| 핵심 원리 | 불변 Segment + Reader 스냅샷 | 튜플 버전 관리 + 트랜잭션 스냅샷 |
공통된 철학은 "읽기는 과거의 일관된 스냅샷을 보고, 쓰기는 새 버전을 만들며, 둘은 서로 간섭하지 않는다"입니다.
핵심 정리: NRT Reader는 Segment 불변성을 기반으로, 쓰기 중에도 기존 검색을 방해하지 않는 스냅샷 읽기를 제공합니다. 이것은 PostgreSQL의 MVCC와 같은 철학입니다.
5편에 걸쳐 Elasticsearch의 겉부터 속까지를 살펴봤습니다. 1편에서 "역인덱스 덕분에 빠르다"고 했던 단순한 답이, 5편에 이르러서는 여러 층위의 최적화로 분해되었습니다.
Lucene의 성능은 단일 기술의 승리가 아닙니다. 각 계층에서 서로 다른 문제를 해결하는 자료구조들의 조합입니다.
┌───────────────────────────────────────────────────────────────────────────┐
│ Lucene 성능 최적화 계층 │
├───────────────────────────────────────────────────────────────────────────┤
│ │
│ 파일 구조 역할별 파일 분리 → 필요한 파일만 읽어 I/O 최소화 │
│ │ │
│ 토큰 조회 FST → 메모리에서 O(토큰 길이)로 즉시 조회 │
│ │ │
│ 문서 목록 FOR 압축 → 디스크 공간 75~80% 절약 │
│ │ Skip List → 교집합 연산 수십 배 가속 │
│ │ │
│ 필터링 Bitset → CPU 비트 연산으로 AND/OR 처리 │
│ │ Roaring Bitmap → 밀도별 적응적 저장 │
│ │ │
│ 정렬/집계 Doc Values → 컬럼 지향 저장으로 필요한 필드만 읽기 │
│ │ │
│ 범위 검색 BKD Tree → 트리 탐색으로 범위 밖 데이터 건너뛰기 │
│ │ │
│ 동시성 NRT Reader → Segment 불변성 기반 무중단 스냅샷 │
│ │
└───────────────────────────────────────────────────────────────────────────┘
이 구조를 보면 하나의 패턴이 보입니다. "전부 읽지 않는다"는 것입니다.
검색 엔진의 성능은 "얼마나 빠르게 처리하는가"보다 "얼마나 많은 것을 하지 않는가"에 달려 있습니다. Lucene의 모든 자료구조는 불필요한 I/O, 불필요한 연산, 불필요한 메모리 사용을 제거하는 방향으로 설계되어 있습니다.
이것은 Lucene만의 이야기가 아닙니다. RDB의 인덱스도, 캐시 전략도, 네트워크 프로토콜도 같은 원리입니다. 시스템이 빨라지는 방법은 더 빠른 하드웨어가 아니라, 하지 않아도 되는 일을 찾아서 제거하는 것입니다.
ELK Deep Dive 시리즈를 마칩니다. 1편에서 "Elasticsearch는 왜 검색에 강한가"라는 질문으로 시작했고, 5편에서 그 답의 물리적 실체를 확인했습니다. 이 시리즈가 "Elasticsearch를 사용하는 것"에서 "Elasticsearch가 왜 그렇게 동작하는지 이해하는 것"으로 한 단계 나아가는 데 도움이 되었기를 바랍니다.