Home ElasticSearch의 내부 구조와 동작 원리
Post
Cancel

ElasticSearch의 내부 구조와 동작 원리

글을 작성하게 된 계기


최근 OpenSearch를 사용하게 됐는데, 이전에 학습했던 Elasticsearch의 Near real-time 검색 원리와 내부 구조와 동작 원리를 복습 및 정리하기 위해 글을 작성하게 되었습니다.





1. ElasticSearch의 내부 구조


엘라스틱서치의 내부는 다음과 같이 구성 돼 있습니다. 하나의 인덱스(Index)는 여러 개의 샤드(Shard)로 구성되어 있으며, 각 샤드는 데이터를 저장하고 검색을 담당하는 단위입니다. 샤드 내부에는 Apache 루씬 기반의 인덱스가 존재하고, 이 루씬 인덱스는 다시 여러 개의 세그먼트(Segment)로 나뉘어 데이터를 저장하게 됩니다.

image





각 세그먼트는 불변(Immutable)한 구조로 되어 있어 새로운 데이터가 추가될 경우 기존 세그먼트를 수정하지 않고, 새로운 세그먼트를 생성해 저장하게 됩니다. 이후 일정 주기 마다 병합(Merge) 작업을 통해 세그먼트가 정리되며, 이러한 구조 덕분에 효율적인 데이터 관리와 빠른 검색 성능을 제공할 수 있습니다. 세그먼트는 새로운 문서가 추가되거나 삭제될 때마다 새롭게 생성되며, 일정 시간이 지나면 병합을 통해 하나로 합쳐지기도 합니다. 이렇게 세그먼트 단위로 나누어 저장하는 구조 덕분에 검색 시 빠른 응답이 가능하고, 인덱싱 작업도 병렬로 효율적으로 처리할 수 있습니다.

인덱스 생성 시, ES 7.0 이전 버전은 기본적으로 5개의 샤드가 할당되었으나, 7.0 이후에는 1개로 변경되었습니다.







2. 동작 원리


여기서 조금 자세히 볼 부분은 샤드와 세그먼트 인데요, 엘라스틱서치에서는 새로운 문서가 추가될 때마다 SegmentA, SegmentB, SegmentC 와 같이 독립적인 세그먼트들이 생성되며, 각 세그먼트는 변경되지 않는 불변 구조로 유지됩니다.

1
                         SegmentA     SegmentB     SegmentC




이 상태에서 커밋 포인트(Commit Point)는 어떤 세그먼트들이 현재 검색 가능한지 에 대한 스냅샷 역할을 하며, 해당 시점의 세그먼트 목록을 기록하게 됩니다. 즉, 커밋 포인트는 이 시점에서 검색 가능한 세그먼트 목록을 정의하는 메타데이터 인데, 이는 값을 단순 저장하는 것이 아닌, 검색 가능성과 내구성을 동시에 보장하는 핵심 구조입니다.

A segment is similar to an inverted index, but the word index in Lucene means “a collection of segments plus a commit point”. After a commit, a new segment is added to the commit point and the buffer is cleared.




그러나 세그먼트가 계속해서 증가하면 디스크 사용량이 늘어나고, 검색 성능에도 영향을 줄 수 있기 때문에, 루씬은 문서 수, 세그먼트 크기 등에 따라 일정 기준에 따라 이를 병합 합니다. 이 작업은 여러 세그먼트를 하나로 통합하여 Segment_Merged와 같은 새로운 세그먼트를 생성하고, 기존의 세그먼트들은 삭제되거나 폐기 처리하는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
                      # 세그먼트가 여러 개 존재하는 상태
                      SegmentA       SegmentB       SegmentC
                         |              |              |
                         |              |              |
                         +--------------+--------------+
                                        |
                                        v
                        ---------- Commit Point ----------
                        | SegmentA | SegmentB | SegmentC |
                        ----------------------------------
                                        |
                                        v
                                  Segment Merged
                                        |
                                        v
                        ---------- Commit Point ----------
                        |         Segment_Merged         |
                        ----------------------------------





2-1. 장점

2-1-1. 동시성 문제 회피

세그먼트를 불변 구조로 만들면, 기존 데이터를 수정할 필요가 없기 때문에 여러 스레드가 동시에 동일한 세그먼트를 읽어도 충돌이나 동기화 문제가 발생하지 않습니다. 이로 인해 복잡한 락(lock) 처리를 하지 않아도 되고, 병렬로 문서를 검색하거나 인덱스를 관리할 수 있어 성능이 크게 향상됩니다. 멀티스레드 환경에서도 안정성과 일관성을 보장할 수 있다는 점에서 매우 큰 장점입니다.



2-1-2. 시스템 캐시 활용

세그먼트가 변하지 않기 때문에, 운영체제의 커널은 해당 세그먼트를 디스크에서 읽은 후 페이지 캐시(Page Cache)에 오래 유지시킬 수 있습니다. 만약 세그먼트가 자주 변경된다면 캐시가 자주 무효화되었겠지만, 불변 구조 덕분에 캐시 적중률이 매우 높게 유지되며 디스크 접근 횟수를 줄일 수 있습니다. 이는 곧 전체 검색 성능의 향상으로 이어집니다.

In computing, a page cache, sometimes also called disk cache, is a transparent cache for the pages originating from a secondary storage device such as a hard disk drive (HDD) or a solid-state drive (SSD).



2-1-3. 높은 캐시 적중률

불변 세그먼트는 일단 한 번 생성되면 폐기되기 전까지 내용이 변하지 않기 때문에, 해당 세그먼트를 기반으로 생성된 각종 캐시(예: 필드 데이터 캐시, 검색 결과 캐시 등)도 오랫동안 유효하게 유지될 수 있습니다. 이로 인해 반복되는 쿼리에 대한 응답 속도가 매우 빨라지고, 자주 조회되는 데이터에 대한 캐시 재사용률이 극대화됩니다.



2-1-4. 리소스 절감

변경이 없는 세그먼트는 한 번 읽어들인 이후 추가적인 디스크 I/O 없이도 처리할 수 있으므로, 불필요한 디스크 접근을 줄이고 시스템 리소스를 절약할 수 있습니다. 또한, 메모리에 올려놓은 세그먼트를 지속적으로 재활용할 수 있기 때문에 메모리 효율성도 높아지며, 전체 시스템의 부하를 줄이는 데 기여합니다.





2-2. 단점

2-2-1. 디스크 공간 낭비

세그먼트가 불변이기 때문에, 문서가 삭제되거나 갱신(update)될 경우 실제로 세그먼트의 데이터를 수정할 수 없습니다. 대신 삭제된 문서는 delete marker(삭제 플래그)만 설정되며, 업데이트는 기존 문서를 삭제한 후 새로운 문서를 추가하는 방식으로 처리됩니다. 이로 인해 실제 디스크에는 삭제되었거나 더 이상 사용되지 않는 데이터가 남게 되고, 병합(Merge) 작업 전까지는 디스크 공간을 낭비하게 됩니다.



2-2-2. 세그먼트 수 증가

새로운 문서가 추가될 때마다 세그먼트가 생성되기 때문에, 단기간에 대량의 인덱싱 작업이 이루어지면 세그먼트 수가 급격하게 증가할 수 있습니다. 세그먼트 수가 많아질수록 검색 시 더 많은 파일을 동시에 조회해야 하므로 I/O가 증가하고, 검색 속도에 악영향을 줄 수 있습니다. 따라서 일정 주기로 병합이 필수적입니다.



2-2-3. 병합 비용 발생

세그먼트를 줄이기 위해 수행되는 병합(Merge) 작업은 CPU, 메모리, 디스크 I/O를 많이 사용하는 작업입니다. 특히 대규모 세그먼트를 병합할 경우 Elasticsearch의 퍼포먼스에 일시적인 영향을 줄 수 있습니다. 타이밍을 잘못 설정하거나, 리소스가 부족한 상태에서 병합이 발생하면 검색 지연이나 노드 불안정 현상이 발생할 수 있습니다.



2-2-4. 실시간성 한계

세그먼트는 메모리에서 디스크로 플러시될 때 검색에 포함되므로, 문서가 인덱싱되었다고 해서 바로 검색 가능한 것은 아닙니다. refresh 주기가 지나야 새로운 문서가 반영되는데, 이 주기적 반영 구조는 완전한 실시간 검색(real-time search)에는 제약이 됩니다.

The term “near real-time” or “nearly real-time”, in telecommunications and computing, refers to the time delay introduced, by automated data processing or network transmission, between the occurrence of an event and the use of the processed data, such as for display or feedback and control purposes.



2-2-5. 복잡한 Merge 정책 관리

세그먼트를 언제, 어떻게 병합할지 결정하는 Merge Policy는 성능과 디스크 활용률에 큰 영향을 줍니다. 단순한 설정으로 운영할 경우 비효율적인 병합이 발생하거나, 반대로 병합이 너무 자주 일어나 리소스가 낭비될 수 있습니다. 따라서 운영자는 상황에 맞는 병합 전략을 고민하고 튜닝해야 하는 부담이 있습니다.







3. 세그먼트 생성과 삭제 처리 과정


루씬은 세그먼트를 불변 구조로 유지하기 위해 문서를 삭제하더라도 실제 데이터를 지우지 않습니다. 대신 해당 문서가 삭제되었음을 표시하는 비트 배열(BitSet) 또는 삭제 비트맵(deleted docs bitmap)을 사용합니다.

When a document is deleted or updated (= delete + add), Apache Lucene simply marks a bit in a per-segment bitset to record that the document is deleted. All subsequent searches simply skip any deleted documents.



루씬의 검색 엔진은 이 비트 배열을 기준으로 삭제된 문서를 자동으로 필터링합니다. 사용자는 삭제된 문서를 직접 신경 쓸 필요 없이, 루씬이 내부적으로 이를 걸러내고 유효한 문서만 검색 결과에 포함시킵니다. 하지만 삭제된 문서도 세그먼트 내에 물리적으로는 남아 있기 때문에, 디스크 공간은 여전히 사용되며, 세그먼트 병합(Merge) 시에야 비로소 실제로 삭제된 문서들이 디스크에서 제거됩니다. 이 과정을 통해 디스크 공간이 회수되고 인덱스가 정리됩니다.

1
2
3
4
5
6
7
8
9
10
11
# 세그먼트 내부 구조 예시
SegmentA
 ├── Doc_0     ✅
 ├── Doc_1     ❌ (deleted)
 ├── Doc_2     ✅
 ├── Doc_3     ❌ (deleted)
 └── Doc_4     ✅

# 삭제 비트맵 (deleted docs bitmap)
BitSet:  0 1 0 1 0
Index:   0 1 2 3 4




Elasticsearch에서 문서가 인덱싱되면, 루씬은 이를 먼저 RAM 버퍼에 저장해 디스크 I/O를 줄이고 성능을 높입니다. 이 버퍼는 일정 크기나 시간이 지나면 자동으로 Flush되어 디스크에 새로운 세그먼트 파일로 저장됩니다. 이때 생성된 세그먼트는 아직 검색에 사용되지 않으며, 검색 대상이 되기 위해서는 Commit을 통해 세그먼트가 공식적으로 등록되어야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 문서 인덱싱 → RAM Buffer → Flush → Segment 생성 (디스크)
                                                                                
User Request                                                                     
     │                                                                          
     ▼                                                                          
 ┌────────────┐                                                                 
 │ Doc_1      │                                                                 
 │ Doc_2      │                                                                 
 │ Doc_3      │                                                                 
 └────────────┘                                                                 
       │                                                                        
       ▼                                                                        
 ┌────────────────────┐                                                         
 │   RAM Buffer (메모리) │  ← 임시 저장                                           
 └────────────────────┘                                                         
       │                                                                        
       │  (용량 초과 or 시간 경과 시 Flush 발생)                                
       ▼                                                                        
 ┌────────────────────────────┐                                                 
 │ SegmentA (디스크 저장 구조) │ ← 불변, 검색 대상 아님 (아직 Commit 전)       
 └────────────────────────────┘                                                 




Commit은 새로 생성된 세그먼트를 공식적으로 루씬의 “커밋 포인트(commit point)”에 등록하는 작업입니다. 이 커밋 포인트는 루씬이 검색 시 사용할 세그먼트 목록을 관리하는 메타데이터로, 커밋이 이루어져야만 해당 세그먼트가 검색 대상에 포함됩니다. Elasticsearch는 이를 위해 주기적으로 refresh 작업을 수행하여 최신 데이터를 빠르게 검색에 반영할 수 있도록 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[ SegmentA (디스크 저장, 아직 검색 불가) ]
              │
              ▼
      ┌────────────────────┐
      │     Commit 수행     │
      └────────────────────┘
              │
              ▼
┌───────────────────────────────┐
│ Commit Point에 세그먼트 등록 │ ← 검색 가능 상태로 전환
└───────────────────────────────┘
              │
              ▼
    ┌────────────────────────┐
    │ Elasticsearch Refresh  │ ← 주기적으로 자동 실행
    └────────────────────────┘
              │
              ▼
    ┌────────────────────────┐
    │   검색 결과에 반영됨    │
    └────────────────────────┘




시간이 지나면서 새로운 문서들이 지속적으로 인덱싱되면 세그먼트의 수가 늘어나고, 이는 검색 성능 저하로 이어질 수 있습니다. 또한 삭제나 갱신된 문서들이 쌓이면서 세그먼트 내부에 불필요한 데이터가 증가하게 됩니다. 이를 해결하기 위해 루씬은 여러 세그먼트를 하나로 통합하는 Merge 작업을 수행합니다. 이 과정에서 삭제된 문서는 실제로 디스크에서 제거되며, 새로운 세그먼트가 생성되고, 이전 세그먼트는 삭제됩니다. Merge를 통해 루씬은 디스크 공간을 정리하고 검색 성능을 유지합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[ SegmentA ]   [ SegmentB ]   [ SegmentC ]  
     │               │               │
     └──────┬────────┴─────┬─────────┘
            ▼              ▼
      ┌────────────────────────┐
      │     Merge 작업 수행      │ ← 삭제/갱신 반영
      └────────────────────────┘
                  │
                  ▼
        ┌──────────────────────┐
        │   Segment_Merged     │ ← 최적화된 새로운 세그먼트
        └──────────────────────┘
                  │
                  ▼
      ┌──────────────────────────┐
      │   낡은 세그먼트 삭제 및 정리   │ ← 디스크 공간 회수, 성능 향상
      └──────────────────────────┘







4. 루씬의 fsync 처리와 인덱스 무결성


루씬에서 인덱스를 생성하거나 변경할 때, 새로운 세그먼트 파일은 먼저 write() 시스템 콜을 통해 디스크에 기록됩니다. 하지만 이 과정은 실제로 데이터를 물리적인 디스크에 기록하는 것이 아니라, 커널이 관리하는 페이지 캐시(page cache)에 데이터를 적재하는 단계에 불과합니다. 리눅스와 같은 운영체제에서는 write() 호출 시, 유저 스페이스에서 커널 스페이스로 데이터가 복사되고, 이후 별도의 백그라운드 플러시 프로세스에 의해 디스크에 쓰기가 수행됩니다.

1
2
3
+------------+   write   +------------------+  fsync/flush   +---------------+
| 루씬 (JVM)  |   ─────▶  | Kernel Page Cache|  ───────────▶  | Physical Disk |
+------------+           +------------------+                +---------------+




문제는, 이 시점에서 시스템에 전원 장애나 커널 패닉 등 예기치 못한 문제가 발생하면, 페이지 캐시에 있던 데이터가 디스크에 반영되기 전에 유실될 수 있다는 점입니다. 이는 곧 루씬 인덱스 손상으로 이어지며, 검색 시 오류나 전체 인덱스 무효화로까지 번질 수 있습니다. 루씬은 이를 방지하기 위해 모든 세그먼트 파일에 대해 fsync() 시스템 콜을 호출합니다. fsync()는 특정 파일 디스크립터에 대해 커널 페이지 캐시에 있던 데이터를 실제 디스크의 플래터(혹은 SSD 블록)에 물리적으로 동기화(sync) 하는 작업을 수행합니다.

fsync() causes all modified data and attributes of fildes to be moved to a permanent storage device. This normally results in all in-core modified copies of buffers for the associated file to be written to a disk.




이는 해당 파일이 디스크에 완전히 쓰였으며 전원 장애가 발생하더라도 손실되지 않는다는 것을 의미합니다. 운영체제는 이 과정을 보장하기 위해 디스크 드라이버에 명시적인 flush 명령을 전송합니다. 루씬의 커밋( commit) 과정에서는 단순히 세그먼트 파일만 동기화하는 것이 아니라, 현재 인덱스가 참조하고 있는 세그먼트 목록을 담은 메타 파일인 segments_N 파일 역시 반드시 fsync()를 수행합니다. 이 파일은 루씬 인덱스의 진입점 역할을 하며, 이 파일에 어떤 세그먼트가 포함되어 있는지가 루씬의 전체 인덱스 구조를 결정합니다.

1
2
3
4
5
6
7
8
9
+---------------+   write()   +-------------------+   fsync()   +------------------+
| 루씬 Segment| ----------> | Kernel Page Cache | ---------> |  Physical Disk   |
+---------------+             +-------------------+             +------------------+
      ▲                                                              ▲
      |                                                              |
      |                        fsync()                               |
      |                +--------------------+                        |
      +----------------|  segments_N file   |<-----------------------+
                       +--------------------+




따라서 segments_N가 유실되면, 해당 인덱스는 복구가 불가능하거나 손실된 상태로 로드됩니다. 이 때문에 루씬은 segments_N에 대한 fsync()를 가장 마지막 단계에서 수행하여, 앞서 저장한 세그먼트 파일들이 디스크에 온전히 존재함이 확정된 이후에만 이를 참조하게 만듭니다. 이를 통해 인덱스의 원자성을 보장합니다.

  • 세그먼트 파일 → 먼저 fsync()
  • segments_N → 마지막에 fsync()
  • segments_N는 인덱스 구조의 진입점
  • 유실 시 인덱스 복구 불가




또한, 루씬은 커밋 이후 인덱스의 변경 여부를 감지하기 위해 과거에는 DirectoryReader.openIfChanged() 메서드를 사용했지만, 이 방식은 실제 내부적으로 리더를 캐싱하고 있었기 때문에, 새로운 세그먼트가 생겼더라도 리더가 교체되지 않으면 탐지하지 못하는 문제가 있었습니다. 또한 자원 회수 시점이 명확하지 않아, IndexInput, IndexOutput 객체가 해제되지 않고 남는 경우도 종종 발생했습니다.

루씬 9 이후부터는 이러한 문제를 해결하고자 해당 메서드를 deprecated 처리하였고, 사용자에게 명시적으로 DirectoryReader.open()을 호출하여 새로운 인덱스 상태를 로드하도록 가이드하고 있습니다.




운영체제 관점에서도 fsync()는 비용이 큰 시스템 콜인데요, I/O 스케줄러는 해당 블록이 저장된 디바이스까지 실제로 쓰기 작업이 완료되기를 기다려야 하며, SSD에서는 FTL(Flash Translation Layer)을 통한 블록 맵핑 및 GC 비용이 동반되기 때문입니다. 따라서 fsync( )는 비교적 느리며, 루씬은 이러한 비용을 감수하고서라도 인덱스 무결성을 우선시합니다.

Usually, flash memory controllers also include the “flash translation layer” (FTL), a layer below the file system that maps host side or file system logical block addresses (LBAs) to the physical address of the flash memory








This post is licensed under CC BY 4.0 by the author.