bedaily.me
Backend2026년 4월 14일31분 읽기

Elasticsearch 7 아키텍처 — 검색 엔진의 속을 들여다보다

Elasticsearch는 JSON 문서를 색인해 전문 검색과 집계를 고속으로 수행하는 분산 검색·분석 엔진이다. Index → Shard → Segment로 내려가는 계층 구조와 쓰기·검색 경로, 복제·장애 복구까지 실무 관점으로 정리한다.

Elasticsearch검색 엔진분산 시스템Lucene역색인

개요

Elasticsearch는 "Apache Lucene 위에 세운 분산 검색·분석 엔진"이다. JSON 문서를 색인하면 단어 기반 전문 검색과 조건별 통계 처리를 빠르게 수행한다.

검색, 로그·관측성, 분석 대시보드 같은 워크로드에 적합하다. 트랜잭션·조인 중심의 주 저장소가 아니라 보조 계층이라는 점을 먼저 짚고 가자. Elasticsearch의 설계 철학은 수평 확장, 고가용성, near real-time이다.

RDB vs Elasticsearch 용어 대응Database → IndexTable → Index/매핑Row → Document (JSON)Index → Inverted Index

클러스터 토폴로지

Elasticsearch 클러스터는 cluster.name이 같은 노드들의 집합이다. 노드 하나는 ES 프로세스 하나에 대응하고, 외부 통신은 REST 9200, 내부 통신은 transport 9300 포트를 사용한다.

Master 노드가 클러스터 상태(매핑·샤드 위치·노드 목록)를 관리하고, 상태 변경을 모든 노드에 publish한다.

Elasticsearch Cluster (cluster.name: my-app)Node 1 (Master)master-eligible, dataP0R1Node 2 (Data)dataP1R2Node 3 (Data)dataP2R0범례Primary 샤드Replica 샤드Master-eligible 노드Data 노드✦ Primary와 Replica는 반드시 다른 노드에 배치된다.✦ Replica 수는 운영 중 변경 가능하지만, Primary 수는 생성 시 고정된다.

멀티 클러스터 환경에서는 Cross-Cluster Search(CCS)로 여러 클러스터를 동시에 검색하거나, Cross-Cluster Replication(CCR)으로 원격 클러스터에 인덱스를 복제할 수 있다.

논리 구조와 물리 구조

Elasticsearch의 데이터 계층은 Index → Shard → Segment 세 단계로 내려간다.

Index → Shard → Segment 계층Index (매핑 + 세팅)Shard P0 (= Lucene Index)Shard R0 (Replica, 다른 노드)Segments (불변)segment _0 [doc doc doc]segment _1 [doc doc]segment _2 [doc]translog (WAL)각 Segment 내부Inverted Index (term→doc 매핑)Stored Fields (_source)Doc Values (정렬·집계용 컬럼)Norms (스코어링 가중치)Points (숫자·위치 BKD 트리)핵심 제약• Segment는 생성 후 수정 불가 (불변)• Primary 샤드 수는 생성 시 고정
  • Index: 매핑과 세팅을 가진 논리 단위다. ES7부터 타입이 하나(_doc)로 통합됐다.
  • Shard: 인덱스의 조각이자 독립된 Lucene 인덱스. ES7의 기본값은 primary 1, replica 1이다 (ES6의 primary 5에서 변경).
  • Segment: 불변 파일 묶음이다. 생성 후 수정할 수 없고, 삭제는 표시만 해둔다.

노드 역할과 클러스터 코디네이션

ES7에서는 노드 역할이 명확히 분리됐다.

ES7 노드 역할master-eligible클러스터 상태 관리샤드 할당 · 노드 추적보통 3개 (홀수)data샤드 저장CRUD · 검색 · 집계CPU·메모리·디스크 집약ingest인덱싱 전 전처리pipeline 실행변환·필터·보강coordinating요청 분배 · 결과 병합모든 노드의 기본 능력전용 노드 분리 가능ES7 코디네이션 변화• discovery.seed_hosts: 접속 시도할 노드 후보 목록• cluster.initial_master_nodes: 최초 부트스트랩 전용 (재시작 시 사용 금지)• 투표 기반 마스터 선출 → split-brain 자동 방지• minimum_master_nodes 수동 설정 제거

ES 7.9부터는 node.roles 배열로 역할을 통합 설정할 수 있다.

node.roles: [ master, data ]

샤드 라우팅 · 복제 · 장애 복구

라우팅 공식

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

shard = hash(_routing) % primary_shard_count

_routing의 기본값은 문서 _id다. 이 공식이 Primary 샤드 수가 생성 후 변경 불가인 이유다. 샤드 수가 바뀌면 동일 _id의 라우팅 결과가 달라져서 문서를 찾을 수 없게 된다.

쓰기 흐름

쓰기 경로 (Write Path)Client①Coordinating라우팅 결정②Primary Shard기록③Replica 1 (병렬)Replica 2 (병렬)④ ACK흐름 정리① Client → Coordinating: 아무 노드나 받을 수 있음② Coordinating → Primary: hash(_routing) % primary 수로 대상 결정③ Primary → in-sync Replica: 병렬 복제, in-sync 응답(실패 시 격리) 후 ④ 응답

읽기

읽기는 Primary/Replica 아무 사본에서 가능하다. ES7은 기본적으로 adaptive replica selection을 사용해서 응답이 빠른 사본을 우선 선택한다.

복제 내부

  • seq_no · primary_term: 쓰기 연산마다 시퀀스 번호와 primary 임기를 부여한다. 장애 시 어느 연산까지 복제됐는지 추적할 수 있다.
  • 체크포인트 기반 복구: local/global checkpoint으로 복제 상태를 추적하고, 노드 복구 시 마지막 체크포인트 이후만 재전송한다.

쓰기 경로: refresh · translog · flush · merge

문서 하나가 색인될 때 거치는 단계를 시간축으로 보면 다음과 같다.

쓰기 경로 타임라인1단계인메모리 버퍼 + translog(디스크 WAL)에 기록검색 불가 ✗내구성 확보 ✓↓ refresh (기본 1초)2단계새 세그먼트 생성 → OS 파일 캐시에 적재검색 가능 ✓내구성 미확보 ✗↓ flush (주기적)3단계디스크 fsync — translog 비움검색 가능 ✓내구성 확보 ✓백그라운드 merge: 작은 세그먼트 → 큰 세그먼트로 병합 · 삭제 표시 정리

핵심은 refresh ≠ flush라는 것이다. refresh는 검색 가능 상태를 만들고(기본 1초), flush는 디스크에 영구 저장한다. 이 1초 간격이 Elasticsearch가 "near real-time"인 이유다. 색인 직후 바로 검색되지 않는다.

# 급한 경우: 색인 후 즉시 검색 가능하게
POST /my-index/_doc/1?refresh=wait_for

# 처리량 우선: 대량 적재 시 refresh 끄기
index.refresh_interval: -1

Translog은 내구성을 담당한다. flush 전에 노드가 죽으면 translog를 재생해서 데이터를 복구한다.

# 기본값: 매 요청마다 fsync (안전하지만 느림)
index.translog.durability: request

# 성능 우선: 비동기 sync (유실 위험 있음)
index.translog.durability: async
index.translog.sync_interval: 5s

Merge는 세그먼트 관리의 핵심이다. 세그먼트는 불변이므로 update는 "기존 문서 삭제 표시 + 새 문서 추가"로 처리된다. 삭제 표시된 문서는 merge 때 실제로 정리된다. 색인이 완료된 인덱스에 _forcemerge를 실행하면 검색 성능이 올라간다.

검색 경로: Query → Fetch (scatter-gather)

검색은 2단계로 나뉜다.

검색 2단계: Query → FetchQuery 단계Coordinating요청 분배결과 병합Shard 0Shard 1Shard 2_id + score만 반환 (본문 X)_id + score만 반환 (본문 X)_id + score만 반환 (본문 X)Fetch 단계Coordinating전역 정렬 + top NShard 0 (해당 건만)Shard 2 (해당 건만)_source (본문) 반환_source (본문) 반환⚠ Deep paging: from:10000이면 각 샤드 10010건 정렬

Query 단계에서는 모든 샤드에 검색을 보내고, 각 샤드는 _id와 점수만 돌려준다. Coordinating 노드가 전역 정렬 후 상위 N건을 확정하면, Fetch 단계에서 해당 샤드에서만 본문(_source)을 회수한다.

Deep paging 함정: from: 10000, size: 10이면 각 샤드가 10,010건을 정렬해서 보내야 한다. 해결책은 search_after, scroll, 또는 7.10+ Point in Time(PIT)을 사용하는 것이다.

집계(Aggregation): 검색과 별개 경로로, 각 샤드가 부분 집계 후 coordinating이 합산한다. doc_values(디스크 컬럼 저장)를 사용해서 고속화한다.

스코어링: ES7 기본은 BM25 (TF-IDF 개선판)이다. filter 컨텍스트는 점수 계산을 건너뛰고 캐시된다.

역색인(Inverted Index)

역색인은 Elasticsearch가 빠른 이유의 핵심이다.

역색인 (Inverted Index) 원리원본 문서doc1: "빠른 갈색 여우"doc2: "느린 갈색 개"doc3: "빠른 하얀 개"분석역색인 (term → posting list)빠른→ doc1, doc3갈색→ doc1, doc2여우→ doc1개→ doc2, doc3느린→ doc2하얀→ doc3검색: "갈색" → posting list 즉시 조회 → doc1, doc2 반환 (전체 문서 스캔 불필요)역색인은 세그먼트 단위로 존재한다. 세그먼트가 많으면 여러 역색인을 훑어야 → merge가 중요한 이유

애널라이저 — 텍스트가 토큰이 되는 과정

애널라이저(Analyzer)는 원본 텍스트를 역인덱스에 들어갈 토큰으로 가공하는 파이프라인이다. 세 단계를 순서대로 거친다.

input<b>TheCats</b>① CharacterFilterhtml_stripHTML 태그 제거② Tokenizer공백 기준으로단어 분리반드시 1개만 지정③ TokenFilterlowercase, stop…0개 이상, 순서 적용cat"the" 제거됨소문자·어간화
  • Character Filter — 토큰화 전에 원문을 손본다. html_strip으로 <b> 태그 제거.
  • Tokenizer — 텍스트를 토큰으로 자르는 핵심 단계. 정확히 하나만 지정.
  • Token Filter — 잘린 토큰을 후처리. 여러 개를 순서대로 적용.

세 단계 모두 부품처럼 골라 조립한다. 개수 규칙만 다르다 — 캐릭터 필터는 0개 이상, 토크나이저는 정확히 1개(필수), 토큰 필터는 0개 이상.

토크나이저 — 어떻게 자를지 결정

토큰 필터가 아무리 좋아도, 애초에 어떻게 자르느냐(토크나이저)가 검색 범위를 좌우한다.

토크나이저자르는 방식
standard공백·문장부호 기준. 일반 텍스트의 기본
whitespace공백에서만 자른다 (기호·대소문자 유지)
lowercase문자가 아닌 곳에서 자르고 소문자화 (숫자·기호 버림)
keyword자르지 않고 입력 전체를 1토큰으로
nori_tokenizer한글 형태소 단위 (Nori 플러그인)
edge_ngram / ngram접두어·글자 조각으로 잘게 쪼갬 (자동완성용)

캐릭터 필터 — 자르기 전에 원문 손질

토큰화 이전 단계라, 결과는 아직 토큰이 아니라 다듬어진 문자열이다.

html_stripmappingpattern_replace<p>Hello</p>→HelloAT&T→ATandT010-1234-5678→01012345678

html_strip은 태그 제거, mapping은 지정 문자 치환(& → and), pattern_replace는 정규식 치환(- 제거). 검색에 방해되는 문자를 색인 전에 정리한다.

기본 제공 애널라이저

애널라이저동작언제 쓰나
standard공백·문장부호로 자르고 소문자화. 숫자는 유지기본값. 특별한 요구 없으면 대부분 이걸로
simple문자가 아닌 것 모두 기준으로 자르고 소문자화 (숫자·기호 버림)숫자·기호가 의미 없는 순수 텍스트
whitespace공백으로만 자른다. 소문자화·기호 제거 안 함 (원형 보존)대소문자·기호를 그대로 살려야 할 때
stopsimple처럼 자르고 소문자화 + 불용어(a, the, or…) 제거영문에서 흔한 불용어 노이즈를 걷어낼 때
keyword자르지 않고 통째로 하나의 토큰. 분석 없이 원문 그대로정확 일치·정렬·집계 (보통 keyword 타입으로 대체)

같은 문장을 애널라이저별로 넣으면 토큰이 이렇게 갈린다.

입력"The Cats & 3 Dogs"애널라이저별 결과 비교 — 점선 테두리 = 버려진 토큰 / 회색 = 원형 보존 / 노란 테두리 = 통째standardsimplewhitespacestopkeywordthecats3dogsthecatsdogs·3TheCats&3Dogscatsdogs·the3The Cats & 3 Dogsstandard는 숫자(3)를 남기지만 simple·stop은 버린다. whitespace만 대소문자·기호(&)를 보존한다. stop은 불용어 "the"까지 뺀다.

기본 애널라이저 = 부품들의 프리셋

위 애널라이저들은 결국 아래 부품 조합을 미리 묶어둔 것이다. 프리셋이 안 맞으면 이 조합을 직접 바꿔 커스텀으로 만든다.

애널라이저char filtertokenizertoken filter
standard없음standardlowercase
simple없음lowercase없음
whitespace없음whitespace없음
stop없음lowercasestop
keyword없음keyword없음

캐릭터 필터는 **선택 사항(0개 이상)**이라 대부분의 기본 애널라이저는 쓰지 않는다 — 원문을 그대로 토크나이저에 넘긴다. html_strip 같은 게 필요하면 커스텀 애널라이저에서 직접 추가해야 한다. 한편 simple·stop이 숫자를 버린 건 lowercase 토크나이저(문자만 남김) 때문이고, stop이 불용어까지 없앤 건 거기에 stop 토큰 필터가 더해져서다.

자주 쓰는 토큰 필터

필터역할언제 쓰나
lowercase대소문자 통일. "Apple"과 "apple"을 같은 토큰으로거의 항상. 대소문자 구분 없는 검색의 기본
stop불용어 제거. 색인 크기↓, 노이즈↓영문 본문 검색 (한글엔 효과 적음)
stemmer어형을 어간으로 환원. 시제·복수형 흡수영문에서 running/runs를 run으로 함께 잡을 때
synonym동의어를 같은 토큰으로 묶음노트북↔랩탑처럼 다른 표현을 함께 검색

각 필터를 통과하면 토큰이 이렇게 바뀐다.

lowercasestopstemmersynonymQuickBROWNquickbrownaquickfoxquickfox"a" 불용어 제거runningjumpsrunjump시제·복수형 어간화laptoplaptopnotebook← 동의어 추가

stemmer·stop은 토큰을 줄이고, synonym은 토큰을 늘린다(동의어 추가). 그래서 notebook으로 색인된 문서를 "laptop"으로도 찾게 된다.

중요 색인 때 쓴 애널라이저와 검색 때 쓰는 애널라이저가 같아야 결과가 맞는다. 둘 다 lowercase를 거쳐야 "Apple" 검색이 "apple" 색인어와 매칭된다.

애널라이저는 언제·어디서 지정하나

애널라이저는 인덱스를 만들 때 필드 매핑에 박아두는 설정이다. 한 번 지정하면 그 필드의 색인·검색에 자동으로 적용된다.

  • 언제 정하나 — 인덱스 생성 시, 필드별 매핑에 지정한다. 명시하지 않으면 기본값 standard가 적용된다 (한글은 nori를 명시해야 함).
  • 색인 시점 — 그 필드에 값을 저장하면, 지정된 애널라이저가 자동으로 토큰을 쪼개 역인덱스에 넣는다.
  • 검색 시점 — 같은 필드를 검색하면 같은 애널라이저로 검색어도 분석한 뒤 역인덱스와 대조한다. 색인용·검색용이 달라지면 매칭이 깨진다.

흐름은 인덱스 생성(매핑에 애널라이저 지정) → 색인(자동 분석) → 검색(같은 분석 후 대조). standard·nori 같은 기본 제공 애널라이저는 매핑에 이름만 쓰면 되고, 필터를 직접 조합한 커스텀 애널라이저는 settings.analysis에서 정의해 이름을 붙인 뒤 매핑에서 참조한다.

📄PUT /products — 커스텀 애널라이저 정의 후 필드에 지정
{
  "settings": { "analysis": { "analyzer": {
    "my_nori": { "type": "custom", "tokenizer": "nori_tokenizer",
                 "filter": ["lowercase", "nori_part_of_speech"] }
  } } },
  "mappings": { "properties": {
    "name": { "type": "text", "analyzer": "my_nori" }
  } }
}

토큰이 실제로 어떻게 쪼개지는지는 _analyze API로 확인한다. 매핑 잡기 전에 반드시 찍어보는 습관이 좋다.

📄POST /_analyze
{
  "analyzer": "standard",
  "text": "The Quick Brown Foxes"
}
// → ["the", "quick", "brown", "foxes"]  (소문자화 + 공백 분리)

text vs keyword

타입동작용도
textanalyzer로 쪼개서 역색인 저장전문 검색
keyword통째로 저장정확 일치 · 정렬 · 집계

실무에서는 Multi-field 매핑으로 둘 다 사용하는 것이 일반적이다.

{
  "name": {
    "type": "text",
    "fields": {
      "keyword": { "type": "keyword" }
    }
  }
}

데이터 티어 & ILM 수명주기

시계열 데이터(로그, 메트릭, 이벤트)는 시간이 지나면 가치가 떨어진다. Elasticsearch 7.9+의 데이터 티어와 ILM(Index Lifecycle Management)으로 이를 자동 관리한다.

ILM 데이터 티어 수명주기HotSSD쓰기 + 잦은 검색Warm읽기 전용shrink · forcemergeCold드문 검색replica 수 감소Frozen7.12+스냅샷 마운트Delete자동 삭제Rollover크기·문서수·기간 도달 시 새 인덱스로 전환, alias 자동 승계ILM위 단계 전환을 자동 수행 — 오래된 데이터의 저장 비용을 단계적으로 절감

실무에서 빠지면 안 되는 설정

메모리·JVM

물리 RAM 배분JVM Heap (50%) — 26~30GB 이하 권장OS 페이지 캐시 (50%) — Lucene이 사용Xms = Xmx 동일 설정 · 32GB 미만으로 JVM 압축 OOP 유지 · bootstrap.memory_lock: true로 스왑 방지

샤드 사이징

  • 샤드 1개당 10~50GB를 권장한다.
  • 노드당 샤드 수는 힙 1GB당 20개 이하가 경험칙이다.
  • cluster.max_shards_per_node 기본값은 1000.
  • ES7 기본이 primary 1이라 over-sharding은 줄었지만, 시계열 인덱스가 많으면 여전히 주의해야 한다.

인덱싱 성능

# 기본: Bulk API로 묶음 전송 (단건 X)
# 배치 크기: 5~15MB부터 튜닝 시작

# 대량 적재 시 최적화
index.refresh_interval: -1
index.number_of_replicas: 0
# 완료 후 원복 + _forcemerge 실행

안정성·운영

항목설정비고
Master 노드3개 (홀수)투표 기반 합의
디스크 워터마크low 85% / high 90% / flood 95%flood에서 쓰기 차단
Circuit breakeruse_real_memory: true실제 힙 사용량 기반
존 분산allocation.awareness노드를 AZ별로 분산
보안TLS + 기본 인증 + RBAC기본 라이선스에 포함

백업·복구

  • 스냅샷: _snapshot API로 S3·공유 파일시스템에 백업. 증분 방식으로 변경 세그먼트만 저장한다.
  • Searchable snapshots (7.12+): 스냅샷을 frozen 티어로 마운트해서 검색 가능한 상태로 유지한다.
  • 샤드 재배치: 노드 추가/제거 시 자동 재분배된다.

인덱스 관리

  • Index template: 새 인덱스에 매핑·세팅 자동 적용. 7.8+의 Component template으로 조합 가능.
  • Alias: 애플리케이션은 항상 alias로 접근한다. 무중단 인덱스 전환이 가능하다.
  • 매핑 주의: 필드 타입은 변경 불가(추가만 가능). dynamic: strict 또는 명시적 매핑을 권장한다.

ES6에서 ES7로 주요 변화

ES6 → ES7 핵심 변경 사항ES6: 기본 primary 5 · 매핑 타입 여러 개ES7: 기본 primary 1 · 타입 _doc 하나Zen Discovery + minimum_master_nodes 수동새 합의 코디네이션 + 자동 quorum별도 JDK 설치 필요번들 JDK 포함추정 기반 circuit breaker실메모리 기반 circuit breaker+ adaptive replica selection 기본 활성화 · node.roles 배열(7.9+) · 데이터 티어 도입

마무리

Elasticsearch 7은 Index → Shard → Segment로 내려가는 계층 구조 위에서 동작한다. 이 글을 관통하는 핵심 세 가지를 기억하자.

  1. "샤드는 분산의 단위이자 Lucene 인덱스 그 자체다" — 샤드 수 = Lucene 인스턴스 수. 너무 많으면 오버헤드, 너무 적으면 확장 한계.
  2. "Refresh와 flush는 다른 일이다" — refresh는 검색 가능, flush는 디스크 영구 저장. 이 구분이 near real-time과 내구성을 모두 설명한다.
  3. "Primary 샤드 수는 라우팅에 묶여 생성 후 변경 불가" — 운영 중 변경 가능한 건 replica 수뿐이다. 초기 설계가 중요한 이유.

주간 기술 뉴스레터

Backend · AI · Java 핵심 내용을 매주 이메일로 보내드립니다.