들어가며

  • Last Update: 2025/02/16

  • Elasticsearch와 Lucene의 파일 포맷 관리 방식을 살펴보며, 검색엔진에서 파일 포맷을 어떻게 다루는지 알아봅니다.
  • 이 글은 현재 제가 학습 및 경량화 목적으로 진행중인 문서 기반 NoSQL 데이터베이스, Feather의 파일 포맷 관리 방식 역시 다룹니다.

Lucene의 파일 포맷 관리

문서 기반 NoSQL 데이터베이스, 특히 Elasticsearch는 문서를 저장하고 검색하는 것이 핵심입니다. 저는 요즘 Elasticsearch와 유사하나 더욱 가벼운 문서 기반 NoSQL 데이터베이스를 만드는 프로젝트, Feather를 진행중인데요. Elasticsearch는 검색 엔진 라이브러리 Lucene의 주요 파일 확장자를 이용해 기능을 확장하고 있습니다. 따라서 이번 글에서는 Elasticsearch에서 이용되는 Lucene의 파일 포맷 관리 방식을 살펴보고자 합니다.

세그먼트 파일

Lucene에서 세그먼트(Segment)는 색인된 문서들의 집합을 의미합니다. 세그먼트라는 이름은 데이터를 작은 단위로 분할(segmentation)한다는 의미에서 유래했는데요. 각 세그먼트는 독립적인 검색 가능한 단위로, 자체적인 색인 구조를 가지고 있습니다. 세그먼트 파일이 가지는 특징은 다음과 같습니다.

세그먼트 파일의 특징

불변성

  • 한번 생성된 세그먼트 파일은 수정되지 않습니다.
  • 문서의 업데이트나 삭제가 필요한 경우, 새로운 세그먼트를 생성하고 기존 세그먼트를 제거하는 방식으로 동작합니다.

증분적 업데이트

  • 새로운 문서가 추가될 때마다 새로운 세그먼트가 생성됩니다.
  • 불변성과 함께, 기존 세그먼트에 대한 동시성 제어를 용이하게 합니다.
  • 이는 곧 NRT(Near Real-Time) 검색을 가능하게 합니다.

병합

  • 세그먼트 파일의 수가 많아지면 검색 성능이 저하될 수 있기에 병합이 필요합니다.
  • 세그먼트 수 임계값 설정을 초과하거나 크기가 불균형할 때 병합이 이루어집니다.
  • 병합은 리소스를 많이 사용하는 작업이므로, 백그라운드에서 점진적으로 수행됩니다.

파일 구조

  • 각 세그먼트는 각각 용어 사전, 문서 저장, 필드 정보 등의 다른 정보를 보유한 여러 파일로 구성됩니다.
  • 즉, 하나의 논리적 세그먼트는 여러 물리적 파일로 구성됩니다.

파일 확장자

파일 종류 확장자 설명
세그먼트 파일 segments_N 커밋 포인트에 대한 정보 저장
잠금 파일 write.lock 여러 IndexWriter가 동일한 파일에 쓰는 것을 방지하는 잠금 장치
세그먼트 정보 .si 세그먼트에 대한 메타데이터 저장
복합 파일 .cfs, .cfe 파일 핸들 부족 문제가 있는 시스템을 위한 선택적 “가상” 파일로, 다른 모든 인덱스 파일을 포함
필드 .fnm 필드에 대한 정보 저장
필드 인덱스 .fdx 필드 데이터에 대한 포인터 포함
필드 데이터 .fdt 문서의 저장된 필드들
용어 사전 .tim 용어 사전으로, 용어 정보 저장
용어 인덱스 .tip 용어 사전에 대한 인덱스
빈도수 .doc 각 용어를 포함하는 문서 목록과 빈도수 저장
위치 정보 .pos 용어가 인덱스에서 어디에 위치하는지에 대한 정보 저장
페이로드 .pay 문자 오프셋과 사용자 페이로드와 같은 추가적인 위치별 메타데이터 정보 저장
정규화 .nvd, .nvm 문서와 필드에 대한 길이 및 부스트 요소 인코딩
문서별 값 .dvd, .dvm 추가 점수 요소 또는 기타 문서별 정보 인코딩
용어 벡터 인덱스 .tvx 문서 데이터 파일의 오프셋 저장
용어 벡터 데이터 .tvd 용어 벡터 데이터 포함
활성 문서 .liv 현재 활성 상태인 문서들에 대한 정보
포인트 값 .dii, .dim 인덱싱된 포인트 저장
벡터 값 .vec, .vem, .veq, .vex 인덱싱된 벡터 저장(.vec: 원시 벡터 데이터, .vem: 벡터 메타데이터, .veq: 양자화된 벡터 데이터, .vex: HNSW 그래프 데이터)

중심적인 파일 확장자

이번 프로젝트의 핵심은 Elasticsearch/Lucene을 직접 구현해보고, 최소한의 기능을 빠르게 제공하는 문서 기반 NoSQL 데이터베이스를 제작하는 것인데요. 따라서 위 파일 확장자에 대한 기능을 모두 구현할 수 없다고 판단했습니다. 그렇다면 어떤 확장자가 단순 검색 엔진 및 문서 기반 NoSQL에 필요한 기능일까요?

segments_N, writes.lock, .si와 같이 세그먼트 파일 관리에 필요한 파일 확장자를 제외하고, 일단 기능적으로는 다음 확장자들이 필요합니다. 특히 Elasticsearch가 인덱스에 대해 고정적인 필드와 역색인 구조를 이용하는 것을 고려했을 때, 다음 확장자들이 필요합니다.

  1. 필드 정보 파일 (.fnm)

    • JSON과 같은 문서 기반 NoSQL의 스키마 정보를 관리
    • 각 필드의 타입 정보 저장 (text, keyword, numeric 등)
    • 필드별 검색/저장 설정 관리 (검색 가능 여부, 저장 여부 등)
    • 예시: title: text, price: numeric, category: keyword
  2. 문서 저장 파일 (.fdt, .fdx)

    • .fdt: 실제 문서 내용을 저장하는 데이터 파일
    • .fdx: 문서 위치를 빠르게 찾기 위한 인덱스 파일
    • 문서 ID로 빠르게 원본 데이터 접근 가능
    • 예시: 문서 ID 42번의 전체 내용 조회 시 사용
  3. 용어 사전 파일 (.tim, .tip)

    • .tim: 모든 검색 가능한 단어들의 사전
      • 각 용어별로 출현한 문서 목록 참조 정보 포함
      • B-트리 구조로 구성되어 빠른 검색 지원
    • .tip: 용어 사전의 인덱스
      • 용어 검색 성능 향상을 위한 인덱스
      • 메모리 효율성을 위해 일부만 메모리에 로드
  4. 용어-문서 매핑 파일 (.doc)

    • 각 용어가 어떤 문서에 등장하는지 기록
    • 용어별 문서 내 출현 빈도 저장
    • 검색 시 관련 문서 목록 빠르게 조회
    • 예시: “elasticsearch”라는 단어가 포함된 모든 문서 ID 목록

파일 포맷 응용하기

그래서 Lucene의 파일 포맷을 바탕으로, 기본적인 파일 시스템을 구현해보기로 했습니다. 그러나 Lucene에 존재하는 모든 기능을 구현할 자원과 능력 모두 현재 없기에 최소한의 기능만을 구현하기 위한 설계가 필요했습니다.

02/16 현재 세그먼트 파일에 대한 기본적인 쓰기 및 읽기 기능이 완료된 상태입니다. Feather Pull Request에서 확인할 수 있습니다. 현재 삭제 기능과 병합 기능은 미구현 상태입니다.

Feather 프로젝트 파일 구조 요약

파일 종류 확장자 설명
문서 저장소 .doc 문서의 고유 ID, 길이, 내용 등을 저장
용어 사전 .dic 검색 가능한 모든 용어와 해당 용어의 문서 빈도, 포스팅 위치 정보를 저장
포스팅 리스트 .post 각 용어에 대한 문서 목록과 출현 빈도, 위치 정보를 저장
세그먼트 메타데이터 .meta 세그먼트의 생성 시간, 문서 수, 삭제된 문서 수, 문서 ID 범위, 데이터 무결성을 위한 체크섬 등을 저장

저는 위와 같은 4개의 기본 파일 포맷을 중심으로 Elasticsearch/Lucene의 검색 기능을 구현해보기로 했습니다. 검색 엔진의 핵심 개념인 역인덱싱, 세그먼트 기반 저장 및 병합 기능을 구현해보기 위해서입니다.

특히, 지나치게 복잡하지 않은 파일 시스템을 구현하며 오히려 블록 단위 인덱싱, 델타 인코딩, 접두어 기반 검색에 도전해보기 위한 목적도 있습니다.

상세 설명

공통 파일 헤더 (FileHeader)

  • 모든 세그먼트 파일의 시작 부분에 위치하며, 파일의 무결성과 유형을 확인하는 데 사용됩니다.
  • 고정된 크기(25바이트)로 설계되어 효율적인 읽기/쓰기가 가능합니다.
  • 파일 타입을 구분하여 해당 파일의 구조를 해석할 수 있도록 합니다.

문서 저장소 (.doc)

  • 파일 헤더: 공통 헤더를 포함하여 문서 저장소의 기본 정보를 제공합니다.
  • 문서 레코드: 각 문서의 고유 ID, 길이, 내용을 저장하며, 문서의 빠른 접근을 지원합니다.

용어 사전 (.dic)

  • 파일 헤더: 공통 헤더를 포함하여 용어 사전의 기본 정보를 제공합니다.
  • 용어 레코드: 각 용어의 길이, 내용, 문서 빈도수, 포스팅 위치를 저장하여 효율적인 검색을 지원합니다.
  • 용어 인덱스: 빠른 검색을 위해 용어를 블록 단위로 인덱싱하여 검색 성능을 향상시킵니다.

포스팅 리스트 (.post)

  • 파일 헤더: 공통 헤더를 포함하여 포스팅 리스트의 기본 정보를 제공합니다.
  • 포스팅 레코드: 각 용어에 대한 문서 목록과 출현 빈도, 위치 정보를 저장하여 빠른 검색 결과 도출을 지원합니다.

세그먼트 메타데이터 (.meta)

  • 파일 헤더: 공통 헤더를 포함하여 메타데이터 파일의 기본 정보를 제공합니다.
  • 메타데이터: 세그먼트의 생성 시간, 문서 수, 삭제된 문서 수, 문서 ID 범위, 체크섬 등을 저장하여 세그먼트의 상태와 무결성을 관리합니다.
  • 삭제 목록: 비트맵 형태로 삭제된 문서의 ID를 관리하여 효율적인 삭제 처리를 지원합니다. (현재는 미구현 상태입니다.)

Feather 프로젝트의 기본적인 파일 시스템

1. 파일 구조

segment_1/
├── \_1.doc # 문서 저장소
│ └── [문서ID][문서길이][문서내용]...
├── \_1.dic # 용어 사전
│ └── [용어][문서빈도수][포스팅위치]...
├── \_1.post # 포스팅 리스트
│ └── [문서ID][출현빈도][위치정보]...
└── \_1.meta # 세그먼트 메타데이터
└── [메타데이터][삭제 목록]...


2. 문서 저장소 (_1.doc) 세부 구조


[파일 헤더]
├── 매직 넘버 (4바이트) # 파일 형식 식별자
├── 버전 (4바이트) # 파일 포맷 버전
├── 파일 타입 (1바이트) # 파일의 종류를 나타내는 코드
├── 레코드 수 (4바이트) # 파일 내 레코드의 총 개수
├── 타임스탬프 (8바이트) # 파일 생성 시각 (밀리초 단위)
└── 헤더 크기 (4바이트) # 헤더의 총 크기


[문서 레코드]
├── 문서1
│ ├── 문서ID (4바이트)
│ ├── 문서길이 (4바이트)
│ └── 문서내용 (가변길이, field 구조)
├── 문서2
│ ├── 문서ID (4바이트)
│ ├── 문서길이 (4바이트)
│ └── 문서내용 (가변길이, field 구조)
└── ...

예시


문서1
├── 문서ID: `0001`
├── 문서길이: `0020`
└── 문서내용: `{ "title": "Feather 프로젝트", "content": "..." }`

문서2
├── 문서ID: `0002`
├── 문서길이: `0030`
└── 문서내용: `{ "title": "Lucene 이해하기", "content": "..." }`
...


3. 용어 사전 (.dic) 세부 구조

[파일 헤더]
├── 매직 넘버 (4바이트)     # 파일 형식 식별자
├── 버전 (4바이트)         # 파일 포맷 버전
├── 파일 타입 (1바이트)     # 파일 종류 코드 (DIC)
├── 레코드 수 (4바이트)     # 파일 내 레코드의 총 개수
├── 타임스탬프 (8바이트)    # 파일 생성 시각 (밀리초 단위)
└── 헤더 크기 (4바이트)     # 헤더의 총 크기

[용어 레코드 영역]          # 시작 위치: HEADER_SIZE
├── 용어1
│   ├── 필드명 길이 (2바이트)
│   ├── 필드명 (가변길이, UTF-8)
│   ├── 용어 길이 (2바이트)
│   ├── 용어 텍스트 (가변길이, UTF-8)
│   ├── 문서빈도수 (4바이트)
│   └── 포스팅위치 (8바이트)
├── 용어2
│   ├── 필드명 길이 (2바이트)
│   ├── 필드명 (가변길이, UTF-8)
│   ├── 용어 길이 (2바이트)
│   ├── 용어 텍스트 (가변길이, UTF-8)
│   ├── 문서빈도수 (4바이트)
│   └── 포스팅위치 (8바이트)
└── ...

[용어 인덱스 영역]         # 접두사 기반 BLOCK 구조
├── 블록 수 (4바이트)      # INDEX_BLOCK_SIZE(128)개 단위로 구분
├── 블록 오프셋 배열       # 각 블록의 시작 위치
│   ├── 블록1 오프셋 (8바이트)
│   ├── 블록2 오프셋 (8바이트)
│   └── ...
└── 블록 데이터
    ├── 블록1
    │   ├── 필드명 길이 (2바이트)
    │   ├── 필드명 (가변길이, UTF-8)
    │   ├── 접두어 길이 (2바이트)
    │   ├── 접두어 (가변길이, UTF-8, 최대 8자)
    │   └── 레코드위치 (8바이트)
    ├── 블록2
    │   ├── 필드명 길이 (2바이트)
    │   ├── 필드명 (가변길이, UTF-8)
    │   ├── 접두어 길이 (2바이트)
    │   ├── 접두어 (가변길이, UTF-8, 최대 8자)
    │   └── 레코드위치 (8바이트)
    └── ...

예시

[용어 레코드]
용어1
├── 필드명 길이: `0005`
├── 필드명: "title"
├── 용어 길이: `0007`
├── 용어 텍스트: "feather"
├── 문서빈도수: `0003`
└── 포스팅위치: `00000120`

[용어 인덱스]
블록1
├── 필드명 길이: `0005`
├── 필드명: "title"
├── 접두어 길이: `0007`
├── 접두어: "feather"
└── 레코드위치: `00000040`

4. 포스팅 리스트 (.post) 세부 구조

[파일 헤더]
├── 매직 넘버 (4바이트)     # 파일 형식 식별자
├── 버전 (4바이트)         # 파일 포맷 버전
├── 파일 타입 (1바이트)     # 파일 종류 코드 (POST)
├── 레코드 수 (4바이트)     # 파일 내 레코드의 총 개수
├── 타임스탬프 (8바이트)    # 파일 생성 시각 (밀리초 단위)
└── 헤더 크기 (4바이트)     # 헤더의 총 크기

[포스팅 레코드]            # 시작 위치: HEADER_SIZE
├── 포스팅 수 (4바이트)     # 저장된 총 포스팅의 수
└── 포스팅 목록            # documentId 기준 정렬됨
    ├── 포스팅1
    │   ├── 문서ID (4바이트)    # 델타 인코딩 적용 (이전 문서ID와의 차이)
    │   ├── 출현빈도 (4바이트)   # 해당 문서 내 용어 출현 횟수
    │   └── 위치정보
    │       ├── 위치 수 (4바이트)
    │       └── 위치목록 (4바이트 × N)  # 델타 인코딩 적용
    ├── 포스팅2
    │   ├── 문서ID (4바이트)    # 델타 인코딩 적용
    │   ├── 출현빈도 (4바이트)
    │   └── 위치정보
    │       ├── 위치 수 (4바이트)
    │       └── 위치목록 (4바이트 × N)
    └── ...

예시

[포스팅 레코드]
포스팅 수: `0003`

포스팅1
├── 문서ID: `0005` (델타: 5)
├── 출현빈도: `0002`
└── 위치정보
    ├── 위치 수: `0002`
    └── 위치목록: `0010, 0015` (델타: 10, 5)

포스팅2
├── 문서ID: `0008` (델타: 3)
├── 출현빈도: `0003`
└── 위치정보
    ├── 위치 수: `0003`
    └── 위치목록: `0005, 0012, 0020` (델타: 5, 7, 8)

5. 세그먼트 메타데이터 (_1.meta) 세부 구조


[파일 헤더]
├── 매직 넘버 (4바이트) # 파일 형식 식별자
├── 버전 (4바이트) # 파일 포맷 버전
├── 파일 타입 (1바이트) # 파일의 종류를 나타내는 코드
├── 레코드 수 (4바이트) # 파일 내 레코드의 총 개수
├── 타임스탬프 (8바이트) # 파일 생성 시각 (밀리초 단위)
└── 헤더 크기 (4바이트) # 헤더의 총 크기

[메타데이터]
├── 생성시간 (8바이트) # 세그먼트가 생성된 시간 (타임스탬프)
├── 문서 수 (4바이트) # 세그먼트 내의 총 문서 수
├── 삭제된 문서 수 (4바이트) # 세그먼트 내에서 삭제된 문서의 수
├── 최소 문서ID (4바이트) # 세그먼트 내의 최소 문서 ID
├── 최대 문서ID (4바이트) # 세그먼트 내의 최대 문서 ID
└── 체크섬 (8바이트) # 데이터 무결성을 검증

[삭제 목록] _(비트셋 형태)_
└── 삭제된 문서ID 비트맵: 삭제된 문서의 ID를 비트맵으로 관리

현재 삭제 관련 기능은 미구현 상태입니다.

예시


생성시간: `1617181920000`
문서 수: `0010`
삭제된 문서 수: `0002`
최소 문서ID: `0001`
최대 문서ID: `0010`
체크섬: `12345678ABCDEF00`
삭제 목록: `0000001100` (문서 ID 3, 4 삭제)

나가며

NoSQL 검색 엔진의 파일 시스템을 설계하면서 구현하면서 배운 점도 많지만, 특히 이 세 가지가 기억에 남습니다. Java 기반의 Spring 프로젝트를 진행할 때와는 꽤 다르고도 재밌는 교훈을 얻었습니다.

바이너리 파일 포맷 설계

배경

처음에는 프로젝트 목적에 맞게 단순히 String 기반 포맷을 이용하려고 했으나, 매번 파싱과 함께 메모리 할당을 하는 것이 너무 비효율적이라고 생각해 바이너리 파일로 변경하게 되었습니다.

게다가 바이너리 파일 포맷이 가지는 저장 공간에서의 효율성, 정확한 위치 계산, 데이터 타입의 정확성 등의 장점을 이용해보고자 했습니다.

배운 점

바이너리 파일 포맷을 직접 설계하고 구현하며 데이터 구조화의 중요성을 배웠습니다. 특히 고정 길이와 가변 길이 데이터를 함께 다루면서 파일을 읽을 때 오류가 발생하고는 했습니다.

게다가 바이너리 데이터는 눈으로 직접 확인하기도 어렵고, 어디에서 어떤 위치에서 오류가 발생하는지 정확한 위치를 파악하기도 어려웠습니다. 특히 ByteBuffer 내에서 position을 관리하는 코드가 잘못 작성된 경우가 대표적이었습니다.

개선

AI assistant와 함께 작성된 코드에 포괄적인 테스트 코드를 작성했지만, 사실 WIP(Work In Progress) 과정에서 최소한의 임시방편적인 테스트 코드라고 느껴집니다. 기초적인 기능을 완성한 뒤에, 스펙과 지식을 명확히 한 뒤 다시 테스트 코드를 작성하려고 합니다.

데이터 무결성과 신뢰성

배경

파일 시스템을 구현하면서, 악의적인 데이터 조작이나 데이터 손실의 발생 가능성을 고려해보고자 했습니다. 이를 방지하기 위해 데이터 무결성과 신뢰성을 확보하고자 했습니다.

Lucene의 세그먼트 파일은 매직 넘버를 사용하여 파일 형식을 식별하고, 코덱(Codec)을 통해 파일 포맷의 버전을 관리합니다. 이러한 실제 시스템의 구조와 동일하진 않더라도, 참고하여 파일 시스템의 안전성을 확보하는 방법을 학습했습니다.

배운 점

Lucene은 CodecUtil을 통해 파일의 매직 넘버를 사용하여 파일 형식을 식별합니다. 매직 넘버는 파일 정체성을 확인하는 데 사용됩니다. Feather에서는 매직 넘버는 동일하게 이용해 시스템의 일부임을 검증하고, 이후 FileType.enum을 통해 구체적인 파일 확장자를 검증하는 방식을 채택했습니다.

체크섬은 파일 내용의 무결성을 보장하는 핵심 메커니즘입니다. 네트워크 전송이나 디스크 오류로 인한 데이터 손상을 감지할 수 있으며, 불완전한 쓰기 작업도 확인할 수 있습니다.

개선

현재에는 체크섬 학습 과정에서 작성한 .meta 확장자에서 이용하는 SegmentMetadata에서만 체크섬이 구현되어 있는데요. 이러한 정보를 헤더로 옮김으로써, 파일 형식을 식별하는 과정에서 체크섬을 이용할 수 있도록 개선하고자 합니다.

게다가 transaction log 개념을 이용해 커밋 과정에서의 로직을 학습하며 오류를 방지할 수 있도록 개선하고자 합니다.

Java 언어에 대한 이해

배경

Elasticsearch와 Lucene의 소스코드를 살펴보고, 직접 설계와 구현을 고려하며 Java로 작성되었음이 어떤 의미를 지니는지를 되돌아보게 되었습니다.

배운 점

파일 시스템에서 계층 구조를 고민하며 상속과 추상화에 대해서 더 이해하게 되었습니다. SegmentFile.java를 작성하며, 왜 Elasticsearch/Lucene은 비슷한 클래스인 Directory 클래스를 인터페이스가 아닌 추상클래스로 작성했는지 궁금했는데요.

이는 파일 시스템 추상화의 공통 상태와 동작을 공유하고, 일관성을 보장하기 위함임을 알게 되었습니다. 게다가 인터페이스로 구현한다면, 상태 관리 코드가 중복되거나 구현체가 동일한 동작을 다르게 구현할 위험이 있기도 했습니다.

또한, Elasticsearch 내의 생명 주기를 살펴보며 가시성과 volatile 키워드를 학습한 것이 추후 노드와 클러스터 개념을 구현하며 도움이 될 것이라고 믿습니다.

개선

막상 코드를 구현하고 보니, Lucene과 같이 추상 클래스를 이용한 상속이 아닌 컴포지션을 이용하는 방법을 고민하지 않았다는 점이 아쉬움으로 남습니다. 상속과 컴포지션의 장단을 고려해볼 예정입니다.

References