📌 데이터베이스 깊이 파기 시리즈 — 4편 들어가며
3편에서 B-Tree와 LSM-Tree를 비교하면서, LSM-Tree는 쓰기에 최적화된 구조라고 했습니다. 그런데 Elasticsearch(이하 ES)는 검색 엔진입니다. 읽기가 핵심인 시스템이 왜 쓰기 최적화 구조를 채택했을까요?
[Data] B-Tree vs LSM-Tree — RDB와 NoSQL은 왜 다른 스토리지 엔진을 선택했을까
📌 데이터베이스 깊이 파기 시리즈 — 3편 들어가며들어가며이전 편들에서는 RDB, 문서 DB, 그래프 DB의 데이터 모델을 비교하고, SQL·MapReduce·그래프 질의 언어의 철학적 차이를 살펴봤습니다. 이
dmoritle.tistory.com
이 질문에 답하려면 ES의 코어 엔진인 Lucene의 세그먼트 아키텍처를 이해해야 합니다. 그리고 최근 ES가 벡터 검색까지 지원하게 되면서, 세그먼트 안에 담기는 내용물도 달라지고 있습니다.
이번 글에서는 다음 세 가지를 다루겠습니다.
- Lucene의 세그먼트 아키텍처 — LSM-Tree와의 관계
- 역인덱스 — 텍스트 검색이 빠른 이유
- 벡터 검색 — 역인덱스와는 완전히 다른 구조
1. Lucene의 세그먼트 = LSM-Tree 방식
세그먼트란?
3편에서 LSM-Tree의 디스크 저장 단위를 SSTable이라고 했습니다. Lucene에서는 이에 해당하는 단위를 세그먼트(Segment) 라고 부릅니다. 이름만 다를 뿐, 데이터 라이프사이클은 LSM-Tree와 거의 동일합니다. 메모리 버퍼에 먼저 쓰고, 일정 크기가 되면 디스크에 세그먼트로 플러시하고, 세그먼트가 쌓이면 백그라운드에서 병합합니다.
핵심 특성도 SSTable과 동일합니다.
불변(Immutable)입니다. 디스크에 한 번 써진 세그먼트는 절대 수정되지 않습니다. 데이터를 업데이트하면 기존 세그먼트의 해당 문서에 "삭제됨" 표시(tombstone)를 하고, 새로운 세그먼트에 수정된 문서를 추가합니다.
순차적으로 쓰입니다. 메모리 버퍼를 통째로 플러시하므로 디스크에 순차 쓰기로 기록됩니다.
병합(Merge)으로 정리됩니다. 자잘한 세그먼트가 쌓이면 백그라운드에서 병합하여 큰 세그먼트로 합칩니다. 이때 삭제 표시된 문서가 실제로 물리 삭제됩니다.
왜 검색 엔진에 쓰기 최적화 구조가 필요할까요
검색 엔진에 데이터를 넣는 과정을 색인(Indexing) 이라고 합니다. ES가 실제로 사용되는 장면을 떠올려보면 답이 보입니다.
서비스 로그가 초당 수만 건씩 쏟아집니다. 수백 개 웹페이지의 크롤링 결과가 실시간으로 들어옵니다. 수백만 건의 상품 데이터를 한꺼번에 밀어 넣어야 합니다. — 이 모든 것이 "쓰기"입니다.
B-Tree처럼 매번 디스크의 정확한 위치를 찾아가서 in-place update를 하면, 이 속도를 감당할 수 없습니다. 그래서 일단 빠르게 쏟아붓고 나중에 정리하는 LSM-Tree 방식의 세그먼트 아키텍처가 필요한 것입니다.
2. 역인덱스 — 세그먼트 안의 특수 무기
LSM-Tree인데 검색이 빠른 이유
3편에서 LSM-Tree의 약점은 읽기라고 했습니다. 여러 SSTable을 뒤져야 하니까요. 그런데 ES는 검색이 빠릅니다. 그 비밀은 세그먼트 안에 담기는 내용물에 있습니다.
일반적인 LSM-Tree 기반 DB(Cassandra, RocksDB 등)의 SSTable 안에는 key → value 형태의 데이터가 들어 있습니다. 반면 Lucene 세그먼트 안에는 역인덱스(Inverted Index) 가 들어 있습니다.
역인덱스란?
책 맨 뒤의 "찾아보기"를 생각하면 됩니다. 일반 인덱스가 "문서 → 단어 목록"이라면, 역인덱스는 그 반대로 "단어 → 해당 단어가 포함된 문서 목록" 입니다.
문서 1: "Elasticsearch는 검색 엔진이다"
문서 2: "Lucene은 검색 라이브러리다"
문서 3: "RDB는 관계형 데이터베이스다"
역인덱스:
"검색" → [문서 1, 문서 2]
"엔진" → [문서 1]
"Elasticsearch" → [문서 1]
"Lucene" → [문서 2]
"라이브러리" → [문서 2]
"RDB" → [문서 3]
"관계형" → [문서 3]
"데이터베이스" → [문서 3]
"검색"이라는 키워드로 조회하면 어떻게 될까요? 역인덱스에서 "검색" 항목을 찾으면 바로 [문서 1, 문서 2]가 나옵니다. 전체 문서를 하나하나 열어보며 "검색"이라는 단어가 있는지 확인하는 풀 스캔이 필요 없습니다.
불변성이 만드는 캐싱의 마법
여기서 세그먼트의 불변성이 한 번 더 빛을 발합니다. 세그먼트가 절대 변하지 않으니, OS는 이 파일들을 안심하고 파일시스템 캐시(Filesystem Cache) 에 올려둘 수 있습니다. 누군가 데이터를 수정할까 봐 캐시를 무효화(invalidate)하거나 락을 걸 필요가 없습니다.
결과적으로 자주 조회되는 세그먼트의 역인덱스는 메모리에 상주하게 되고, 디스크 I/O 없이 바로 검색 결과를 반환할 수 있습니다.
BM25 — 역인덱스 위에서 동작하는 랭킹 알고리즘
역인덱스로 "어떤 문서에 해당 단어가 있는지"는 알 수 있습니다. 하지만 검색 결과에는 순위가 필요합니다. 100건의 문서가 매칭되었을 때 어떤 문서를 먼저 보여줄 것인가 — 여기서 BM25 스코어링이 사용됩니다.
BM25는 간단히 말하면 다음 두 가지를 고려합니다.
TF (Term Frequency) — 해당 문서에서 검색어가 얼마나 자주 등장하는가입니다. 자주 등장할수록 관련성이 높습니다.
IDF (Inverse Document Frequency) — 전체 문서에서 해당 단어가 얼마나 희귀한가입니다. "the"같은 흔한 단어보다 "Elasticsearch"같은 특수한 단어에 더 높은 가중치를 줍니다.
이 통계 데이터도 세그먼트 안에 함께 저장되어 있기 때문에, 별도의 계산 없이 검색 시점에 바로 스코어링이 가능합니다.
3. 벡터 검색 — 역인덱스로는 불가능한 영역
왜 역인덱스만으로는 부족할까요
역인덱스는 정확한 키워드 매칭에 특화되어 있습니다. "검색"이라는 단어가 있는 문서를 찾는 건 잘하지만, "조회", "탐색", "찾기" 같은 유사한 의미의 단어까지 연결하지는 못합니다.
벡터 검색은 이 한계를 넘습니다. 텍스트를 임베딩 모델에 넣어 고차원 벡터(숫자 배열) 로 변환하면, 의미가 비슷한 텍스트는 벡터 공간에서 가까운 위치에 놓이게 됩니다. 검색은 "가장 가까운 벡터를 찾는 것"이 됩니다.
"검색 엔진" → [0.82, -0.15, 0.43, ...]
"탐색 시스템" → [0.79, -0.12, 0.41, ...] ← 의미가 비슷 → 벡터도 가까움
"맛있는 피자" → [-0.31, 0.67, -0.22, ...] ← 의미가 다름 → 벡터도 멀리
여기서 핵심은, 벡터 간의 거리를 계산하는 것(코사인 유사도 등)은 역인덱스로 할 수 있는 게 아니라는 점입니다. 완전히 다른 자료구조가 필요합니다.
HNSW — 벡터 검색의 핵심 자료구조
ES(Lucene)가 벡터 검색에 사용하는 자료구조는 HNSW(Hierarchical Navigable Small World) 라는 그래프 구조입니다.
HNSW는 데이터를 노드로 만들고, 거리가 가까운(의미가 비슷한) 노드들끼리 엣지로 연결한 그래프를 구성합니다. 탐색 속도를 높이기 위해 여러 계층(Hierarchy)으로 나뉘어 있습니다.
계층 2 (고속도로): A ─────────────── D
│ │
계층 1 (국도): A ──── B ──── C ── D
│ │ │ │
계층 0 (골목길): A ─ E ─ B ─ F ─ C ─ G ─ D ─ H
검색: "A에서 가장 가까운 이웃 찾기"
→ 계층 2에서 대략적인 방향 잡기
→ 계층 1로 내려가서 범위 좁히기
→ 계층 0에서 정밀 탐색
최상위 계층에서 대략적인 위치를 잡고, 아래 계층으로 내려가면서 점점 정밀하게 가장 가까운 이웃을 찾아나갑니다. 전체 벡터를 다 비교하지 않아도 되므로 빠릅니다. (정확한 최근접이 아닌 근사 최근접을 찾는 ANN 알고리즘입니다.)
벡터 데이터도 세그먼트 안에 들어갑니다
여기서 중요한 점이 있습니다. HNSW는 역인덱스와 완전히 다른 자료구조이지만, 디스크에 저장되는 방식은 동일한 세그먼트 아키텍처를 따릅니다.

문서가 들어오면 메모리에서 역인덱스와 HNSW 그래프를 동시에 구성한 뒤, 하나의 세그먼트로 플러시합니다. 이 덕분에 ES는 하나의 엔진 안에서 텍스트 검색(BM25)과 벡터 검색을 동시에 수행하고 점수를 조합하는 하이브리드 검색이 가능합니다. 예를 들어 "검색 엔진"이라는 키워드로 BM25 점수를 뽑고, 동시에 해당 쿼리의 임베딩 벡터로 의미적 유사도를 계산한 뒤, 두 점수를 가중 합산하여 최종 랭킹을 매길 수 있습니다.
4. 세그먼트 파편화 — 벡터 검색에서 특히 치명적인 이유
3편에서 SSTable이 많아지면 읽기 성능이 떨어진다고 했습니다. 세그먼트도 마찬가지인데, 벡터 검색에서는 이 문제가 훨씬 심각합니다.

텍스트 검색(역인덱스)의 경우
세그먼트가 10개면 역인덱스(단어장)도 10개입니다. "검색"이라는 단어를 찾으려면 10개를 다 뒤져야 하지만, 역인덱스 조회는 기본적으로 가벼운 연산입니다. 정렬된 단어장에서 키를 찾는 것이니까요.
벡터 검색(HNSW)의 경우
세그먼트가 10개면, 하나의 통합된 HNSW 그래프가 아니라 서로 연결되지 않은 10개의 미니 그래프가 존재하게 됩니다. 검색 시에는 각 그래프에서 독립적으로 Top-K를 뽑아낸 뒤, 결과를 모아서 다시 정렬해야 합니다.
이것이 심각한 이유는 세 가지입니다.
탐색 중복. 통합 그래프였다면 최상위 계층에서 한 번에 방향을 잡고 내려올 수 있는데, 미니 그래프마다 계층 탐색을 처음부터 반복해야 합니다.
CPU 연산 폭발. 벡터 간 거리 계산(코사인 유사도 등)은 고차원 실수 배열 연산이라 CPU 비용이 큽니다. 이걸 세그먼트 수만큼 배수로 수행해야 합니다.
결과 병합 오버헤드. 각 세그먼트에서 뽑은 후보들을 메모리에 모아 다시 거리 비교 후 정렬해야 합니다.
Force Merge — 세그먼트를 하나로 합치기
이 문제의 해결책은 단순합니다. 세그먼트를 1개로 강제 병합(Force Merge) 하면 됩니다. 10개의 미니 HNSW 그래프가 1개의 거대한 통합 그래프로 합쳐지면서, 탐색 중복과 결과 병합 오버헤드가 모두 사라집니다.
이렇게 하면 텍스트 검색에서도 이점이 있습니다. 역인덱스가 하나로 합쳐지면서 I/O가 줄고, 삭제 표시만 되어있던 찌꺼기 문서들이 물리적으로 제거되며, BM25 스코어링에 사용되는 통계(IDF 등)가 전체 데이터 기준으로 정확해집니다.
⚠️ 주의: Force Merge는 전체 세그먼트를 다시 쓰는 무거운 작업입니다. 특히 HNSW 그래프 병합은 모든 노드의 거리를 다시 계산하고 엣지를 재구성해야 하므로 역인덱스 병합보다 훨씬 비쌉니다. 따라서 더 이상 쓰기가 발생하지 않는 Read-only 인덱스에만 수행하는 것이 철칙입니다. 실시간으로 데이터가 들어오는 활성 인덱스에 Force Merge를 걸면 쓰기가 막히는 심각한 병목이 발생할 수 있습니다.
5. 정리 — 세그먼트 안의 두 세계
| 항목 | 텍스트 검색 | 벡터 검색 |
| 세그먼트 내부 구조 | 역인덱스 (Inverted Index) | HNSW 그래프 |
| 찾는 방식 | 정확한 키워드 매칭 | 벡터 거리 계산 (근사 최근접) |
| 스코어링 | BM25 (TF-IDF 기반) | 코사인 유사도, 내적 등 |
| 세그먼트 파편화 영향 | 있음 (I/O 증가) | 심각함 (CPU 연산 폭발) |
| 병합 비용 | 상대적으로 저렴 (단어장 머지) | 매우 비쌈 (그래프 재구성) |
| 디스크 저장 방식 | 동일: LSM-Tree 방식의 세그먼트 아키텍처 | 동일: LSM-Tree 방식의 세그먼트 아키텍처 |
두 구조 모두 "메모리에 모았다가 불변 파일로 플러시하고, 나중에 병합한다"는 LSM-Tree 방식의 세그먼트 아키텍처 위에서 돌아갑니다. 택배 상자(세그먼트)는 같은데, 안에 담기는 내용물(역인덱스 vs HNSW)이 다른 것입니다.
마무리
결국 모든 것은 트레이드오프입니다. "일단 빠르게 쓰고 나중에 정리한다"는 LSM-Tree의 철학은 검색 엔진에서도 유효했고, 그 위에 역인덱스와 HNSW라는 서로 다른 무기를 올려놓음으로써 키워드 검색과 의미 검색을 하나의 엔진에서 해결하는 하이브리드 검색이 가능해졌습니다.
← 이전 편: 3편: B-Tree vs LSM-Tree — RDB와 NoSQL은 왜 다른 스토리지 엔진을 선택했을까
레퍼런스
데이터 중심 애플리케이션 설계 - 마틴 클레프만
읽어주셔서 감사합니다. 잘못된 내용이나 보완할 점이 있다면 댓글로 알려주세요!
'Data' 카테고리의 다른 글
| [Data] 보조 색인에서 인메모리 DB까지 — 데이터를 찾는 다양한 전략 (0) | 2026.03.31 |
|---|---|
| [Data] SQL vs MapReduce vs 그래프 질의 언어 — 선언형과 명령형 사이에서 (1) | 2026.03.29 |
| [Data] B-Tree vs LSM-Tree — RDB와 NoSQL은 왜 다른 스토리지 엔진을 선택했을까 (0) | 2026.03.28 |
| [Data] RDB vs 문서 DB vs 그래프 DB — 스키마 변동성과 데이터 지역성 관점에서 (1) | 2026.03.28 |