이전 글에서는 Schema Registry가 Kafka 환경에서 스키마 호환성을 검증하는 구조를 살펴봤습니다. 스트리밍에서는 Schema Registry가 스키마 진화를 관리하지만, 데이터 레이크 쪽은 사정이 다릅니다. S3에 Parquet 파일이 수만 개 쌓여 있고, 파일마다 스키마가 다를 수 있고, 파티션 구조도 중간에 바뀔 수 있습니다.
이번 글의 주제는 Apache Iceberg입니다. Iceberg는 Parquet 같은 데이터 파일을 직접 건드리지 않고, 그 위에 메타데이터 레이어를 올려서 수만 개의 파일을 하나의 테이블처럼 다루는 테이블 포맷(Table Format)입니다. 데이터 파일은 건드리지 않으니, ACID나 스키마 진화 같은 기능이 메타데이터 조작만으로 가능해집니다.
1. Hive 테이블의 한계
Iceberg 이전에 Hadoop 생태계에서 쓰였던 Hive 테이블부터 보겠습니다.
1-1. 디렉토리 = 파티션
Hive 테이블은 디렉토리 구조로 파티션을 표현합니다.
s3://warehouse/events/
├── year=2024/month=01/ ← 디렉토리가 곧 파티션
│ ├── part-00001.parquet
│ └── part-00002.parquet
├── year=2024/month=02/
│ └── part-00001.parquet
└── year=2024/month=03/
├── part-00001.parquet
└── part-00002.parquetSELECT * FROM events WHERE year=2024 AND month=01을 실행하면, Hive Metastore가 year=2024/month=01/ 디렉토리를 알려주고, 쿼리 엔진은 그 디렉토리의 파일만 읽습니다. 디렉토리와 파티션이 물리적으로 결합된 구조입니다.
1-2. 파티션을 바꿀 수 없음
처음에 year/month로 파티셔닝했는데, 데이터가 쌓이면서 year/month/day로 바꾸고 싶으면 Hive에서는 전체 데이터를 다시 써야 합니다. 디렉토리 구조 자체가 파티션이기 때문입니다.
기존: s3://warehouse/events/year=2024/month=01/
변경: s3://warehouse/events/year=2024/month=01/day=15/
→ 기존 파일을 전부 읽어서 day별로 다시 나눠 써야 함
→ 데이터가 수 TB면 이 작업만 몇 시간1-3. 파일 목록을 모름
Hive Metastore는 파티션 디렉토리 경로만 알고, 그 안에 파일이 몇 개 있는지, 각 파일의 통계(행 수, 컬럼 범위)가 어떤지는 모릅니다. 쿼리를 실행할 때마다 디렉토리를 list해서 파일 목록을 확인해야 합니다.
쿼리 실행 시:
1. Hive Metastore에서 파티션 경로 조회
2. S3에 디렉토리 LIST 요청 (파일 목록 확인) ← 느림
3. 각 파일의 Footer 읽기 (통계 확인) ← 추가 I/O
4. 실제 데이터 읽기S3의 LIST 연산은 느립니다. 파티션 하나에 파일이 수천 개면 LIST만 수 초가 걸릴 수 있습니다. 파일이 동시에 추가/삭제되는 중이면 LIST 결과가 일관되지 않을 수도 있습니다.
1-4. 트랜잭션이 없음
Hive 테이블에는 ACID 트랜잭션이 없습니다. 데이터를 쓰는 도중에 누군가 읽으면, 반만 쓰인 데이터를 읽을 수 있습니다. 쓰기가 실패하면 불완전한 파일이 디렉토리에 남습니다.
Writer: 파일 1 쓰기 완료 → 파일 2 쓰기 중...
Reader: 디렉토리 LIST → 파일 1은 보이고, 파일 2는 아직 불완전
→ Reader가 읽는 시점에 따라 결과가 달라짐
→ Writer가 실패하면 파일 1만 남아 있는 상태Hive 테이블은 파티션을 바꿀 수 없고, 파일 단위 추적이 안 되고, 트랜잭션도 없습니다.
2. Iceberg의 메타데이터 구조
2-1. 네 개의 레이어
Iceberg 테이블은 네 개의 레이어로 구성됩니다. 위에서 아래로 갈수록 실제 데이터에 가깝습니다.
Catalog
│
│ "현재 메타데이터 파일 위치"
▼
Metadata File (v3)
┌──────────────────┐
│ 스키마 정보 │
│ 파티션 스펙 │
│ 스냅샷 목록 │
│ └─ snap-3 (현재) │
│ └─ snap-2 │
│ └─ snap-1 │
└──────────────────┘
│
│ snap-3이 가리킴
▼
Manifest List
┌──────────────────┐
│ manifest-A.avro │
│ manifest-B.avro │
│ manifest-C.avro │
└──────────────────┘
/ │ \
/ │ \
▼ ▼ ▼
Manifest Files (Avro)
┌───────────────┐
│ file-1.parquet│ 행 수, 컬럼 범위,
│ file-2.parquet│ 파티션 값 등
│ file-3.parquet│ 파일별 통계 포함
└───────────────┘
│
▼
Data Files (Parquet)각 레이어의 역할
- Catalog
- 테이블의 현재 메타데이터 파일 위치를 가리키는 포인터
- Hive Metastore, AWS Glue, Nessie 등이 Catalog 역할을 합니다
- Metadata File
- 테이블의 스키마, 파티션 스펙, 스냅샷 이력 등 테이블 전체 정보
- JSON 형식
- Manifest List
- 하나의 스냅샷에 포함된 Manifest File 목록
- Avro 형식
- Manifest File
- 실제 데이터 파일(Parquet)의 경로와 파일별 통계 정보
- Avro 형식
2-2. Metadata File
Metadata File은 테이블의 전체 상태를 정의하는 JSON 파일입니다.
{
"format-version": 2,
"table-uuid": "9c12d441-03fe-4693-bc6e-12f520d83a2a",
"location": "s3://warehouse/db/events",
"current-snapshot-id": 3497810964824022504,
"schemas": [
{
"schema-id": 0,
"fields": [
{"id": 1, "name": "event_id", "type": "string", "required": true},
{"id": 2, "name": "timestamp", "type": "timestamptz", "required": true},
{"id": 3, "name": "user_id", "type": "long", "required": false}
]
},
{
"schema-id": 1,
"fields": [
{"id": 1, "name": "event_id", "type": "string", "required": true},
{"id": 2, "name": "timestamp", "type": "timestamptz", "required": true},
{"id": 3, "name": "user_id", "type": "long", "required": false},
{"id": 4, "name": "region", "type": "string", "required": false}
]
}
],
"current-schema-id": 1,
"partition-specs": [...],
"snapshots": [
{"snapshot-id": 3497810964824022504, "manifest-list": "s3://warehouse/db/events/metadata/snap-34978...avro"},
{"snapshot-id": 2389100394082843922, "manifest-list": "s3://warehouse/db/events/metadata/snap-23891...avro"}
]
}첫째, 스키마에 필드 ID가 있습니다. "id": 1, "name": "event_id"처럼 각 필드에 고유 ID가 부여됩니다. 필드 이름을 바꿔도 ID가 같으면 같은 필드로 인식합니다. 이전 글에서 다뤘던 Avro는 Writer/Reader Schema를 필드 이름으로 매칭했는데, Iceberg는 필드 ID로 매칭합니다. 덕분에 필드 이름을 자유롭게 바꿀 수 있습니다.
둘째, 스키마 버전이 여러 개 보관됩니다. schema-id: 0과 schema-id: 1이 함께 존재합니다. 과거 스냅샷은 이전 스키마를, 최신 스냅샷은 현재 스키마를 참조합니다. 데이터 파일을 다시 쓸 필요 없이, 읽을 때 스키마 매핑만 하면 됩니다.
2-3. Manifest List
Manifest List는 하나의 스냅샷이 어떤 Manifest File로 구성되는지 기록합니다. Avro 형식이고, 각 항목에는 Manifest File의 경로와 요약 통계가 들어 있습니다.
Manifest List (snap-3497...)
┌────────────────────────────────────────────────────┐
│ manifest_path │ partition_summary │
│────────────────────────────│───────────────────────│
│ s3://.../manifest-A.avro │ region: [us, eu] │
│ s3://.../manifest-B.avro │ region: [ap] │
│ s3://.../manifest-C.avro │ region: [us] │
└────────────────────────────────────────────────────┘WHERE region = 'ap'라는 조건이 들어오면, Manifest List 단계에서 이미 manifest-A, manifest-C를 건너뛸 수 있습니다. Hive처럼 디렉토리를 LIST하는 것이 아니라, 메타데이터 파일 하나를 읽는 것으로 필터링이 끝납니다.
2-4. Manifest File
Manifest File은 실제 데이터 파일(Parquet)의 목록과 파일별 통계를 담고 있습니다. 역시 Avro 형식입니다.
Manifest File (manifest-B.avro)
┌──────────────────────────────────────────────────────────────┐
│ file_path │ partition │ record_count │ col stats │
│──────────────────────│────────────│─────────────│──────────│
│ s3://.../data-1.pq │ region=ap │ 150,000 │ ts: [1월~3월] │
│ s3://.../data-2.pq │ region=ap │ 200,000 │ ts: [4월~6월] │
│ s3://.../data-3.pq │ region=ap │ 180,000 │ ts: [7월~9월] │
└──────────────────────────────────────────────────────────────┘WHERE region = 'ap' AND timestamp > '2024-06-01'이라면, Manifest File의 컬럼 통계를 보고 data-1.pq(1월~3월)를 건너뛸 수 있습니다. Parquet 파일의 Footer를 열어보지 않아도 됩니다.
Hive:
Metastore → 디렉토리 경로 → S3 LIST → 파일별 Footer 읽기 → 데이터 읽기
Iceberg:
Catalog → Metadata → Manifest List → Manifest File → 데이터 읽기
(S3 LIST 없음, 파일별 Footer 읽기 없음)파일이 수만 개인 테이블에서는 쿼리 계획(Query Planning) 시간이 수 분에서 수 초로 줄어듭니다.
3. 스냅샷과 ACID
3-1. 스냅샷이란
Iceberg에서 테이블에 데이터를 추가하거나 삭제할 때마다 새로운 스냅샷(Snapshot)이 생성됩니다. 스냅샷은 "이 시점에 테이블을 구성하는 파일 목록"입니다.
snap-1: [file-A, file-B]
│
│ INSERT → file-C 추가
▼
snap-2: [file-A, file-B, file-C]
│
│ DELETE → file-A의 일부 행 삭제
▼
snap-3: [file-A', file-B, file-C]기존 스냅샷은 변경되지 않습니다. 새 스냅샷이 추가될 뿐이고, 기존 데이터 파일도 수정되지 않습니다. Iceberg의 ACID는 이 불변성 위에서 동작합니다.
3-2. 읽기와 쓰기가 충돌하지 않는 이유
Iceberg에서는 읽기와 쓰기가 서로 간섭하지 않습니다.
Writer Reader
│ │
│ 1. 새 데이터 파일 쓰기 │
│ (기존 스냅샷에 영향 없음) │
│ │ snap-2 기준으로 읽기 시작
│ 2. 새 Manifest 파일 쓰기 │ (file-A, file-B, file-C)
│ │
│ 3. 새 Metadata File 쓰기 │ Reader는 snap-2를 보고 있으므로
│ (snap-3 포함) │ Writer의 작업에 영향받지 않음
│ │
│ 4. Catalog 포인터를 snap-3으로 │
│ 원자적으로(atomically) 변경 │
│ │
▼ ▼Writer는 새 파일과 새 메타데이터를 먼저 다 쓰고, 마지막에 Catalog 포인터만 바꿉니다. Reader는 읽기 시작할 때의 스냅샷을 끝까지 사용합니다.
ACID가 성립하는 이유는 마지막 단계인 Catalog 포인터 변경이 원자적이기 때문입니다. 포인터가 바뀌기 전에는 새 스냅샷이 없는 것과 같고, 바뀐 후에는 완전한 스냅샷이 즉시 보입니다. 중간 상태가 존재하지 않습니다.
3-3. 쓰기 충돌 처리
두 Writer가 동시에 같은 테이블에 쓰는 경우도 있습니다. Iceberg는 Optimistic Concurrency Control(낙관적 동시성 제어)로 이를 처리합니다.
Writer A Writer B
│ │
│ 현재 snap-2 기준으로 시작 │ 현재 snap-2 기준으로 시작
│ │
│ 새 파일 쓰기 │ 새 파일 쓰기
│ 새 Manifest 쓰기 │ 새 Manifest 쓰기
│ 새 Metadata 쓰기 (snap-3) │ 새 Metadata 쓰기 (snap-3')
│ │
│ Catalog 업데이트: snap-2→3 ✅ │
│ │ Catalog 업데이트 시도
│ │ → snap-2가 아닌 snap-3이 현재 ❌
│ │ → 충돌 감지, 재시도
│ │
│ │ snap-3 기준으로 재시도
│ │ 새 Metadata (snap-4) 쓰기
│ │ Catalog: snap-3→4 ✅Writer B는 Catalog를 업데이트하려는 시점에 이미 다른 Writer가 스냅샷을 변경한 것을 감지하고, 충돌하지 않는 경우 자동으로 재시도합니다. 충돌이 해결 불가능한 경우(같은 파일을 수정한 경우)에는 쓰기가 실패합니다.
Catalog가 RDBMS(예: JDBC Catalog)라면 트랜잭션으로, S3라면 조건부 쓰기(conditional put)로 원자성을 보장합니다.
4. Hidden Partitioning
4-1. Hive 파티션의 문제
Hive에서 timestamp 컬럼으로 월별 파티셔닝을 하려면, 사용자가 직접 파티션 컬럼을 만들어야 합니다.
-- Hive: 파티션 컬럼을 명시적으로 지정
CREATE TABLE events (
event_id STRING,
timestamp TIMESTAMP,
user_id BIGINT
) PARTITIONED BY (month STRING);
-- INSERT할 때도 파티션 값을 직접 지정
INSERT INTO events PARTITION (month='2024-01')
SELECT event_id, timestamp, user_id FROM ...;
-- 쿼리할 때도 파티션 컬럼 이름을 알아야 함
SELECT * FROM events WHERE month = '2024-01';timestamp와 month가 별개의 컬럼이라 사용자가 파티션 구조를 알아야 합니다. WHERE timestamp > '2024-01-01'로 쿼리하면 파티션 프루닝이 안 될 수 있습니다. 그리고 월별에서 일별로 바꾸려면 데이터를 전부 다시 써야 합니다.
4-2. Partition Transform
Iceberg는 원본 컬럼에 변환 함수(Transform)를 적용하여 파티션을 만듭니다. 별도의 파티션 컬럼이 필요 없습니다.
-- Iceberg: 원본 컬럼에 Transform 적용
CREATE TABLE events (
event_id STRING,
timestamp TIMESTAMP,
user_id BIGINT
) USING iceberg
PARTITIONED BY (month(timestamp));사용자 입장에서는 파티션 구조를 몰라도 됩니다. WHERE timestamp > '2024-01-01'로 쿼리하면, Iceberg가 month(timestamp) 변환을 적용하여 알아서 파티션 프루닝을 수행합니다.
Iceberg가 제공하는 Transform 함수:
4-3. Partition Evolution
Iceberg에서는 데이터를 다시 쓰지 않고 파티션 구조를 변경할 수 있습니다.
-- 월별 파티셔닝에서 일별로 변경
ALTER TABLE events ADD PARTITION FIELD day(timestamp);
ALTER TABLE events DROP PARTITION FIELD month(timestamp);변경 이후 새로 쓰는 데이터만 day(timestamp) 기준으로 파티셔닝됩니다. 기존 데이터 파일은 그대로 두고, Manifest File에 기록된 파티션 정보를 참조합니다.
snap-1 ~ snap-5: month(timestamp) 기준 파일들
↕ Manifest에 파티션 정보 기록되어 있음
snap-6 이후: day(timestamp) 기준 파일들
쿼리 시: 양쪽 파티션 스펙을 모두 참조하여 프루닝기존 데이터를 재작성할 필요 없이, 메타데이터만 변경하면 됩니다.
5. 스키마 진화와 타임트래블
5-1. 스키마 진화
Iceberg의 스키마 진화는 필드 ID 기반입니다. 이전 글에서 다뤘던 Avro는 필드 이름으로 매칭했기 때문에 이름 변경이 breaking change였는데, Iceberg는 필드 ID로 매칭하므로 이름을 자유롭게 바꿀 수 있습니다.
지원하는 스키마 변경:
-- 필드 추가
ALTER TABLE events ADD COLUMN region STRING;
-- 필드 이름 변경 (필드 ID는 유지)
ALTER TABLE events RENAME COLUMN region TO geo_region;
-- 필드 타입 변경 (호환 가능한 변경만: int→long, float→double)
ALTER TABLE events ALTER COLUMN user_id TYPE long;
-- 필드 삭제
ALTER TABLE events DROP COLUMN geo_region;
-- 필드 순서 변경
ALTER TABLE events ALTER COLUMN user_id AFTER event_id;기존 데이터 파일은 수정되지 않습니다. Metadata File에 새 스키마 버전이 추가되고, 읽을 때 필드 ID를 기준으로 매핑합니다. 예를 들어, region 필드(ID: 4)의 이름을 geo_region으로 바꿔도, 기존 Parquet 파일에는 ID 4인 필드가 region으로 저장되어 있고, Iceberg가 읽을 때 현재 스키마의 ID 4 → geo_region으로 매핑합니다.
5-2. 타임트래블
스냅샷이 보관되어 있으므로, 과거 시점의 테이블 상태를 조회할 수 있습니다.
-- 특정 스냅샷으로 조회
SELECT * FROM events VERSION AS OF 3497810964824022504;
-- 특정 시점으로 조회
SELECT * FROM events TIMESTAMP AS OF '2024-06-15 10:00:00';스냅샷 ID나 타임스탬프를 지정하면, 해당 스냅샷의 Manifest List → Manifest File → Data File 경로를 따라갑니다. 현재 상태든 과거 상태든 데이터 파일은 물리적으로 함께 존재하고, 어떤 스냅샷 메타데이터를 읽느냐만 다릅니다.
현재 (snap-3): [file-A', file-B, file-C]
과거 (snap-1): [file-A, file-B]
→ file-A, file-A', file-B, file-C가 모두 S3에 존재
→ 어떤 스냅샷으로 읽느냐에 따라 보이는 파일이 다름타임트래블이 실제로 쓰이는 상황:
- 잘못된 데이터 수정 후 이전 상태와 비교: 어제까지는 맞았는데 오늘 틀어진 데이터를 추적
- 감사(Audit): 특정 시점의 데이터 상태를 증명
- 롤백: 잘못된 변경을 스냅샷 단위로 되돌리기
-- snap-2로 롤백
CALL system.rollback_to_snapshot('db.events', 2389100394082843922);5-3. 스냅샷 관리
스냅샷이 계속 쌓이면 메타데이터와 데이터 파일이 무한히 늘어납니다. Iceberg는 이를 관리하는 유지보수 작업을 제공합니다.
스냅샷 만료(Expire Snapshots)
-- 7일 이전 스냅샷 삭제
CALL system.expire_snapshots('db.events', TIMESTAMP '2024-06-08 00:00:00');만료된 스냅샷에서만 참조하던 데이터 파일은 삭제됩니다. 현재 스냅샷이나 다른 유효한 스냅샷에서도 참조하는 파일은 유지됩니다.
고아 파일 정리(Remove Orphan Files)
쓰기가 실패하면 데이터 파일은 S3에 올라갔는데 메타데이터에 등록이 안 된 고아 파일(Orphan File)이 생길 수 있습니다.
-- 메타데이터에 등록되지 않은 파일 삭제
CALL system.remove_orphan_files('db.events');컴팩션(Compaction)
작은 파일이 많으면 읽기 성능이 떨어집니다. 컴팩션은 작은 파일들을 합쳐서 적정 크기의 파일로 재작성합니다.
-- 작은 파일들을 합쳐서 최적화
CALL system.rewrite_data_files('db.events');컴팩션도 스냅샷 기반으로 동작합니다. 기존 파일을 합친 새 파일을 쓰고, 새 스냅샷을 만들어 기존 파일 대신 새 파일을 참조합니다. 컴팩션 중에도 읽기는 기존 스냅샷을 계속 사용하므로 중단 없이 진행됩니다.
마무리
Iceberg는 Parquet 파일은 그대로 두고, 그 위에 Catalog → Metadata File → Manifest List → Manifest File이라는 네 단계 메타데이터를 올리는 것입니다. ACID, 스키마 진화, 파티션 변경, 타임트래블은 전부 이 메타데이터 위에서 동작합니다.
Parquet의 Row Group, Avro의 스키마 동봉, Schema Registry의 호환성 규칙, 그리고 Iceberg의 메타데이터 레이어까지 이 시리즈에서 다룬 내용의 핵심은 데이터를 직접 바꾸는 대신, 메타데이터로 해석을 바꾸는 것입니다. 다음 시리즈에서는 기존 시리즈 내에서 간단하게 다루었던 BigQuery에 대해서 더 자세히 알아볼 예정입니다.