Elasticsearch 내부 구조 — Index, Shard, Segment

JH.KIM·2026년 2월 22일

ELK Deep Dive

목록 보기
2/5

[ELK Deep Dive] Elasticsearch — 내부 구조

1편에서 Elasticsearch의 정체와 역인덱스 원리를 살펴봤습니다. Index는 RDB의 테이블, Document는 Row라는 비유도 했습니다. 이번 글에서는 그 안쪽으로 한 단계 더 들어갑니다.

Elasticsearch의 Index는 내부적으로 Shard와 Segment라는 계층 구조로 이루어져 있습니다. 이 구조를 이해하면 Primary Shard 수가 왜 변경 불가능한지, 삭제가 왜 즉시 디스크를 확보하지 못하는지, 동시성 제어는 어떤 방식으로 이루어지는지가 자연스럽게 설명됩니다.


PART 1. Index와 Shard — 분산의 뼈대
1. Index는 RDB의 테이블이 아니다
2. Shard — 분산의 단위

PART 2. Segment — 불변의 검색 단위
3. Segment — 불변의 검색 단위
4. 삭제와 수정의 실체

PART 3. 동시성과 일관성
5. 동시성 제어


1. Index는 RDB의 테이블이 아니다

1편에서 "Index는 RDB의 테이블에 대응한다"고 설명했습니다. 개념을 처음 잡을 때는 유용한 비유이지만, 실제 운영에서의 사용 방식은 상당히 다릅니다. 그 차이를 살펴봅니다.

1.1. Index의 실체: Shard의 묶음

Index는 논리적인 이름일 뿐, 실체는 Shard의 묶음입니다. Document를 Index에 저장하면, Elasticsearch는 내부적으로 어떤 Shard에 넣을지를 결정하고, 실제 데이터는 Shard 안에 저장됩니다.

┌─────────────────────────────────────────────────────┐
│ Index: order-logs-2025-02-21                        │
├─────────────────────────────────────────────────────┤
│                                                     │
│  ┌──────────────┐  ┌──────────────┐  ┌───────────┐  │
│  │ Primary      │  │ Primary      │  │ Primary   │  │
│  │ Shard 0      │  │ Shard 1      │  │ Shard 2   │  │
│  │              │  │              │  │           │  │
│  │ (Doc A,D,F)  │  │ (Doc B,E)    │  │ (Doc C,G) │  │
│  └──────────────┘  └──────────────┘  └───────────┘  │
│                                                     │
└─────────────────────────────────────────────────────┘

Index를 만든다는 것은, "이 이름으로 N개의 Shard를 할당해달라"는 뜻에 가깝습니다.

1.2. 날짜별 Index 패턴

RDB에서 주문 로그를 관리한다면 보통 하나의 테이블을 사용합니다. 데이터가 커지면 날짜 기준으로 파티셔닝을 적용합니다.

-- RDB 방식: 하나의 테이블 + 파티셔닝
CREATE TABLE order_logs (
    id         BIGINT PRIMARY KEY,
    order_id   VARCHAR(50),
    message    TEXT,
    created_at TIMESTAMP
) PARTITION BY RANGE (created_at);

CREATE TABLE order_logs_20250221
    PARTITION OF order_logs
    FOR VALUES FROM ('2025-02-21') TO ('2025-02-22');

Elasticsearch에서는 접근이 다릅니다. 날짜마다 별도의 Index를 만드는 것이 일반적입니다.

order-logs-2025-02-19   (3 Primary Shards)
order-logs-2025-02-20   (3 Primary Shards)
order-logs-2025-02-21   (3 Primary Shards)

이 패턴이 낯설게 느껴질 수 있습니다. "테이블을 매일 새로 만드는 것 아닌가?" 맞습니다. 그리고 그것이 합리적인 이유가 있습니다.

1.3. Index는 파티션 1개에 더 가깝다

RDB에서 "테이블"은 전체 데이터를 담는 하나의 논리 단위입니다. Elasticsearch에서 하나의 Index(예: order-logs-2025-02-21)는 전체 주문 로그가 아니라 하루치 로그만 담습니다. 즉 RDB의 테이블보다는 파티션 1개에 가깝습니다.

그러면 "전체 주문 로그"는 어떻게 다루느냐? 와일드카드와 Alias가 이 역할을 합니다.

GET /order-logs-2025-02-*/_search     ← 2월 전체를 한번에 검색
GET /order-logs-*/_search             ← 전체 기간 검색

Index Alias를 사용하면 여러 Index를 하나의 이름으로 묶을 수도 있습니다.

// Alias 설정
POST /_aliases
{
  "actions": [
    { "add": { "index": "order-logs-2025-02-*", "alias": "order-logs" } }
  ]
}

// 이후 Alias로 검색 — 마치 하나의 테이블처럼
GET /order-logs/_search
개념RDBElasticsearch
전체 데이터테이블 1개여러 Index (날짜별)
데이터 분리 단위파티션Index 1개
통합 접근테이블 이름으로Alias 또는 와일드카드로
분리 기준날짜, 지역 등주로 날짜

1.4. 날짜별 Index가 유리한 핵심 이유: Index 단위 삭제

그런데 왜 굳이 날짜마다 Index를 나눌까요? 가장 큰 이유는 삭제 효율입니다.

로그 데이터는 일정 기간이 지나면 삭제해야 합니다. RDB에서 30일 지난 로그를 지운다면 이런 쿼리를 실행합니다.

-- RDB: 행 단위 DELETE
DELETE FROM order_logs WHERE created_at < '2025-01-22';

이 쿼리는 조건에 맞는 Row를 하나하나 찾아서 삭제합니다. 수억 건이면 트랜잭션 로그가 쌓이고, 인덱스 재구성이 필요하고, 실행 시간이 수십 분에서 수 시간까지 걸릴 수 있습니다.

Elasticsearch에서 날짜별 Index를 쓰면?

DELETE /order-logs-2025-01-21

Index 전체를 삭제하는 것이므로, Shard에 할당된 파일들을 통째로 제거합니다. 수십억 건이든 즉시 완료됩니다. 메타데이터만 정리하면 되기 때문입니다.

삭제 방식동작소요 시간부하
RDB: DELETE WHERERow 단위 스캔 + 삭제분~시간높음 (I/O, Lock)
RDB: DROP PARTITION파티션 파일 제거즉시낮음
ES: Document 단위 삭제.del 표시만 (물리 삭제 아님)빠름Merge 시 실제 삭제
ES: Index 삭제Shard 파일 통째 제거즉시낮음

RDB의 DROP PARTITION과 ES의 Index 삭제가 같은 원리입니다. 그리고 ES에서 날짜별 Index 패턴은 이 DROP PARTITION을 자연스럽게 가능하게 만드는 설계입니다.

핵심: Elasticsearch에서 Index는 테이블이 아니라 파티션에 가깝습니다. 날짜별 Index 패턴은 삭제 효율을 위한 실전 설계입니다.


2. Shard — 분산의 단위

Index가 논리적 단위라면, Shard는 물리적 단위입니다. 데이터가 실제로 저장되고 검색이 실행되는 곳이 Shard입니다. Shard의 동작 원리를 이해하면, "왜 Primary Shard 수를 변경할 수 없는가"라는 질문에 답할 수 있게 됩니다.

2.1. Document 라우팅: 어떤 Shard에 넣을 것인가

Document가 Index에 저장될 때, Elasticsearch는 다음 공식으로 Shard를 결정합니다.

shard_number = hash(_id) % number_of_primary_shards

_id를 해싱한 결과를 Primary Shard 수로 나눈 나머지가 해당 Document의 Shard 번호가 됩니다.

┌─────────────────────────────────────────────────────┐
│ Document (_id: "order-001")                         │
│                                                     │
│ hash("order-001") = 2387645                         │
│ 2387645 % 3 = 1                                     │
│                                                     │
│ → Primary Shard 1에 저장                             │
└─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│ Document (_id: "order-002")                         │
│                                                     │
│ hash("order-002") = 9812044                         │
│ 9812044 % 3 = 2                                     │
│                                                     │
│ → Primary Shard 2에 저장                             │
└─────────────────────────────────────────────────────┘

이 라우팅에서 두 가지를 알 수 있습니다.

첫째, 같은 _id는 항상 같은 Shard로 갑니다. 해시 함수의 결정론적(deterministic) 특성 때문입니다. 이 덕분에 특정 Document를 GET할 때 모든 Shard를 조회하지 않고 정확히 하나의 Shard만 찾아갈 수 있습니다.

둘째, UPDATE 시에도 _id가 변하지 않으므로 같은 Shard에 저장됩니다. Elasticsearch의 UPDATE는 내부적으로 "기존 Document 삭제 표시 + 새 Document 추가"인데, 새 Document도 같은 _id를 사용하므로 같은 Shard에 들어갑니다.

2.2. Primary Shard 수는 변경할 수 없다

이 라우팅 공식에서 number_of_primary_shards는 나누는 수(모듈러 연산의 분모)입니다. 이 값이 바뀌면 모든 Document의 라우팅 결과가 달라집니다.

# Primary Shard가 3개일 때
hash("order-001") % 3 = 1   → Shard 1

# Primary Shard를 5개로 바꾸면?
hash("order-001") % 5 = 0   → Shard 0 (다른 곳!)

Document는 물리적으로 Shard 1에 있는데, 라우팅 공식은 Shard 0을 가리킵니다. 조회하면 찾을 수 없습니다. 이런 불일치가 전체 Document에 걸쳐 발생합니다.

그래서 Elasticsearch는 Index 생성 후 Primary Shard 수 변경을 허용하지 않습니다. 잘못 설정했다면, 새로운 Index를 만들고 전체 데이터를 다시 색인하는 Reindex 작업이 필요합니다.

// ✅ Index 생성 시점에 Primary Shard 수를 결정
PUT /order-logs-2025-02-21
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1
  }
}

// ❌ 이미 생성된 Index의 Shard 수는 변경 불가
// Reindex로 새 Index에 데이터를 복사해야 함
POST /_reindex
{
  "source": { "index": "order-logs-2025-02-21" },
  "dest":   { "index": "order-logs-2025-02-21-new" }
}

여기서도 날짜별 Index 패턴의 장점이 드러납니다. 오늘 Index의 Shard 설정이 부적절했다면, 내일 Index는 다른 Shard 수로 생성하면 됩니다. 이미 존재하는 Index를 건드릴 필요가 없습니다.

order-logs-2025-02-21   → 3 Primary Shards (트래픽 적은 날)
order-logs-2025-02-22   → 5 Primary Shards (프로모션 예정)

2.3. Replica Shard의 역할

Shard에는 Primary Shard와 Replica Shard가 있습니다. Replica는 Primary의 복사본으로, 두 가지 역할을 합니다.

┌────────────────────────────────────────────────────────────┐
│ Index: order-logs-2025-02-21  (shards: 2, replicas: 1)     │
├────────────────────────────────────────────────────────────┤
│                                                            │
│   Node A              Node B              Node C           │
│  ┌──────────┐       ┌──────────┐       ┌──────────┐        │
│  │Primary 0 │       │Primary 1 │       │Replica 0 │        │
│  │          │       │          │       │(= P0복사) │        │
│  └──────────┘       └──────────┘       └──────────┘        │
│  ┌──────────┐                                              │
│  │Replica 1 │                                              │
│  │(= P1복사) │                                              │
│  └──────────┘                                              │
│                                                            │
└────────────────────────────────────────────────────────────┘

첫째, 고가용성입니다. Node A가 죽으면 Primary Shard 0의 데이터를 잃게 되지만, Node C에 Replica Shard 0이 있으므로 이 Replica가 Primary로 승격되어 서비스가 유지됩니다.

둘째, 검색 성능 향상입니다. 검색 요청은 Primary와 Replica 중 아무 곳에서나 처리할 수 있습니다. Replica가 많을수록 검색 요청을 분산할 수 있으므로 읽기 처리량이 늘어납니다.

구분Primary ShardReplica Shard
쓰기O (Document 저장)X (Primary에서 복제 받음)
읽기(검색)OO
수 변경불가 (Index 생성 시 고정)가능 (운영 중 동적 변경)
데이터원본Primary의 복사본

Replica 수는 운영 중에 변경할 수 있습니다. 검색 트래픽이 늘면 Replica를 추가하고, 줄면 제거하면 됩니다. Primary Shard 수와 달리 유연합니다.

핵심: Shard는 해시 기반 라우팅으로 Document를 분산합니다. Primary Shard 수가 한번 정해지면 바꿀 수 없으므로 Index 설계 시 신중해야 합니다.


3. Segment — 불변의 검색 단위

Index 안에 Shard가 있고, Shard 안에는 Segment가 있습니다. Segment는 Elasticsearch(정확히는 Lucene)에서 데이터가 실제로 저장되는 가장 작은 단위이며, 한번 만들어지면 변경되지 않는 불변(immutable) 객체입니다. 이 불변성이 Elasticsearch의 검색 성능과 동시성 처리의 핵심 기반입니다.

3.1. Document가 Segment가 되기까지

새 Document가 Elasticsearch에 도착하면 곧바로 디스크에 기록되지 않습니다. 다음 과정을 거칩니다.

┌─────────────────────────────────────────────────────────────────┐
│ Document 저장 과정                                                │
│                                                                 │
│  1. 클라이언트가 Document를 전송                                     │
│     │                                                           │
│     ▼                                                           │
│  2. In-memory Buffer에 쌓임                                       │
│     │  (아직 검색 불가)                                             │
│     │                                                           │
│     ▼  ← refresh 발생 (기본 1초마다)                                │
│  3. Segment 생성 → 디스크(또는 OS 캐시)에 기록                         │
│     │  (이제부터 검색 가능)                                          │
│     │  (Segment는 봉인 — 이후 수정 불가)                             │
│     │                                                           │
│     ▼                                                           │
│  4. 이후 새 Document → 새로운 In-memory Buffer                     │
│     → 다음 refresh 시 또 다른 Segment 생성                          │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
  1. 새 Document가 도착하면 In-memory Buffer에 쌓입니다. 이 시점에서는 검색이 되지 않습니다.
  2. Refresh가 발생하면(기본 1초 주기), 버퍼의 내용이 Segment로 변환됩니다. Segment가 생성되는 순간부터 검색이 가능해집니다.
  3. 생성된 Segment는 봉인(seal)됩니다. 이후에는 어떤 수정도, 추가도 불가능합니다.
  4. 다음에 들어오는 Document는 새로운 버퍼에 쌓이고, 다음 refresh에서 또 다른 Segment가 됩니다.

시간이 흐르면 하나의 Shard 안에 여러 개의 Segment가 쌓이게 됩니다.

┌──────────────────────────────────────────┐
│ Shard 0                                  │
├──────────────────────────────────────────┤
│                                          │
│  ┌──────────┐  ← 10:00:00에 생성           │
│  │ Segment 0│  (Doc 1, 2, 3)             │
│  └──────────┘                            │
│  ┌──────────┐  ← 10:00:01에 생성           │
│  │ Segment 1│  (Doc 4, 5)                │
│  └──────────┘                            │
│  ┌──────────┐  ← 10:00:02에 생성           │
│  │ Segment 2│  (Doc 6)                   │
│  └──────────┘                            │
│  ┌──────────┐  ← 10:00:03에 생성           │
│  │ Segment 3│  (Doc 7, 8, 9, 10)         │
│  └──────────┘                            │
│                                          │
└──────────────────────────────────────────┘

3.2. Segment 안의 구성 요소

Segment 하나에는 검색에 필요한 모든 데이터 구조가 들어 있습니다. 독립적인 작은 검색 엔진이라고 보면 됩니다.

┌──────────────────────────────────────────────────────┐
│ Segment                                              │
├──────────────────────────────────────────────────────┤
│                                                      │
│  ┌────────────────────────────────────────────┐      │
│  │ Inverted Index (역인덱스)                    │      │
│  │ "주문" → [Doc1, Doc3, Doc7]                 │      │
│  │ "결제" → [Doc2, Doc7]                       │      │
│  │ "실패" → [Doc3]                             │      │
│  │ → 전문 검색(Full-text Search)에 사용           │      │
│  └────────────────────────────────────────────┘      │
│                                                      │
│  ┌────────────────────────────────────────────┐      │
│  │ Stored Fields (원본 JSON)                    │     │
│  │ Doc1: {"orderId":"ORD-001", "msg":"주문 .."}│      │
│  │ Doc3: {"orderId":"ORD-003", "msg":"주문 .."}│      │
│  │ → _source 반환 시 사용                        │     │
│  └────────────────────────────────────────────┘      │
│                                                      │
│  ┌────────────────────────────────────────────┐      │
│  │ Doc Values (컬럼 스토어)                      │      │
│  │ amount: [15000, 32000, 8000, ...]           │     │
│  │ status: ["PAID", "FAILED", "PAID", ...]     │     │
│  │ → 정렬, 집계(aggregation)에 사용                │     │
│  └────────────────────────────────────────────┘      │
│                                                      │
│  ┌────────────────────────────────────────────┐      │
│  │ .del (삭제 마커)                              │      │
│  │ [0, 0, 1, 0, ...]  (1 = 삭제됨)               │     │
│  │ → 검색 결과에서 삭제된 Document 제외              │     │
│  └────────────────────────────────────────────┘      │
│                                                      │
└──────────────────────────────────────────────────────┘
구성 요소저장 내용용도
Inverted Index토큰 → Document ID 목록전문 검색 (Match, Term Query)
Stored Fields원본 JSON (_source)검색 결과 반환
Doc Values필드별 값 (컬럼 지향)정렬, 집계 (sort, aggs)
.del삭제 표시 비트셋삭제된 Document 필터링

3.3. 각 Segment가 독립적인 Lucene 인덱스

여기서 중요한 점이 있습니다. 역인덱스와 원본 Document는 Shard별로 하나가 아니라 Segment별로 각각 존재합니다.

Shard 0
├─ Segment 0: 자체 역인덱스 + 자체 Stored Fields + 자체 Doc Values
├─ Segment 1: 자체 역인덱스 + 자체 Stored Fields + 자체 Doc Values
└─ Segment 2: 자체 역인덱스 + 자체 Stored Fields + 자체 Doc Values

각 Segment는 Lucene 관점에서 독립적인 인덱스입니다. Segment 하나만으로도 완전한 검색이 가능합니다. Shard 내 검색은 "모든 Segment에서 각각 검색한 결과를 합치는 것"입니다.

왜 이렇게 설계했을까요? Segment의 불변성 때문입니다. Shard 전체에 하나의 역인덱스를 두면, 새 Document가 추가될 때마다 그 역인덱스를 수정해야 합니다. 수정은 락을 필요로 하고, 락은 동시 검색의 병목이 됩니다. Segment를 독립적으로 만들면, 기존 Segment를 건드리지 않고 새 Segment만 추가하면 됩니다.

3.4. Segment 불변성이 가져오는 이점

Segment는 한번 생성되면 절대 변경되지 않습니다. 이 불변성이 가져오는 이점은 구체적입니다.

Lock이 불필요합니다. 변경될 가능성이 없는 데이터를 읽는 데 락이 왜 필요하겠습니까? 여러 검색 요청이 동시에 같은 Segment를 읽어도 안전합니다. RDB에서는 읽기 중에도 다른 트랜잭션의 쓰기로 인해 일관성 문제가 생길 수 있지만, 불변 Segment에서는 그런 문제 자체가 존재하지 않습니다.

캐싱이 효율적입니다. Segment 내용이 바뀌지 않으므로, OS 페이지 캐시에 올라간 Segment 데이터는 무효화(invalidation)될 일이 없습니다. 캐시 히트율이 높아집니다.

동시성 처리가 단순해집니다. 쓰기는 새 Segment를 만들 뿐이고, 읽기는 기존 Segment를 읽을 뿐입니다. 읽기와 쓰기가 서로 간섭하지 않습니다.

관점가변(Mutable) 구조불변(Immutable) Segment
동시 읽기Lock 필요Lock 불필요
캐시 효율쓰기 시 무효화 발생무효화 없음
읽기-쓰기 간섭충돌 가능간섭 없음
구현 복잡도높음 (Lock 관리)낮음
트레이드오프-Segment 수 증가, Merge 필요

물론 공짜는 아닙니다. Segment가 계속 생성되면서 수가 늘어나는 문제가 있고, 이를 해결하기 위한 Merge 비용이 발생합니다. 이 부분은 다음 섹션에서 다룹니다.

핵심: Segment는 불변의 독립적인 검색 단위입니다. 불변성 덕분에 Lock 없이 동시 검색이 가능하지만, Segment 수 관리라는 새로운 과제가 생깁니다.


4. 삭제와 수정의 실체

Segment가 불변이라면, Document를 삭제하거나 수정하면 어떻게 될까요? "지울 수 없는 구조에서 삭제를 어떻게 처리하는가"가 이 섹션의 핵심 질문입니다.

4.1. DELETE — 실제로 지우지 않는 삭제

Segment는 불변이므로 이미 기록된 데이터를 물리적으로 제거할 수 없습니다. 대신 .del 파일에 "이 Document는 삭제되었다"고 표시만 합니다.

┌───────────────────────────────────────────┐
│ DELETE /order-logs/_doc/order-001         │
└───────────┬───────────────────────────────┘
                                            │
            ▼
┌───────────────────────────────────────────┐
│ Segment 0                                 │
│                                           │
│  Inverted Index:                          │
│    "주문" → [Doc1, Doc3]   ← 그대로 유지      │
│                                           │
│  Stored Fields:                           │
│    Doc1: {"orderId":"ORD-001"...} ← 남아있음 │
│                                           │
│  .del:                                    │
│    Doc1 = DELETED  ← 표시만 추가             │
│                                           │
└───────────────────────────────────────────┘

검색 시 Elasticsearch는 각 Segment에서 결과를 가져온 뒤, .del 파일을 확인하여 삭제 표시된 Document를 결과에서 제외합니다. 데이터는 여전히 물리적으로 존재하지만, 검색 결과에는 나오지 않습니다.

이것이 "30일 지난 로그를 Document 단위로 DELETE 했는데 디스크 사용량이 안 줄어요"라는 질문의 답입니다. 물리 삭제는 나중에 Segment Merge가 일어날 때 비로소 수행됩니다.

4.2. UPDATE = Delete + Insert

Elasticsearch의 UPDATE는 실제로는 두 단계의 조합입니다.

┌──────────────────────────────────────────────────────────┐
│ POST /order-logs/_update/order-001                       │
│ { "doc": { "status": "SHIPPED" } }                       │
└─────────────┬────────────────────────────────────────────┘
                                                           │
              ▼
┌──────────────────────────────────────────────────────────┐
│                                                          │
│  Step 1: 기존 Document를 .del 처리                          │
│                                                          │
│  Segment 0 (기존)                                         │
│  ┌────────────────────────────────┐                      │
│  │ Doc1: {status: "PAID"}         │                      │
│  │ .del: Doc1 = DELETED  ← 표 시   │                      │
│  └────────────────────────────────┘                      │
│                                                          │
│  Step 2: 수정된 Document를 새 Segment에 추가                 │
│                                                          │
│  Segment 3 (새로 생성)                                     │
│  ┌────────────────────────────────┐                      │
│  │ Doc1: {status: "SHIPPED"} ← 새 │                      │
│  └────────────────────────────────┘                      │
│                                                          │
└──────────────────────────────────────────────────────────┘

Segment 0의 기존 Doc1은 .del 표시되고, 수정된 Doc1은 Segment 3이라는 새 Segment에 추가됩니다. 같은 Segment에 수정된 데이터가 추가되는 것이 아닙니다. Segment는 추가조차 안 되는 완전한 불변이기 때문입니다.

이 방식은 RDB의 PostgreSQL MVCC와 유사합니다. PostgreSQL도 UPDATE 시 기존 Row를 직접 수정하지 않고, 새 Row를 추가한 뒤 기존 Row를 "죽은 튜플"로 표시합니다. Elasticsearch는 이 접근을 Segment 수준에서 더 극단적으로 적용한 것입니다.

4.3. Segment Merge

시간이 지나면 Segment가 계속 늘어납니다. Refresh 때마다 새 Segment가 생기고, UPDATE/DELETE가 많으면 .del 표시된 Document도 쌓입니다. 이 문제를 해결하는 것이 Segment Merge입니다.

┌──────────────────────────────────────────────────────────┐
│ Segment Merge 과정                                        │
│                                                          │
│  Before Merge:                                           │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐                  │
│  │Segment 0 │ │Segment 1 │ │Segment 2 │                  │
│  │Doc1(del) │ │Doc3      │ │Doc5      │                  │
│  │Doc2      │ │Doc4(del) │ │Doc6      │                  │
│  └──────────┘ └──────────┘ └──────────┘                  │
│       │             │             │                      │
│       └─────────────┼─────────────┘                      │
│                     │                                    │
│                     ▼                                    │
│  After Merge:                                            │
│  ┌────────────────────────────────┐                      │
│  │ New Segment                    │                      │
│  │ Doc2, Doc3, Doc5, Doc6         │                      │
│  │ (Doc1, Doc4는 물리 삭제됨)        │                      │
│                                                          │
└──────────────────────────────────────────────────────────┘

Merge 과정에서 일어나는 일은 다음과 같습니다.

  1. 작은 Segment 여러 개를 선택합니다.
  2. 살아있는 Document만 모아서 새로운 큰 Segment를 만듭니다.
  3. .del 표시된 Document는 새 Segment에 포함되지 않으므로, 이 시점에서 진짜 물리 삭제가 일어납니다.
  4. 기존 작은 Segment들은 제거됩니다.

Merge는 백그라운드에서 자동으로 실행됩니다. 이상적인 상태는 크고 적은 수의 Segment입니다.

4.4. Segment가 많으면 왜 느린가

Shard 내 검색은 모든 Segment를 각각 검색한 뒤 결과를 합치는 방식입니다. Segment가 많을수록 다음과 같은 문제가 발생합니다.

검색: "주문 실패"

Segment가 3개일 때:
  Segment 0 역인덱스 조회 → 결과 A
  Segment 1 역인덱스 조회 → 결과 B
  Segment 2 역인덱스 조회 → 결과 C
  → 3번 조회 후 합산

Segment가 300개일 때:
  Segment 0 역인덱스 조회 → 결과 A
  Segment 1 역인덱스 조회 → 결과 B
  ...
  Segment 299 역인덱스 조회 → 결과 ...
  → 300번 조회 후 합산
문제설명
역인덱스 조회 횟수Segment 수만큼 각각 조회해야 함
파일 핸들 증가Segment마다 여러 파일을 열어야 함 (OS 파일 디스크립터 소모)
메모리 사용량각 Segment의 Term Dictionary(FST)가 메모리에 상주
캐시 비효율OS 페이지 캐시가 많은 Segment에 분산되어 히트율 하락

이 문제에 대한 깊이 있는 분석은 5편에서 다룹니다. 여기서는 Segment 수가 성능에 직접적인 영향을 미친다는 점만 기억하면 됩니다.

4.5. 성능 유지를 위한 전략

Segment 수를 적정하게 유지하기 위한 두 가지 실전 전략이 있습니다.

refresh_interval 조절

Refresh가 발생할 때마다 새 Segment가 만들어집니다. 기본값은 1초입니다. 실시간 검색이 필요 없는 데이터라면 이 주기를 늘려서 Segment 생성 빈도를 줄일 수 있습니다.

// 로그성 데이터: 30초마다 refresh
PUT /order-logs-2025-02-21/_settings
{
  "index.refresh_interval": "30s"
}

// 대량 색인 중에는 아예 끄기
PUT /order-logs-2025-02-21/_settings
{
  "index.refresh_interval": "-1"
}
// 색인 완료 후 다시 켜기
PUT /order-logs-2025-02-21/_settings
{
  "index.refresh_interval": "1s"
}
설정refresh_intervalSegment 생성 빈도검색 가능 시점
실시간 검색 필요1s (기본값)매초~1초 후
로그/분석용30s30초마다~30초 후
대량 색인 중-1 (비활성)수동 refresh 시만수동으로 결정

Force Merge

쓰기가 끝난 Index에 대해 수동으로 Merge를 실행하여 Segment를 하나로 합칩니다. 날짜별 Index 패턴에서 "어제 로그"는 더 이상 새 Document가 들어오지 않으므로 Force Merge의 좋은 대상입니다.

// 어제의 Index를 Segment 1개로 합치기
POST /order-logs-2025-02-20/_forcemerge?max_num_segments=1

Force Merge는 CPU와 I/O를 많이 사용하므로, 피크 타임을 피해서 실행해야 합니다. 또한 쓰기가 계속 발생하는 Index에는 적용하지 않는 것이 좋습니다. 곧바로 새 Segment가 다시 생기기 때문입니다.

질문: 이 Index에 아직 쓰기가 발생하는가?
├─ Yes → refresh_interval 조절로 Segment 생성 빈도만 관리
│         Force Merge 적용하지 않음
└─ No  → Force Merge로 Segment를 1개로 합치기 ⭐
          (날짜가 지난 로그 Index가 대표적 대상)

핵심: DELETE는 표시만, UPDATE는 Delete+Insert, 물리 삭제는 Merge 때 발생합니다. Segment 수를 적게 유지하는 것이 검색 성능의 핵심입니다.


5. 동시성 제어

RDB를 사용해 온 개발자에게 Elasticsearch의 동시성 제어는 낯설 수 있습니다. 트랜잭션도 없고, 커밋 즉시 보이지도 않습니다. 이것은 결함이 아니라, 다른 문제를 풀기 위한 다른 설계입니다.

5.1. Elasticsearch에는 RDB 수준의 트랜잭션이 없다

RDB에서는 여러 연산을 하나의 트랜잭션으로 묶어 ACID를 보장합니다.

-- RDB: 트랜잭션 안에서 원자적 수행
BEGIN;
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 'P001';
INSERT INTO orders (product_id, quantity) VALUES ('P001', 1);
COMMIT;

재고 감소와 주문 생성이 하나의 단위로 실행되거나, 둘 다 취소됩니다. Elasticsearch에는 이런 메커니즘이 없습니다. Document 하나의 쓰기는 원자적이지만, 여러 Document에 걸친 트랜잭션은 지원하지 않습니다.

이것이 결함이냐고 묻는다면, 문제를 바라보는 프레임 자체가 다르다고 답해야 합니다.

관점RDBElasticsearch
설계 목표데이터 정합성 (ACID)검색 속도와 확장성
트랜잭션멀티 Row/Table 트랜잭션단일 Document 수준만
정합성 보장강한 일관성 (Strong Consistency)최종 일관성 (Eventual Consistency)
동시성 전략비관적 락(Pessimistic Lock)이 기본낙관적 동시성 제어(OCC)만 제공

Elasticsearch는 ACID를 포기하는 대신, 분산 환경에서의 수평 확장과 빠른 검색을 얻었습니다. 이것은 트레이드오프이며, 어떤 데이터를 다루느냐에 따라 적합성이 달라집니다.

5.2. Lost Update 방지: Optimistic Concurrency Control

트랜잭션은 없지만, 같은 Document에 대한 동시 수정으로 인한 Lost Update 문제는 방지할 수 있습니다. Elasticsearch는 Optimistic Concurrency Control(낙관적 동시성 제어)을 제공합니다.

모든 Document에는 _seq_no_primary_term이라는 두 값이 붙어 있습니다.

// Document 조회
GET /products/_doc/P001

// 응답
{
  "_index": "products",
  "_id": "P001",
  "_seq_no": 5,          // ← 이 Shard에서의 연산 순서 번호
  "_primary_term": 1,     // ← Primary Shard의 세대 번호
  "_source": {
    "name": "무선 키보드",
    "stock": 100
  }
}

UPDATE 시 이 두 값을 함께 전달하면, "내가 읽은 시점의 버전"과 "현재 버전"이 같을 때만 수정이 성공합니다.

┌──────────────────────────────────────────────────────────────┐
│ 두 요청이 동시에 같은 Document를 수정하는 시나리오                     │
│                                                              │
│  Client A: 재고를 99로 수정                                     │
│  Client B: 재고를 98로 수정                                     │
│                                                              │
│  1. Client A와 B가 동시에 Document를 읽음                        │
│     → 둘 다 _seq_no=5, _primary_term=1을 확인                   │
│                                                              │
│  2. Client A가 먼저 UPDATE 요청                                 │
│     POST /products/_update/P001?if_seq_no=5&if_primary_term=1│
│     → 성공: _seq_no가 5→6으로 변경                               │
│                                                              │
│  3. Client B가 뒤늦게 UPDATE 요청                                │
│     POST /products/_update/P001?if_seq_no=5&if_primary_term=1│
│     → 실패: 현재 _seq_no는 이미 6, 요청한 5와 불일치                 │
│     → 409 Conflict 응답                                       │
│                                                              │
│  4. Client B는 Document를 다시 읽고 재시도                        │
│                                                              │
└──────────────────────────────────────────────────────────────┘

코드로 보면 다음과 같습니다.

// ✅ Optimistic Concurrency Control 적용
fun updateStock(productId: String, newStock: Int) {
    // 1. 현재 Document 조회
    val response = client.get(GetRequest("products", productId))
    val seqNo = response.seqNo
    val primaryTerm = response.primaryTerm

    // 2. 조건부 UPDATE
    val updateRequest = UpdateRequest("products", productId)
        .doc(mapOf("stock" to newStock))
        .setIfSeqNo(seqNo)             // 읽은 시점의 _seq_no
        .setIfPrimaryTerm(primaryTerm)  // 읽은 시점의 _primary_term

    try {
        client.update(updateRequest)
    } catch (e: VersionConflictEngineException) {
        // 3. 충돌 시 다시 읽고 재시도
        updateStock(productId, newStock)  // 재시도 로직 (실무에서는 최대 횟수 제한 필요)
    }
}
// ❌ Optimistic Concurrency Control 없이 (Lost Update 위험)
fun updateStock(productId: String, newStock: Int) {
    val updateRequest = UpdateRequest("products", productId)
        .doc(mapOf("stock" to newStock))
    client.update(updateRequest)
    // 동시 수정 시 마지막 쓰기가 이전 쓰기를 덮어씀 (Lost Update)
}

RDB에서는 SELECT FOR UPDATE로 행을 잠그는 비관적 락(Pessimistic Lock)이 일반적입니다. Elasticsearch는 정반대 접근입니다. 잠그지 않고 일단 진행한 뒤, 충돌이 발생하면 그때 처리합니다.

구분RDB (Pessimistic Lock)ES (Optimistic Concurrency)
전략먼저 잠그고 수정일단 수정, 충돌 시 재시도
구현SELECT FOR UPDATE + COMMITif_seq_no + if_primary_term
충돌 빈도가 높을 때유리 (대기 후 순서대로 처리)불리 (재시도 반복)
충돌 빈도가 낮을 때불리 (불필요한 락 비용)유리 (대부분 바로 성공)
분산 환경 확장성제한적 (락 관리 복잡)유리 (락 없이 독립 처리)

5.3. NRT(Near Real-Time)과 읽기 일관성

RDB에서는 보통 Read Committed 이상의 격리 수준을 사용합니다. 트랜잭션이 커밋되면 즉시 다른 트랜잭션에서 그 변경을 볼 수 있습니다. Elasticsearch는 다릅니다.

3장에서 살펴본 것처럼, Document는 In-memory Buffer에 먼저 들어가고, Refresh 후에 Segment가 되어야 검색에 노출됩니다. 기본 refresh_interval은 1초이므로, 쓰기와 검색 사이에 최대 1초의 갭이 존재합니다.

┌─────────────────────────────────────────────────────┐
│ 시간 흐름                                              │
│                                                     │
│ T=0.0s  Document 저장 요청 → 200 OK 응답               │
│         (In-memory Buffer에 저장됨)                   │
│                                                     │
│ T=0.3s  검색: "방금 저장한 Document" → 결과 없음          │
│         (아직 Segment가 안 됨)                         │
│                                                     │
│ T=1.0s  Refresh 발생 → Segment 생성                   │
│                                                     │
│ T=1.1s  검색: "방금 저장한 Document" → 결과 있음          │
│         (이제 검색 가능)                               │
│                                                     │
└─────────────────────────────────────────────────────┘

이것이 Near Real-Time(NRT)의 의미입니다. "거의" 실시간이지 "완전한" 실시간이 아닙니다.

긴급하게 저장 직후 검색이 필요한 경우, refresh=true 파라미터를 사용할 수 있습니다.

// 저장 시 즉시 refresh 강제
PUT /order-logs/_doc/order-999?refresh=true
{
  "orderId": "ORD-999",
  "message": "긴급 주문 생성"
}

그러나 이 옵션은 주의해서 사용해야 합니다. 매 요청마다 refresh=true를 사용하면, Document가 저장될 때마다 새 Segment가 만들어집니다. Segment가 급격히 늘어나 검색 성능이 전반적으로 떨어지는 결과를 초래합니다.

질문: 저장 후 즉시 검색이 가능해야 하는가?
├─ Yes → 정말 모든 요청에 필요한가?
│        ├─ Yes → Elasticsearch가 적합한 저장소인지 재검토
│        └─ No  → 해당 요청에만 ?refresh=true 사용 (제한적)
└─ No  → 기본 refresh_interval 사용 ⭐ (대부분의 경우)

5.4. 이것이 Elasticsearch가 메인 DB로 쓰이지 않는 이유

지금까지 살펴본 내용을 종합하면, Elasticsearch가 메인 데이터베이스로 적합하지 않은 이유가 명확해집니다.

┌────────────────────────────────────────────────────────────┐
│ 데이터 성격에 따른 저장소 선택                                    │
│                                                            │
│  "주문 데이터를 저장하려는데, 어디에 넣을까?"                        │
│                                                            │
│  질문 1: 이 데이터에 트랜잭션이 필요한가?                           │
│  ├─ Yes (재고 차감 + 주문 생성이 원자적이어야 함)                   │
│  │   → RDB ⭐                                              │
│  └─ No                                                     │
│      │                                                     │
│      질문 2: 저장 후 즉시 정확한 조회가 필요한가?                   │
│      ├─ Yes (결제 직후 주문 상태를 바로 확인)                      │
│      │   → RDB ⭐                                          │
│      └─ No (1초 정도 지연은 괜찮음)                             │
│          │                                                 │
│          질문 3: 전문 검색이나 로그 분석이 핵심인가?                │
│          ├─ Yes → Elasticsearch ⭐                         │
│          └─ No  → 요구사항 재검토                              │
│                                                            │
└────────────────────────────────────────────────────────────┘
데이터 유형특성적합한 저장소
주문, 결제, 재고정합성 필수, 트랜잭션 필요RDB (MySQL, PostgreSQL)
로그, 이벤트대량 쓰기, 지연 허용, 기간별 삭제Elasticsearch
상품 검색전문 검색, 관련도 스코어링Elasticsearch (+ RDB가 원본)
실시간 대시보드집계, 시계열 분석Elasticsearch

실무에서 가장 흔한 패턴은, RDB에 원본 데이터를 저장하고 Elasticsearch에는 검색용 복제본을 두는 것입니다. 주문 데이터는 RDB가 정합성을 보장하고, 주문 검색 기능은 Elasticsearch가 담당합니다. 각 저장소가 자신이 잘하는 일을 맡는 구조입니다.


마무리

이번 글에서 Elasticsearch의 세 계층을 열어봤습니다.

  • Index는 Shard의 논리적 묶음이며, RDB의 테이블보다 파티션에 가깝습니다.
  • Shard는 해시 기반 라우팅으로 Document를 분산하며, Primary Shard 수는 변경할 수 없습니다.
  • Segment는 불변의 독립적인 검색 단위이며, 이 불변성이 Lock-free 동시 검색을 가능하게 합니다.
  • 삭제와 수정은 표시와 재생성으로 처리되며, 물리 삭제는 Segment Merge에서 발생합니다.
  • 동시성 제어는 낙관적 방식이며, NRT 특성상 쓰기와 읽기 사이에 갭이 있습니다.

이 구조를 관통하는 원칙이 하나 있습니다. "불변성(Immutability)을 선택하고, 그 대가를 설계로 해결한다." Segment를 불변으로 만들어 Lock과 일관성 문제를 제거하고, Merge로 누적 비용을 관리하고, 날짜별 Index로 삭제 효율을 확보합니다.

RDB는 "데이터를 정확하게 지키는 것"에 최적화된 시스템이고, Elasticsearch는 "데이터를 빠르게 찾는 것"에 최적화된 시스템입니다. 어떤 시스템이 더 좋은가가 아니라, 지금 풀어야 할 문제가 무엇인가를 먼저 물어야 합니다.

3편에서는 한 단계 더 들어가서, Elasticsearch 아래에서 동작하는 Lucene의 텍스트 분석 과정을 살펴보겠습니다. "재고가 부족합니다"라고 검색했을 때 "재고 부족"이 포함된 문서가 왜 매칭되는가 — 그 답은 Analyzer에 있습니다.

profile
일하며 겪은 문제를 나눠요

0개의 댓글