Elasticsearch 7 아키텍처 — 검색 엔진의 속을 들여다보다
Elasticsearch는 JSON 문서를 색인해 전문 검색과 집계를 고속으로 수행하는 분산 검색·분석 엔진이다. Index → Shard → Segment로 내려가는 계층 구조와 쓰기·검색 경로, 복제·장애 복구까지 실무 관점으로 정리한다.
개요
Elasticsearch는 "Apache Lucene 위에 세운 분산 검색·분석 엔진"이다. JSON 문서를 색인하면 단어 기반 전문 검색과 조건별 통계 처리를 빠르게 수행한다.
검색, 로그·관측성, 분석 대시보드 같은 워크로드에 적합하다. 트랜잭션·조인 중심의 주 저장소가 아니라 보조 계층이라는 점을 먼저 짚고 가자. Elasticsearch의 설계 철학은 수평 확장, 고가용성, near real-time이다.
클러스터 토폴로지
Elasticsearch 클러스터는 cluster.name이 같은 노드들의 집합이다. 노드 하나는 ES 프로세스 하나에 대응하고, 외부 통신은 REST 9200, 내부 통신은 transport 9300 포트를 사용한다.
Master 노드가 클러스터 상태(매핑·샤드 위치·노드 목록)를 관리하고, 상태 변경을 모든 노드에 publish한다.
멀티 클러스터 환경에서는 Cross-Cluster Search(CCS)로 여러 클러스터를 동시에 검색하거나, Cross-Cluster Replication(CCR)으로 원격 클러스터에 인덱스를 복제할 수 있다.
논리 구조와 물리 구조
Elasticsearch의 데이터 계층은 Index → Shard → Segment 세 단계로 내려간다.
- Index: 매핑과 세팅을 가진 논리 단위다. ES7부터 타입이 하나(
_doc)로 통합됐다. - Shard: 인덱스의 조각이자 독립된 Lucene 인덱스. ES7의 기본값은 primary 1, replica 1이다 (ES6의 primary 5에서 변경).
- Segment: 불변 파일 묶음이다. 생성 후 수정할 수 없고, 삭제는 표시만 해둔다.
노드 역할과 클러스터 코디네이션
ES7에서는 노드 역할이 명확히 분리됐다.
ES 7.9부터는 node.roles 배열로 역할을 통합 설정할 수 있다.
node.roles: [ master, data ]샤드 라우팅 · 복제 · 장애 복구
라우팅 공식
문서가 어느 샤드에 저장될지는 다음 공식으로 결정된다.
shard = hash(_routing) % primary_shard_count_routing의 기본값은 문서 _id다. 이 공식이 Primary 샤드 수가 생성 후 변경 불가인 이유다. 샤드 수가 바뀌면 동일 _id의 라우팅 결과가 달라져서 문서를 찾을 수 없게 된다.
쓰기 흐름
읽기
읽기는 Primary/Replica 아무 사본에서 가능하다. ES7은 기본적으로 adaptive replica selection을 사용해서 응답이 빠른 사본을 우선 선택한다.
복제 내부
- seq_no · primary_term: 쓰기 연산마다 시퀀스 번호와 primary 임기를 부여한다. 장애 시 어느 연산까지 복제됐는지 추적할 수 있다.
- 체크포인트 기반 복구: local/global checkpoint으로 복제 상태를 추적하고, 노드 복구 시 마지막 체크포인트 이후만 재전송한다.
쓰기 경로: refresh · translog · flush · merge
문서 하나가 색인될 때 거치는 단계를 시간축으로 보면 다음과 같다.
핵심은 refresh ≠ flush라는 것이다. refresh는 검색 가능 상태를 만들고(기본 1초), flush는 디스크에 영구 저장한다. 이 1초 간격이 Elasticsearch가 "near real-time"인 이유다. 색인 직후 바로 검색되지 않는다.
# 급한 경우: 색인 후 즉시 검색 가능하게
POST /my-index/_doc/1?refresh=wait_for
# 처리량 우선: 대량 적재 시 refresh 끄기
index.refresh_interval: -1Translog은 내구성을 담당한다. flush 전에 노드가 죽으면 translog를 재생해서 데이터를 복구한다.
# 기본값: 매 요청마다 fsync (안전하지만 느림)
index.translog.durability: request
# 성능 우선: 비동기 sync (유실 위험 있음)
index.translog.durability: async
index.translog.sync_interval: 5sMerge는 세그먼트 관리의 핵심이다. 세그먼트는 불변이므로 update는 "기존 문서 삭제 표시 + 새 문서 추가"로 처리된다. 삭제 표시된 문서는 merge 때 실제로 정리된다. 색인이 완료된 인덱스에 _forcemerge를 실행하면 검색 성능이 올라간다.
검색 경로: Query → Fetch (scatter-gather)
검색은 2단계로 나뉜다.
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가 빠른 이유의 핵심이다.
애널라이저 — 텍스트가 토큰이 되는 과정
애널라이저(Analyzer)는 원본 텍스트를 역인덱스에 들어갈 토큰으로 가공하는 파이프라인이다. 세 단계를 순서대로 거친다.
- Character Filter — 토큰화 전에 원문을 손본다.
html_strip으로<b>태그 제거. - Tokenizer — 텍스트를 토큰으로 자르는 핵심 단계. 정확히 하나만 지정.
- Token Filter — 잘린 토큰을 후처리. 여러 개를 순서대로 적용.
세 단계 모두 부품처럼 골라 조립한다. 개수 규칙만 다르다 — 캐릭터 필터는 0개 이상, 토크나이저는 정확히 1개(필수), 토큰 필터는 0개 이상.
토크나이저 — 어떻게 자를지 결정
토큰 필터가 아무리 좋아도, 애초에 어떻게 자르느냐(토크나이저)가 검색 범위를 좌우한다.
| 토크나이저 | 자르는 방식 |
|---|---|
standard | 공백·문장부호 기준. 일반 텍스트의 기본 |
whitespace | 공백에서만 자른다 (기호·대소문자 유지) |
lowercase | 문자가 아닌 곳에서 자르고 소문자화 (숫자·기호 버림) |
keyword | 자르지 않고 입력 전체를 1토큰으로 |
nori_tokenizer | 한글 형태소 단위 (Nori 플러그인) |
edge_ngram / ngram | 접두어·글자 조각으로 잘게 쪼갬 (자동완성용) |
캐릭터 필터 — 자르기 전에 원문 손질
토큰화 이전 단계라, 결과는 아직 토큰이 아니라 다듬어진 문자열이다.
html_strip은 태그 제거, mapping은 지정 문자 치환(& → and), pattern_replace는 정규식 치환(- 제거). 검색에 방해되는 문자를 색인 전에 정리한다.
기본 제공 애널라이저
| 애널라이저 | 동작 | 언제 쓰나 |
|---|---|---|
standard | 공백·문장부호로 자르고 소문자화. 숫자는 유지 | 기본값. 특별한 요구 없으면 대부분 이걸로 |
simple | 문자가 아닌 것 모두 기준으로 자르고 소문자화 (숫자·기호 버림) | 숫자·기호가 의미 없는 순수 텍스트 |
whitespace | 공백으로만 자른다. 소문자화·기호 제거 안 함 (원형 보존) | 대소문자·기호를 그대로 살려야 할 때 |
stop | simple처럼 자르고 소문자화 + 불용어(a, the, or…) 제거 | 영문에서 흔한 불용어 노이즈를 걷어낼 때 |
keyword | 자르지 않고 통째로 하나의 토큰. 분석 없이 원문 그대로 | 정확 일치·정렬·집계 (보통 keyword 타입으로 대체) |
같은 문장을 애널라이저별로 넣으면 토큰이 이렇게 갈린다.
기본 애널라이저 = 부품들의 프리셋
위 애널라이저들은 결국 아래 부품 조합을 미리 묶어둔 것이다. 프리셋이 안 맞으면 이 조합을 직접 바꿔 커스텀으로 만든다.
| 애널라이저 | char filter | tokenizer | token filter |
|---|---|---|---|
standard | 없음 | standard | lowercase |
simple | 없음 | lowercase | 없음 |
whitespace | 없음 | whitespace | 없음 |
stop | 없음 | lowercase | stop |
keyword | 없음 | keyword | 없음 |
캐릭터 필터는 **선택 사항(0개 이상)**이라 대부분의 기본 애널라이저는 쓰지 않는다 — 원문을 그대로 토크나이저에 넘긴다. html_strip 같은 게 필요하면 커스텀 애널라이저에서 직접 추가해야 한다. 한편 simple·stop이 숫자를 버린 건 lowercase 토크나이저(문자만 남김) 때문이고, stop이 불용어까지 없앤 건 거기에 stop 토큰 필터가 더해져서다.
자주 쓰는 토큰 필터
| 필터 | 역할 | 언제 쓰나 |
|---|---|---|
lowercase | 대소문자 통일. "Apple"과 "apple"을 같은 토큰으로 | 거의 항상. 대소문자 구분 없는 검색의 기본 |
stop | 불용어 제거. 색인 크기↓, 노이즈↓ | 영문 본문 검색 (한글엔 효과 적음) |
stemmer | 어형을 어간으로 환원. 시제·복수형 흡수 | 영문에서 running/runs를 run으로 함께 잡을 때 |
synonym | 동의어를 같은 토큰으로 묶음 | 노트북↔랩탑처럼 다른 표현을 함께 검색 |
각 필터를 통과하면 토큰이 이렇게 바뀐다.
stemmer·stop은 토큰을 줄이고, synonym은 토큰을 늘린다(동의어 추가). 그래서 notebook으로 색인된 문서를 "laptop"으로도 찾게 된다.
중요 색인 때 쓴 애널라이저와 검색 때 쓰는 애널라이저가 같아야 결과가 맞는다. 둘 다
lowercase를 거쳐야 "Apple" 검색이 "apple" 색인어와 매칭된다.
애널라이저는 언제·어디서 지정하나
애널라이저는 인덱스를 만들 때 필드 매핑에 박아두는 설정이다. 한 번 지정하면 그 필드의 색인·검색에 자동으로 적용된다.
- 언제 정하나 — 인덱스 생성 시, 필드별 매핑에 지정한다. 명시하지 않으면 기본값
standard가 적용된다 (한글은nori를 명시해야 함). - 색인 시점 — 그 필드에 값을 저장하면, 지정된 애널라이저가 자동으로 토큰을 쪼개 역인덱스에 넣는다.
- 검색 시점 — 같은 필드를 검색하면 같은 애널라이저로 검색어도 분석한 뒤 역인덱스와 대조한다. 색인용·검색용이 달라지면 매칭이 깨진다.
흐름은 인덱스 생성(매핑에 애널라이저 지정) → 색인(자동 분석) → 검색(같은 분석 후 대조). standard·nori 같은 기본 제공 애널라이저는 매핑에 이름만 쓰면 되고, 필터를 직접 조합한 커스텀 애널라이저는 settings.analysis에서 정의해 이름을 붙인 뒤 매핑에서 참조한다.
{
"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로 확인한다. 매핑 잡기 전에 반드시 찍어보는 습관이 좋다.
{
"analyzer": "standard",
"text": "The Quick Brown Foxes"
}
// → ["the", "quick", "brown", "foxes"] (소문자화 + 공백 분리)text vs keyword
| 타입 | 동작 | 용도 |
|---|---|---|
text | analyzer로 쪼개서 역색인 저장 | 전문 검색 |
keyword | 통째로 저장 | 정확 일치 · 정렬 · 집계 |
실무에서는 Multi-field 매핑으로 둘 다 사용하는 것이 일반적이다.
{
"name": {
"type": "text",
"fields": {
"keyword": { "type": "keyword" }
}
}
}데이터 티어 & ILM 수명주기
시계열 데이터(로그, 메트릭, 이벤트)는 시간이 지나면 가치가 떨어진다. Elasticsearch 7.9+의 데이터 티어와 ILM(Index Lifecycle Management)으로 이를 자동 관리한다.
실무에서 빠지면 안 되는 설정
메모리·JVM
샤드 사이징
- 샤드 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 breaker | use_real_memory: true | 실제 힙 사용량 기반 |
| 존 분산 | allocation.awareness | 노드를 AZ별로 분산 |
| 보안 | TLS + 기본 인증 + RBAC | 기본 라이선스에 포함 |
백업·복구
- 스냅샷:
_snapshotAPI로 S3·공유 파일시스템에 백업. 증분 방식으로 변경 세그먼트만 저장한다. - Searchable snapshots (7.12+): 스냅샷을 frozen 티어로 마운트해서 검색 가능한 상태로 유지한다.
- 샤드 재배치: 노드 추가/제거 시 자동 재분배된다.
인덱스 관리
- Index template: 새 인덱스에 매핑·세팅 자동 적용. 7.8+의 Component template으로 조합 가능.
- Alias: 애플리케이션은 항상 alias로 접근한다. 무중단 인덱스 전환이 가능하다.
- 매핑 주의: 필드 타입은 변경 불가(추가만 가능).
dynamic: strict또는 명시적 매핑을 권장한다.
ES6에서 ES7로 주요 변화
마무리
Elasticsearch 7은 Index → Shard → Segment로 내려가는 계층 구조 위에서 동작한다. 이 글을 관통하는 핵심 세 가지를 기억하자.
- "샤드는 분산의 단위이자 Lucene 인덱스 그 자체다" — 샤드 수 = Lucene 인스턴스 수. 너무 많으면 오버헤드, 너무 적으면 확장 한계.
- "Refresh와 flush는 다른 일이다" — refresh는 검색 가능, flush는 디스크 영구 저장. 이 구분이 near real-time과 내구성을 모두 설명한다.
- "Primary 샤드 수는 라우팅에 묶여 생성 후 변경 불가" — 운영 중 변경 가능한 건 replica 수뿐이다. 초기 설계가 중요한 이유.
주간 기술 뉴스레터
Backend · AI · Java 핵심 내용을 매주 이메일로 보내드립니다.