3장. Lucene 내부 구조 — 세그먼트의 일생
refresh는 왜 1초인가, 삭제한 문서는 왜 디스크에 남는가, 머지는 왜 계속 도는가 — Elasticsearch의 모든 미스터리는 Lucene의 "불변 세그먼트" 하나로 풀립니다.
① 샤드 = Lucene 인덱스 = 불변 세그먼트의 모음 ② 역색인의 실제 저장 구조(FST, posting list)와 doc values ③ buffer → refresh → segment → flush로 이어지는 색인 라이프사이클과 "near real-time"의 진짜 의미 ④ segment merge와 deleted docs의 운명
3.1 Lucene이란 — 그리고 두 가지 본질
Lucene은 1999년 Doug Cutting이 시작한 자바 풀텍스트 검색 라이브러리입니다. 데이터베이스가 아닙니다. 분산도, 복제도, REST도 없습니다. "디렉터리 하나에 인덱스 자료구조를 만들고 그 위에서 검색하는 라이브러리"일 뿐입니다. Elasticsearch의 샤드 하나 = Lucene 인덱스 하나이고, 그 안은 이렇게 생겼습니다.
| Lucene의 본질 | 의미 | 여기서 파생되는 것 |
|---|---|---|
| 세그먼트 기반 | 인덱스는 여러 세그먼트(자체 완결적인 미니 인덱스)의 모음 | refresh마다 새 세그먼트 생성, 검색은 전체 세그먼트 순회 |
| 세그먼트는 immutable | 한 번 쓰인 세그먼트는 절대 수정되지 않음 | 변경 = 새 세그먼트 추가, 삭제 = 마킹, 정리 = 백그라운드 머지 |
이 두 성질에서 refresh, flush, merge, NRT, 그리고 색인 처리량의 한계까지 — Elasticsearch의 모든 운영 특성이 흘러나옵니다. 이 장의 목표는 이 인과 사슬을 눈으로 확인하는 것입니다.
3.2 역색인은 디스크에 어떻게 저장되나
1장에서 본 "term → 문서 목록" 표가 실제로는 세 가지 컴포넌트로 저장됩니다.
- Term Dictionary — 정렬된 term 사전. 수백만 term을 메모리에 올리기 위해 FST(Finite State Transducer)라는 자료구조로 압축합니다. FST는 공통 접두사·접미사를 공유하는 압축 트라이 같은 것으로, term 룩업을 디스크 접근 없이 처리하게 해줍니다.
- Posting List — 각 term이 등장하는 doc ID 목록. doc ID 간의 차이값(delta)만 저장하고 bit packing으로 압축합니다.
- Term Frequencies + Positions — 각 term이 문서에서 몇 번, 어느 위치에 나왔는지. BM25 스코어링과 구문(phrase) 검색에 필요합니다.
# 쿼리 "the quick brown fox"가 처리되는 과정 (BM25)
① Analyzer가 토큰화: [quick, brown, fox] # the는 stopword로 제거
② 각 term의 posting list 조회 (FST로 룩업):
quick → [doc1(tf=1), doc7(tf=2), ...]
brown → [doc1(tf=1), doc15(tf=1)]
fox → [doc1(tf=1)]
③ 교집합(intersection): {doc1} # SkipList로 정렬 순회
④ 각 문서의 BM25 점수 계산 → top-K 반환
posting list 교집합은 정렬된 리스트들을 한 번에 스캔하며 처리되므로, 검색 속도는 대략 가장 짧은 posting list의 길이에 비례합니다. 희귀한 term이 하나라도 끼어 있으면 매우 빠릅니다.
3.3 Doc Values — 역색인의 반대 방향
역색인은 "term → docs"는 잘하지만 "doc → 필드 값"은 못 합니다. 그런데 정렬·집계·스크립트는 후자가 필요합니다. 그래서 Lucene은 색인 시 Doc Values라는 컬럼 지향 자료구조를 함께 만듭니다.
# 같은 데이터, 두 방향의 자료구조
Inverted Index (검색용): Doc Values (정렬/집계용, 컬럼 지향):
"apple" → [doc1, doc5] field "price":
"banana" → [doc3, doc7] doc1 → 100
doc2 → 250
doc3 → 80 # 한 필드 값이 연속 배치
doc4 → 320 # → sequential read로 집계
ClickHouse의 컬럼 저장과 같은 원리입니다. 중요한 점: Doc Values는 JVM heap이 아니라 OS의 파일 시스템 캐시(off-heap)에 올라갑니다. Elasticsearch가 heap을 시스템 메모리의 50% 이하로 권장하는 이유가 바로 이것입니다 — 나머지 절반은 OS가 doc values와 posting list를 캐싱하는 데 씁니다.
3.4 _source — 원본 JSON의 자리
문서의 원본 JSON은 _source라는 stored field에 통째로 저장됩니다(LZ4/ZSTD 압축 청크). 검색 결과 반환, reindex, update API가 모두 이것에 의존합니다.
디스크를 아끼려 "_source": {"enabled": false}로 끄면 reindex 불가, update API 불가, partial update 불가가 됩니다. 대신 _source.excludes로 큰 필드만 빼거나, doc values에서 _source를 재구성하는 Synthetic _source(8.4+)를 검토하세요.
3.5 색인 라이프사이클 — buffer에서 디스크까지
이 절이 이 장의 심장입니다. 색인 요청 하나가 검색 가능해지고(refresh), 영속화되고(flush), 그 사이 유실을 막는(translog) 전체 과정을 봅시다.
색인 요청이 도착하면 문서는 인메모리 버퍼에 쌓입니다. 이 시점엔 검색되지 않습니다 — 아직 세그먼트가 아니니까요.
동시에 translog에 append + fsync됩니다. 클라이언트가 받는 색인 성공 응답은 "translog에 안전하게 기록됨"이라는 뜻입니다. 노드가 죽어도 재시작 시 translog를 replay해 복원합니다.
기본 1초마다 refresh가 일어나 버퍼 내용으로 새 세그먼트가 만들어집니다. 파일 시스템 캐시에만 있고 아직 fsync는 안 된 상태 — 하지만 검색은 가능해집니다.
새 IndexReader가 열리면서 검색기가 이 세그먼트를 보기 시작합니다. 이것이 "near real-time"의 정체입니다: 검색 가시성(refresh)과 디스크 영속성(fsync)을 분리한 것이죠.
translog가 512MB에 달하거나 30분이 지나면 flush: 쌓인 세그먼트를 전부 fsync하고, 어떤 세그먼트가 활성인지 기록하는 commit point를 갱신합니다.
flush가 끝나면 translog를 비우고 새로 시작합니다. 이제 재시작 시 replay할 양이 줄어듭니다 — flush의 가장 큰 목적은 복구 시간 단축입니다.
정리하면, 세 단어의 역할 분담은 이렇습니다.
| 동작 | 주기(기본) | 하는 일 | 보장하는 것 |
|---|---|---|---|
| Refresh | 1초 | buffer → 새 segment (OS 캐시) | 검색 가시성 (durability 아님!) |
| Translog 기록 | 매 요청 | append + fsync | 내구성 (재시작 시 replay) |
| Flush | translog 512MB 또는 30분 | segment fsync + commit point + translog 초기화 | 영속화 + 복구 시간 단축 |
# refresh_interval 튜닝 — 색인 처리량의 가장 큰 변수
PUT /my-index/_settings
{ "index": { "refresh_interval": "30s" } } # 30s만 돼도 처리량 2~5배 흔함
# 대량 초기 적재: refresh 끄기 → 적재 → 복원
PUT /logs-init/_settings
{ "index": { "refresh_interval": "-1" } }
# ... bulk 적재 ...
PUT /logs-init/_settings
{ "index": { "refresh_interval": "1s" } }
POST /logs-init/_refresh
# translog 내구성 모드 — 의식적으로 선택하라
index.translog.durability: request # 기본. 매 요청 fsync (안전, IOPS 부담)
index.translog.durability: async # 5초 주기 fsync (빠름, 최대 5초 유실 가능)
대량 로그 적재처럼 몇 초의 유실이 허용되는 워크로드는 async로 처리량을 2~3배 올릴 수 있습니다. 결제·주문처럼 유실 불가 데이터는 반드시 기본값 request를 유지하세요.
3.6 삭제와 업데이트 — 불변 세그먼트에서 지우는 법
세그먼트가 불변이라면 DELETE는 어떻게 처리될까요? 답: 지우지 않고 마킹만 합니다.
세그먼트 A에 문서 3개가 들어 있습니다. 이때 doc 1에 대한 DELETE 요청이 도착합니다.
세그먼트는 불변이므로 파일을 고칠 수 없습니다. 대신 별도의 .liv 파일에 "doc 1은 삭제됨" 비트만 마킹합니다. 같은 _id로 재색인(update)해도 마찬가지 — 기존 버전 마킹 + 새 세그먼트에 새 버전 추가입니다.
검색할 때는 deleted bit가 켜진 문서를 결과에서 걸러냅니다. 사용자는 삭제된 것으로 보지만, 디스크 공간은 그대로 차지하고 있습니다.
진짜 제거는 머지의 몫입니다. 세그먼트들이 병합될 때 deleted docs를 빼고 새 세그먼트를 쓰므로, 그때 비로소 공간이 회수됩니다. "대량 삭제 후 디스크가 안 줄어요"의 답이 여기 있습니다.
3.7 Merge — 세그먼트 정리반
refresh가 1초마다 새 세그먼트를 만들면 세그먼트가 빠르게 불어납니다. 세그먼트가 많을수록 검색이 모든 세그먼트를 순회해야 해서 느려지고, 파일 디스크립터와 메타데이터 메모리도 소모됩니다. 그래서 Lucene은 백그라운드에서 작은 세그먼트들을 큰 세그먼트로 병합합니다. 기본 정책은 TieredMergePolicy입니다.
refresh가 돌 때마다 ~5MB짜리 작은 세그먼트가 Tier 0에 하나씩 추가됩니다.
세그먼트가 10개(segments_per_tier 기본값) 모이면 머지 대상이 됩니다.
10개의 작은 세그먼트가 1개의 ~50MB 세그먼트로 병합됩니다. 이때 deleted docs는 빼고 쓰므로 공간도 회수됩니다.
Tier 1 세그먼트도 10개가 모이면 다시 병합 → ~500MB Tier 2. 이렇게 지수적으로 커지는 계단이 만들어집니다.
단, 자동 머지는 max_merged_segment(기본 5GB)까지만 진행됩니다. 더 큰 세그먼트를 원하면 쓰기가 끝난 인덱스에 한해 Force Merge를 명시적으로 호출해야 합니다.
POST /logs-2026.07.10/_forcemerge?max_num_segments=1은 어제 자 로그처럼 더 이상 쓰지 않는 인덱스에는 훌륭한 최적화입니다. 하지만 쓰기 진행 중인 인덱스에 실행하면 5GB를 초과하는 거대 세그먼트가 생기고, 이후 deleted docs가 쌓여도 자동 머지 대상에서 빠져 디스크를 영영 회수하지 못하게 될 수 있습니다. ILM의 warm phase에서 자동화하는 것이 정석입니다.
3.8 운영 팁
- Heap 32GB 룰: JVM heap이 32GB를 넘으면 compressed object pointer가 꺼져 메모리 효율이 급락합니다. heap = min(시스템 메모리의 50%, 30GB)가 표준. 나머지는 OS 캐시 몫입니다.
- 세그먼트 수 모니터링: 한 샤드에 세그먼트가 100개를 넘으면 머지가 색인 속도를 따라잡지 못한다는 신호입니다.
- _id 자동 생성이 빠릅니다:
_id를 직접 지정하면 같은 ID 존재 여부 확인 + 기존 버전 삭제 마킹이 필요해 더 비쌉니다.
# 세그먼트 상태 확인
GET /_cat/segments/logs-2026.07.11?v=true&h=shard,segment,size,docs.count,docs.deleted
# 샤드당 세그먼트 수
GET /_cat/shards/logs-*?v=true&h=index,shard,prirep,docs,store,segments.count
# jvm.options — heap 30GB 고정
-Xms30g
-Xmx30g
✍️ 이해도 체크
이 장의 원문 문서: chapters/ch03_lucene_internals.md — FST 구조 상세, merge policy 설정 표, 색인 파이프라인 전체 플로우가 담겨 있습니다.