Delta Lake 사용자의 Apache Iceberg 적응기
들어가기 전에
전 회사에서는 Databricks와 Delta Lake를 사용하다가, 현 회사에서 Apache Iceberg를 주로 사용 중입니다. 처음엔 익히 들었던 것처럼 “별 다른 점이 없는 스토리지 포맷 아닌가?" 하고 금방 적응할 수 있을 거라 생각했는데, 생각보다 주요한 점들이 달랐습니다.
평소에 학습할 때, 기존에 알고 있는 개념과 연관지어 이해하는 걸 좋아하는 편이라 개념을 정리할 겸 iceberg 와 Delta Lake 에 대해 아래 항목에 대해서 정리해봤습니다.
1. 메타데이터: 완전히 다른 철학
Delta Lake - 단순한 메타데이터 및 트랜잭션 로그
table/
├── part-00001.parquet
├── part-00002.parquet
└── _delta_log/
├── 00000000000000000000.json
├── 00000000000000000001.json
└── 00000000000000000010.checkpoint.parquet
// 00000000000000000001.json 내용
{
"add": {
"path": "part-00001.parquet",
"partitionValues": {"date": "2024-01-01"},
"size": 1024000,
"stats": "{\"numRecords\":1000}"
}
}
Delta Lake는 정말 심플합니다. _delta_log 폴더 하나에 모든 히스토리를 JSON으로 기록합니다.
각각의 JSON 은 버전을 의미하며, Version N = Version N-1 + Actions 공식으로 동작하죠.
각 버전별 메타데이터 정보에 대해서 빠짐없이 기록합니다. 또한 별도로 설정을 변경하지 않는 한 10개 커밋마다 Parquet checkpoint를 생성해서 성능을 최적화합니다.
Iceberg - 계층적 메타데이터
table/
├── data/
│ └── part-00001.parquet
└── metadata/
├── v1.metadata.json # 테이블 스키마, 스냅샷 목록
├── snap-1234-manifest-list.avro # 매니페스트 파일 목록
└── manifest-001.avro # 실제 데이터 파일 경로
Iceberg는 메타데이터를 계층적으로 관리합니다. 처음엔 복잡해 보였는데, 대용량 테이블에서는 이게 더 효율적이더라고요. 필요한 매니페스트만 선택적으로 읽을 수 있거든요.
그리고 Iceberg의 계층 구조에서 각 파일의 역할을 명확히 이해하는 것이 중요합니다.
1. Metadata File (metadata.json)
테이블의 전체 상태를 정의하는 최상위 파일
"The metadata file (JSON format) which lists table schema, partition spec, and references to snapshot information"
포함하는 정보:
테이블 스키마 (컬럼명, 타입)
파티션 스펙 (파티션 규칙)
스냅샷 목록
현재 스냅샷 포인터
테이블 속성
{
"format-version": 2,
"table-uuid": "9c12d441-03fe-4693-9a96-a0705ddf69c1",
"location": "s3://bucket/warehouse/table",
"current-snapshot-id": 3055729675574597004,
"schemas": [...],
"partition-spec": [...],
"snapshots": [
{
"snapshot-id": 3055729675574597004,
"manifest-list": "s3://bucket/.../snap-3055...avro"
}
]
}
2. Manifest List (manifest-list.avro)
특정 스냅샷의 매니페스트 파일 목록
"A manifest list is essentially an index of all manifests that comprise that snapshot"
— 출처: Apache Iceberg in Modern Data Architectures (2024)
포함하는 정보:
해당 스냅샷의 모든 매니페스트 파일 경로
각 매니페스트의 파티션 범위 요약
각 매니페스트의 행 수, 파일 수
manifest-list.avro
├── manifest-1.avro (파티션: 2024-01-01 ~ 2024-01-15)
├── manifest-2.avro (파티션: 2024-01-16 ~ 2024-01-31)
└── manifest-3.avro (파티션: 2024-02-01 ~ 2024-02-15)
3. Manifest File (manifest-1.avro)
실제 데이터 파일들의 상세 정보
"A manifest file is an index that lists a set of data files (e.g., Parquet files) along with metadata about each file – including row count, partition values, and column statistics such as minimum and maximum values"
포함하는 정보:
데이터 파일 경로
각 파일의 파티션 값
각 파일의 컬럼별 min/max 통계
행 수, 파일 크기
null 값 개수
manifest-1.avro
├── data-file-1.parquet
│ ├── path: "s3://bucket/data/part-001.parquet"
│ ├── partition: {date: "2024-01-01"}
│ ├── record_count: 1000000
│ └── column_stats: {
│ └── user_id: {min: 1, max: 5000}
│ }
├── data-file-2.parquet
└── data-file-3.parquet
4. 계층별 역할 정리
| 계층 | 파일 | 역할 | 크기 |
| 1단계 | metadata.json | 테이블 정의, 스냅샷 목록 | 수 KB |
| 2단계 | manifest-list.avro | 스냅샷의 매니페스트 인덱스 | 수 KB ~ MB |
| 3단계 | manifest-1.avro | 데이터 파일 카탈로그 + 통계 | 수 MB |
| 4단계 | data files | 실제 데이터 | GB |
5. 왜 이렇게 나누었나?
"This hierarchical design means that to plan a query, an engine can read just a few files"
효율적인 쿼리 계획을 위해:
Metadata 읽기: 테이블 스키마와 현재 스냅샷 확인 (수 KB)
Manifest List 읽기: 필요한 파티션 범위의 매니페스트만 선택 (수 KB)
Manifest 읽기: 선택된 매니페스트에서 쿼리 조건에 맞는 데이터 파일만 선택 (수 MB)
Data 읽기: 최종 선택된 파일만 읽기
이렇게 단계적으로 필터링하여 페타바이트 데이터에서도 필요한 파일을 O(1) 시간에 찾을 수 있습니다.
왜 Delta Lake는 단일 로그를, Iceberg는 계층 구조를 선택했을까?
위에서 살펴본 것처럼 Delta Lake는 모든 트랜잭션 로그를 _delta_log라는 단일 디렉토리에 저장하는 반면, Iceberg는 메타데이터와 메니페스트라는 계층적 구조를 채택했습니다.
언뜻 보면 Iceberg의 계층 구조가 더 체계적이고 확장 가능해 보이는데, 왜 Delta Lake는 단순한 단일 디렉토리 방식을 고수했는지 궁금해서 Delta Lake 의 논문을 찾아봤습니다.
답은 클라우드 오브젝트 스토어의 성능 특성에 있었습니다. Delta Lake 논문을 살펴보면, 설계 결정의 배경이 명확하게 드러납니다.
Delta Lake는 왜 단일 로그 구조를 선택했을까?
1. LIST 연산의 높은 비용
"S3's LIST only returns up to 1000 keys per call, and each call takes tens to hundreds of milliseconds, so it can take minutes to list a dataset with millions of objects"
S3에서 파일 목록을 조회하는 LIST 연산은 한 번에 최대 1000개의 키만 반환하며, 각 호출마다 수십에서 수백 밀리초가 소요됩니다. 만약 계층 구조를 사용한다면:
metadata/
├── v1.metadata.json
├── v2.metadata.json
└── v3.metadata.json
manifests/
├── manifest-1.avro
├── manifest-2.avro
└── manifest-3.avro
여러 디렉토리를 각각 LIST해야 하므로 성능 저하가 불가피합니다.
2. 연속된 번호 체계의 효율성
Delta Lake는 영리한 해결책을 제시했습니다:
_delta_log/
├── 00000000000000000000.json
├── 00000000000000000001.json
├── 00000000000000000002.json
└── 00000000000000000003.checkpoint.parquet
"Zero-padding the IDs of log records makes it efficient for clients to find all the new records after a checkpoint using the lexicographic LIST operations available on object stores"
이렇게 0으로 패딩된 연속 번호를 사용하면:
특정 시점 이후의 변경사항을 한 번의 LIST 연산으로 찾을 수 있음
사전식 정렬을 활용해 시작 키부터 효율적으로 검색 가능
체크포인트 이후 새로운 로그만 빠르게 식별 가능
3. 단순하면서도 강력한 동시성 제어
"clients need a way to ensure that only a single writer can create the next log record (e.g., 000003.json)"
순차적 번호 체계의 또 다른 장점은 동시성 제어입니다:
다음 로그 번호가 명확함 (현재가 N이면 다음은 N+1)
put-if-absent연산만으로 원자적 쓰기 보장계층 구조에서 발생할 수 있는 여러 지점에서의 충돌 회피
4. 빠른 최신 상태 확인
_delta_log/
└── _last_checkpoint # 최신 체크포인트 위치 저장
_last_checkpoint 파일 하나만 읽으면 전체 로그를 스캔하지 않고도 최신 체크포인트를 찾을 수 있습니다. 이는 대규모 테이블에서 특히 중요한 최적화입니다.
그렇다면 Iceberg는 왜 다른 선택을 했을까?
Iceberg가 계층적 구조를 선택한 이유
Netflix에서 100PB가 넘는 데이터를 처리하며 Hive의 한계를 절감했고, 이를 해결하기 위해 Iceberg를 개발했습니다. 그들이 계층적 메타데이터 구조를 선택한 이유를 살펴보겠습니다.
1. 디렉토리 LIST의 성능 재앙
"For a query on a Hive table, it took 9.6 minutes just to plan the query"
— 출처: Apache Iceberg: An Architectural Look Under the Covers (2024)
Hive에서 수백만 개의 파일을 가진 테이블을 쿼리하려면:
전체 디렉토리를 LIST해야 함
각 파일의 footer를 읽어 통계 정보 확인
쿼리 계획만 9.6분 소요
2. O(1) 성능을 위한 계층 인덱스
"O(1) RPCs to plan: Instead of listing O(n) directories in a table to plan a job, reading a snapshot requires O(1) RPC calls"
— 출처: GitHub - Netflix/iceberg
Iceberg의 계층 구조:
metadata.json (수 KB)
├── 스냅샷 목록
└── 현재 스냅샷 포인터
↓
manifest-list.avro (수 KB ~ MB)
├── 매니페스트 파일 목록
└── 파티션 범위 통계
↓
manifest-1.avro, manifest-2.avro (각 수 MB)
├── 데이터 파일 목록
└── 컬럼별 min/max 통계
↓
data files (GB 단위)
이 구조의 장점:
작은 메타데이터 파일 몇 개만 읽으면 필요한 데이터 파일 즉시 식별
전체 디렉토리 LIST 불필요
통계 기반으로 불필요한 파일 사전 제외 (predicate pushdown)
3. 분산 쿼리 계획의 필요성
"Distributed planning: File pruning and predicate push-down is distributed to jobs, removing the metastore as a bottleneck"
— 출처: GitHub - Netflix/iceberg
중앙 메타스토어의 병목 제거:
매니페스트 파일을 워커들이 병렬로 처리
각 워커가 독립적으로 필요한 데이터 파일 결정
수천 개의 파티션도 빠르게 스캔 가능
4. 데이터 재작성 없는 파티션 진화
"The metadata layer is designed with O(1) complexity for file search operations unlike in Hive where complexity follows a linear function with increasing number of files in a partition"
— 출처: Demystifying a Table Format: Apache Iceberg (2023)
페타바이트 규모에서 파티션 변경의 악몽:
Hive: 전체 데이터셋 재작성 필요
Iceberg: 메타데이터만 변경
5. 숨겨진 파티셔닝 (Hidden Partitioning)
"The most important feature of iceberg partitions is that users are not required to specify the partition clause from the queries"
— 출처: Demystifying a Table Format: Apache Iceberg (2023)
사용자 경험의 혁신:
물리적 파티션 구조를 몰라도 쿼리 작성 가능
매니페스트의 통계 정보로 자동 파티션 pruning
파티션 변경이 쿼리에 투명하게 반영
6. 성능 개선의 실제 결과
"This hierarchical design means that to plan a query, an engine can read just a few files: the metadata file (small JSON), one manifest list (small Avro file), and the relevant manifest files"
— 출처: Apache Iceberg in Modern Data Architectures (2024)
Hive: 쿼리 계획 수립에 9.6분
Iceberg: 쿼리 계획 + 실행 완료까지 42초
개선율: 13배 이상
Netflix의 페타바이트 규모 환경에서 **"몇 분씩 걸리는 쿼리 계획"**은 받아들일 수 없는 성능이었습니다. Iceberg의 계층적 구조는 이 문제를 근본적으로 해결하기 위한 설계였으며, 복잡성을 감수하고서라도 O(1) 성능을 달성하는 것이 목표였습니다.
위 내용을 단순하게 요약하면, 아래와 같습니다.
| 비교 | Delta Lake | Iceberg |
| 구조 | 단일 로그 디렉토리 | 계층적 메타데이터 |
| 스캔 효율 | 전체 로그 읽기 | 선택적 매니페스트 읽기 |
“데이터를 조회한다” 라는 말에는 어떤 쿼리 엔진이, 어떤 스토리지 위에서 가장 빠르고 다른 트랜잭션에 영향을 주지 않으면서 데이터를 읽는가라는 의미를 가지고 있다고 생각합니다. 빅데이터는 여러 쿼리 엔진을 사용할 수 있는 만큼, 데이터를 읽을 때 가장 최소화 해줄 수 있는 스토리지 포맷에 대해서도 어떤 특성을 가지고 있는지 이해하는 게 정말 중요하다는 걸 한번 더 느꼈습니다.
2. MoR 과 Small Files 최적화
데이터는 늘 무한하게 흘러갑니다. 흘러간다라는 말은 데이터가 추가될 수도 있지만 변경되거나 삭제될 수도 있다는 말과 같습니다. 그래서 흔히 CoW(Copy on Write) 와 MoR(Merge on Read) 방식을 사용하게 되는데, 이때 CoW 은 데이터 변경시 변경된 데이터와 함께 새로운 데이터 파일을 만들어 결국에는 스토리지 용량을 매우 빠르게 차지하는 주범이 되기도 합니다.
그렇다고 CoW 가 무조건 나쁘다는 건 아닙니다. 다른 데이터 처리 작업 없이 바로 파일을 읽으면 되므로 읽기 성능이 좋습니다. 만약 용량이 큰 파일에 대한 변경이 일어나면 새로운 파일을 만들기 위한 파일을 쓸 대상이 많아져서 느려질 수도 있다는 단점이 있습니다.
그래서 MoR 방식은 변경된 부분에 대해 바이너리 파일 혹은 여러 형태로 기록하고, 그 내용을 메타데이터 등에 저장합니다. 이 경우 스토리지 용량이 매우 가파르게 올라가지 않게 되나, 단점은 읽을 때 여러 파일을 읽어야한다는 점입니다. 또한 변경된 부분은 전체 파일에 비해 매우 용량이 작은 경우가 많을 거라 KB, MB 단위의 small files 수가 늘어나 스토리지내에 존재하는 객체의 수가 매우 많이 증가할 수 있게 됩니다.
Iceberg 와 Delta Lake 는 MoR 을 아래와 같이 제공합니다.
| 구분 | Delta Lake | Iceberg |
| 기술 | Deletion Vector (v3.0+) | Delete Files |
| 구현 | RoaringBitmap | Positional/Equality Delete |
| 특징 | 단순하지만 효과적 | 더 유연한 삭제 전략 |
MoR 에 대해서는 두 스토리지 포맷 둘 다 지향하는 점은 비슷하나 Iceberg 가 좀 더 옵션이 많은 편이긴 합니다.
가장 차이가 나는 건 제일 처음 살펴봤던 것처럼 두 스토리지 포맷은 메타데이터를 대하는 철학이 달라 명령어가 많이 달랐습니다.
Delta Lake
-- 파일 병합
OPTIMIZE table_name;
-- Z-Order 클러스터링
OPTIMIZE table_name ZORDER BY (col1, col2);
-- Liquid Clustering (v3.1+) - 점진적 최적화
ALTER TABLE table CLUSTER BY (col1, col2);
-- 오래된 파일 정리 (기본 리텐션이 일주일
VACUUM table_name;
-- 데이터 파일의 재구성
REORG TABLE events APPLY (PURGE);
Iceberg
-- 병합 + 정렬을 한 번에!
CALL catalog.system.rewrite_data_files
( table => 'db.table', strategy => 'sort' | 'binpack',
sort_order => 'col1, col2',
options => map('target-file-size-bytes', '134217728')
);
-- 매니페스트 최적화
CALL catalog.system.rewrite_manifests('db.table');
-- 스냅샷 정리
CALL catalog.system.expire_snapshots('db.table');
-- 고아 파일 정리
CALL catalog.system.remove_orphan_files('db.table');
| 작업 | Delta Lake | Iceberg |
| 파일 병합 | OPTIMIZE | rewrite_data_files |
| 정렬 최적화 | ZORDER BY | strategy => 'sort' |
| 메타데이터 정리 | 자동 (checkpoint) | rewrite_manifests |
| 오래된 파일 삭제 | VACUUM | remove_orphan_files |
마치며
그 외에도 Delta Lake 와 Iceberg 는 둘 다 ACID, Time Travel 등을 지원하고 있으나 각 트랜잭션별 기본 격리수준과 CDC 를 처리하는 방식도 많은 부분이 다르지만, 이번 포스트에서는 가장 중요한 메타데이터 구조와 실제로 운영환경에서 최적화를 진행하면서 이 명령어가 뭘 하는 건지 알아보는 게 더 중요해서 이 두가지에 대해서만 다뤘습니다.
이번에 두 스토리지 포맷에 대해 공부하면서 느낌점은 둘 다 대용량 데이터를 어떻게 처리하는지에 대한 고민에서부터 시작했지만 그 결과물이 해결방법에서 달라지는 걸 보면서 문제에 대해서 어떻게 해결할 건인가? 혹은 그 방법 말고 다른 방법은 없을까에 대한 고민이 정말 중요하다는 걸 많이 느꼈습니다.