← all posts
DEV 2026.05.02 · 15 min read Intermediate

Elasticsearch는 어떻게 분산 검색을 완성하는가

Lucene 위에 쌓인 5계층 구조부터 Split-Brain 방지, 라우팅 수식, Scatter-Gather 읽기 경로까지 Elasticsearch의 설계 결정을 추적한다.


Elasticsearch는 Lucene이 아니다. Lucene은 단일 머신 위에서 동작하는 검색 라이브러리고, Elasticsearch는 그 위에 분산 레이어를 얹은 시스템이다. 이 구분이 사소해 보이지만, 운영에서 마주치는 대부분의 문제는 “ES를 Lucene인 줄 알고 쓸 때” 발생한다. 클러스터, 노드, 인덱스, 샤드, 세그먼트 — 이 5계층이 각각 무엇을 책임지고, 문서 하나가 이 계층을 어떤 순서로 통과하는가?

Lucene 위에 무엇을 얹었는가

Lucene이 제공하는 것은 역색인 구축, 세그먼트 관리, BM25 스코어링이다. 단일 프로세스 안에서만 동작하고, Java API로만 접근할 수 있다. 여기에 Elasticsearch가 추가한 것은 세 가지다.

첫째, 분산 레이어. 문서를 어느 샤드에 저장할지 결정하는 라우팅, Primary에서 Replica로의 복제, 모든 샤드에 병렬 요청을 보내고 결과를 병합하는 Scatter-Gather 검색.

둘째, 접근성. REST API와 HTTP/JSON으로 언어 무관하게 접근할 수 있다.

셋째, 운영 도구. 인덱스 생명주기 관리(ILM), 스냅샷, _cat API.

각 샤드는 완전한 Lucene 인스턴스다. 인덱스 하나는 여러 샤드로 쪼개지고, 각 샤드는 독립적으로 색인하고 검색한다. ES는 이 Lucene 인스턴스들 위에서 분산 조율을 담당한다.

5계층 구조와 노드 역할 분리

계층 구조는 클러스터 → 노드 → 인덱스 → 샤드 → 세그먼트로 이어진다.

클러스터는 노드들의 집합이자 공유 클러스터 상태를 가진 단위다. 클러스터 상태에는 모든 노드 정보, 인덱스 메타데이터, 샤드 라우팅 테이블이 담긴다. 인덱스는 MySQL의 테이블에 대응하지만, 내부적으로 샤드로 분산된다는 점에서 다르다. 샤드는 Lucene 인스턴스 하나다. 세그먼트는 샤드 내부의 불변 단위로, refresh(기본 1초)마다 새 세그먼트가 생성되어 검색이 가능해진다.

노드 역할은 셋으로 나뉜다.

Master 노드:    클러스터 상태 관리, 샤드 배치 결정
Data 노드:      샤드 저장, 검색·집계 처리
Coordinating:   요청 라우팅, 결과 병합

대규모에서 Master 전용 노드를 3대(홀수)로 분리하는 이유는 명확하다. Data 노드의 GC pause가 Master에 영향을 주지 않아야 하고, Quorum(과반수)을 보장해야 한다.

Split-Brain 방지 — Raft 기반 합의

클러스터 운영에서 가장 위험한 장애는 Split-Brain이다. 네트워크 파티션으로 두 노드 그룹이 각자 자신을 Master로 선출하면, 두 개의 독립 클러스터가 서로 다른 문서를 인덱싱한다. 네트워크가 복구된 후 어느 쪽이 진실인지 판단할 수 없다.

ES 7.x 이전의 Zen Discovery는 minimum_master_nodes를 운영자가 수동으로 관리해야 했다. 노드를 추가할 때마다 이 값을 업데이트하지 않으면 Split-Brain 위험이 남는다.

ES 7.x부터 Raft 기반 합의로 전환하면서 이 문제가 수학적으로 해소됐다.

Raft의 핵심 보장

동일 term에서 최대 1개의 Master만 선출된다. 양쪽 파티션이 각각 과반수 투표를 얻으려면 공통 노드가 양쪽 모두에 투표해야 하는데, 한 노드는 한 term에서 한 번만 투표할 수 있다. 따라서 Split-Brain은 수학적으로 불가능하다.

3노드 구성에서 Quorum은 2다. 1노드 장애 시 나머지 2노드가 Quorum을 충족해 새 Master를 선출하고 서비스를 이어간다. 짝수 구성(4노드)은 2:2 분리 시 어느 쪽도 Quorum을 충족하지 못해 클러스터 전체가 마비될 수 있어 피해야 한다.

라우팅 수식 — Primary 샤드 수가 고정인 이유

문서가 어느 샤드에 저장될지는 다음 수식으로 결정된다.

shard_num = hash(_id) % number_of_primary_shards

number_of_primary_shards가 바뀌면 기존 모든 문서의 라우팅 결과가 달라진다. hash("abc123") % 3 = 0이던 문서가 hash("abc123") % 5에서는 다른 샤드를 가리킨다. 기존 문서를 찾을 수 없게 되므로, Primary 샤드 수는 인덱스 생성 시 영구 고정된다.

반면 Replica 수는 운영 중 언제든 변경 가능하다. Primary가 쓰기 진입점과 복제 오케스트레이터 역할을 맡고, Replica는 읽기 분산과 장애 시 Primary 승격을 담당한다. Primary 장애 시에는 In-sync Replica Set(IRS)에 포함된 Replica만 승격 대상이 된다.

샤드 수를 변경해야 할 때는 _split(배수로 증가), _shrink(약수로 감소) API가 있지만, 근본적인 재설계는 Reindex가 유일한 선택이다. 처음 설계가 운영 전체를 좌우하는 이유가 여기 있다.

트레이드오프

샤드가 너무 적으면 병렬 검색이 없고 용량 확장이 막힌다. 너무 많으면 Coordinating 노드의 병합 부하가 커지고 클러스터 상태가 비대해진다. Elastic의 공식 권장은 샤드 하나당 1050GB, 노드당 샤드 수 2025개 이하다.

쓰기와 읽기 — NRT의 “Near”와 Scatter-Gather

쓰기 경로는 Coordinating → Primary(로컬 인덱싱 + Translog 기록) → Replica 병렬 복제 순서로 진행된다. 인덱싱된 문서는 즉시 검색되지 않는다. 인메모리 버퍼에 들어간 후 refresh(기본 1초)가 실행되어야 새 세그먼트가 생성되고 검색이 가능해진다. Near Real-Time의 “Near”는 이 1초 지연이다.

Translog는 세그먼트가 디스크에 fsync되기 전 노드 재시작에 대비한다. 재시작 시 마지막 flush 이후의 Translog를 재생해 데이터를 복구한다.

읽기 경로는 세 단계다.

Query Phase (Scatter):
  모든 샤드에 병렬 쿼리 전송
  각 샤드: 문서 ID + 점수만 반환 (_source 없음)

Merge Phase:
  Coordinating이 전역 정렬 후 상위 N개 문서 ID 선정

Fetch Phase (Gather):
  선정된 문서의 _source를 해당 샤드에서만 가져옴

Query Phase에서 _source를 가져오지 않는 이유는 명확하다. 3개 샤드에서 각 10개씩 30개를 모은 후 최종 10개를 고르는데, 30개 전체의 본문을 미리 가져오면 20개가 낭비다.

from + size 페이지네이션이 깊어질수록 느려지는 이유도 여기 있다. from=10000, size=10이면 각 샤드는 상위 10,010개를 계산해서 보내야 한다. 3개 샤드라면 30,030개를 Coordinating이 병합해야 한다. search_after로 전환하면 마지막 결과의 sort 값을 커서로 사용해 항상 O(page_size) 비용으로 처리할 수 있다.

정리

  • Elasticsearch = Lucene(검색 코어) + 분산 레이어. 샤드 하나가 Lucene 인스턴스 하나다.
  • 5계층(클러스터 → 노드 → 인덱스 → 샤드 → 세그먼트)은 각각 명확한 책임을 가진다.
  • ES 7.x+의 Raft 기반 합의는 Split-Brain을 수학적으로 방지한다. Master-eligible 노드는 홀수(최소 3개)로 구성한다.
  • Primary 샤드 수는 라우팅 수식에 묶여 고정이다. 처음 설계가 운영 전체를 결정한다.
  • 읽기는 Scatter-Gather 2회 왕복으로 완성된다. 깊은 페이지네이션은 search_after로 대체한다.

다음 글에서는 각 샤드 내부의 Lucene 역색인이 B-Tree와 어떻게 다른지, 그리고 전문 검색이 왜 역색인 없이는 불가능한지 추적한다.