DB Study

3장. Lucene 내부 구조 — 세그먼트의 일생

refresh는 왜 1초인가, 삭제한 문서는 왜 디스크에 남는가, 머지는 왜 계속 도는가 — Elasticsearch의 모든 미스터리는 Lucene의 "불변 세그먼트" 하나로 풀립니다.

고급 ⏱ 약 30분 🎬 애니메이션 3개 선수 지식: 1장 역색인, 2장 클러스터
🧭
이 장에서 배우는 것

① 샤드 = 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 → 문서 목록" 표가 실제로는 세 가지 컴포넌트로 저장됩니다.

# 쿼리 "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 끄기는 위험한 최적화

디스크를 아끼려 "_source": {"enabled": false}로 끄면 reindex 불가, update API 불가, partial update 불가가 됩니다. 대신 _source.excludes로 큰 필드만 빼거나, doc values에서 _source를 재구성하는 Synthetic _source(8.4+)를 검토하세요.

3.5 색인 라이프사이클 — buffer에서 디스크까지

이 절이 이 장의 심장입니다. 색인 요청 하나가 검색 가능해지고(refresh), 영속화되고(flush), 그 사이 유실을 막는(translog) 전체 과정을 봅시다.

🧠 메모리 (JVM/OS) 💾 디스크 In-memory Indexing Buffer 새 문서가 일단 쌓이는 곳 🔍 검색 불가 ① 문서 도착 → buffer에 추가 Translog (append + fsync) 유실 방지 — 재시작 시 replay ② 로그 기록 후 ack refresh (1s) 새 Segment (OS 캐시) 아직 fsync 안 됨 🔍 검색 가능! (NRT) IndexReader 갱신 검색기가 새 segment를 보기 시작 → NRT 완성 ⑤ flush: fsync + commit point 영속화된 Segments commit point가 활성 목록 기록 Translog 초기화 ♻️ replay할 양 감소 = 복구 빨라짐

정리하면, 세 단어의 역할 분담은 이렇습니다.

동작주기(기본)하는 일보장하는 것
Refresh1초buffer → 새 segment (OS 캐시)검색 가시성 (durability 아님!)
Translog 기록매 요청append + fsync내구성 (재시작 시 replay)
Flushtranslog 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초 유실 가능)
💡
워크로드별 translog 선택

대량 로그 적재처럼 몇 초의 유실이 허용되는 워크로드는 async로 처리량을 2~3배 올릴 수 있습니다. 결제·주문처럼 유실 불가 데이터는 반드시 기본값 request를 유지하세요.

3.6 삭제와 업데이트 — 불변 세그먼트에서 지우는 법

세그먼트가 불변이라면 DELETE는 어떻게 처리될까요? 답: 지우지 않고 마킹만 합니다.

segment_A (immutable) doc 1: "구형 모델 상품" doc 2: "인기 상품" doc 3: "일반 상품" DELETE /products/_doc/1 요청 도착 .liv 파일에 deleted bit 마킹 세그먼트 본체는 그대로! 검색 시 deleted bit가 켜진 문서는 결과에서 필터링 ⚠️ 디스크 공간은 계속 차지 🌀 Merge 머지 때 비로소 deleted docs가 진짜로 사라지고 공간 회수됨

3.7 Merge — 세그먼트 정리반

refresh가 1초마다 새 세그먼트를 만들면 세그먼트가 빠르게 불어납니다. 세그먼트가 많을수록 검색이 모든 세그먼트를 순회해야 해서 느려지고, 파일 디스크립터와 메타데이터 메모리도 소모됩니다. 그래서 Lucene은 백그라운드에서 작은 세그먼트들을 큰 세그먼트로 병합합니다. 기본 정책은 TieredMergePolicy입니다.

Tier 0 — 작은 segment (~5MB) seg×4 ← 10개 도달! merge (deleted docs 제외하고 병합) Tier 1 segment (~50MB) ×10... Tier 2 segment (~500MB) ... 지수적으로 성장 🧱 자동 머지 상한 = max_merged_segment 5GB — 그 이상은 Force Merge로만
🚨
Force Merge는 "쓰기가 끝난" 인덱스에만

POST /logs-2026.07.10/_forcemerge?max_num_segments=1은 어제 자 로그처럼 더 이상 쓰지 않는 인덱스에는 훌륭한 최적화입니다. 하지만 쓰기 진행 중인 인덱스에 실행하면 5GB를 초과하는 거대 세그먼트가 생기고, 이후 deleted docs가 쌓여도 자동 머지 대상에서 빠져 디스크를 영영 회수하지 못하게 될 수 있습니다. ILM의 warm phase에서 자동화하는 것이 정석입니다.

3.8 운영 팁

# 세그먼트 상태 확인
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

✍️ 이해도 체크

refresh와 flush의 역할을 올바르게 설명한 것은?
refresh = 검색 가시성(buffer → OS 캐시의 세그먼트, 기본 1초), flush = 영속화(fsync + commit point + translog 초기화)입니다. refresh만 된 세그먼트는 아직 fsync 전이지만, 그 사이의 내구성은 translog가 책임집니다.
대량의 문서를 DELETE했는데 디스크 사용량이 거의 줄지 않았습니다. 이유는?
✅ 불변 세그먼트에서 삭제는 .liv 파일의 비트 마킹입니다. 검색 결과에서는 즉시 사라지지만 디스크의 데이터는 그대로 있고, 머지가 해당 세그먼트를 병합할 때 비로소 물리적으로 제거됩니다. PostgreSQL의 dead tuple + VACUUM과 닮은 구조입니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch03_lucene_internals.md — FST 구조 상세, merge policy 설정 표, 색인 파이프라인 전체 플로우가 담겨 있습니다.