Parquet의 분석 성능 이점을 유지하면서도 S3의 조건부 쓰기(ETag 기반 CAS)와 톰브스톤을 이용해 파일 재작성 없이 상수 시간 삭제와 스냅샷 격리를 제공하는 테이블 형식을 설계한다.
URL: https://www.shayon.dev/post/2025/277/an-mvcc-like-columnar-table-on-s3-with-constant-time-deletes/
Parquet는 분석 워크로드에 탁월하다. 컬럼형 레이아웃, 강력한 압축, 프레디킷 푸시다운까지. 하지만 삭제(delete)는 전체 파일을 다시 써야 한다. Apache Iceberg나 Delta Lake 같은 시스템은 데이터 파일과 별도로 delete 파일을 추적하는 메타데이터 계층을 추가해 이 문제를 푼다. 그렇다면 (재미 삼아) 더 (어쩌면) 단순한 무언가를 만들어 보면 어떨까? 이제 S3는 외부 조정(coordination) 없이도 원자적 연산을 가능하게 하는 조건부 쓰기(If-Match, If-None-Match)를 지원한다. Parquet의 장점을 대부분 유지하면서 상수 시간 삭제를 지원하는 S3 기반 컬럼형 테이블 포맷을 어떻게 만들 수 있을지 살펴보자.
Parquet 파일은 설계상 불변이다. Parquet 파일을 써 둔 뒤 나중에 id = 500인 행을 삭제해야 한다면, 선택지는 세 가지다.
대부분의 프로덕션 시스템은 3번을 택한다. Iceberg는 데이터 파일과 삭제 파일을 참조하는 매니페스트(manifest) 파일을 유지한다. Delta Lake는 삭제된 행이 들어 있는 파일을 추적하는 트랜잭션 로그를 사용한다. 둘 다 독자가 일관된 스냅샷을 보도록 보장하기 위해 세심한 조정이 필요하다.
핵심 과제는 전통적인 데이터베이스 없이 ACID 의미론을 유지하는 것이다. 여러 작성자(writer)가 동시에 데이터를 append하거나 행을 삭제할 때, 어떻게 불일치 상태를 막을까? 여기서 S3의 조건부 쓰기가 흥미로워진다.
S3는 HTTP 사전조건(precondition) 헤더를 이용한 조건부 쓰기 지원을 도입했다.
If-None-Match: "*" — 오브젝트가 존재하지 않을 때만 성공(생성 전용)If-Match: <etag> — 오브젝트가 바뀌지 않았을 때만 성공(비교-후-교체, compare-and-swap; CAS)비교-후-교체(CAS)는 기본적인 동시성 프리미티브로, 기대하는 현재 값(ETag)을 제공하면 그 값이 바뀌지 않았을 때만 업데이트가 성공한다. 다른 누군가가 오브젝트를 수정했다면, 조용히 덮어쓰는 대신 412 Precondition Failed를 받는다. 이 방식은 락 없이도 여러 작성자가 조정할 수 있게 해 주며, CAS 경쟁에서 이긴 쪽이 커밋하고 진 쪽은 새 상태로 재시도한다.
이 프리미티브들만으로도 외부 조정 없이 분산 커밋 프로토콜을 만들 수 있다. 현재 테이블 상태를 가리키는 단순 포인터 오브젝트를 생각해 보자.
S3 Bucket: mytable/
├── _latest_manifest ← 변경 가능한 포인터(CAS만)
│ {"version": 123}
│
├── manifest/v00000123.json ← 불변 스냅샷
│ {
│ "version": 123,
│ "previous": 122,
│ "data_files": [...],
│ "tombstones": [...]
│ }
│
├── data/2025/10/04/14/
│ ├── f81d4fae.parquet ← Parquet 파일(여러 row group)
│ ├── a1b2c3d4.parquet ← Parquet 파일(여러 row group)
│ └── ...
│
└── tombstone/2025/10/04/14/
└── abc123.del ← 삭제 마커
_latest_manifest 오브젝트는 단일 오브젝트 트랜잭션 로그처럼 동작한다. 작성자들은 compare-and-swap으로 이를 업데이트하려고 경쟁하며, 락이나 외부 DB 없이도 직렬화 가능한 커밋 순서를 제공한다.
포인터를 제외한 모든 것은 한 번 쓰면 끝(write-once)이다. 이 설계 원칙은 다음과 같은 방식으로 일관성을 단순하게 만든다.
WRITE-ONCE OBJECTS COMMIT POINTER
(If-None-Match: "*") (If-Match: etag)
┌──────────────────────────────┐ ┌──────────────────────────┐
│ data/YYYY/MM/DD/HH/ │ │ _latest_manifest │
│ <uuid>.parquet │ │ {"version": 123} │
│ (256 MB, ~60 row groups) │ │ ETag: "abc123def456" │
│ │ │ │
│ tombstone/YYYY/MM/DD/HH/ │◀─────│ CAS 토큰이 │
│ <uuid>.del │ │ 매니페스트 원자적 업데이트│
│ │ │ 를 가능하게 함 │
│ manifest/v*.json │ └──────────────────────────┘
└──────────────────────────────┘
작성자가 데이터를 append하거나 삭제를 기록하고 싶을 때는 원자성을 보장하는 프로토콜을 따른다.
_latest_manifest를 가져와 현재 버전 + ETag 확인이 낙관적 동시성 패턴은 분산 시스템에서 ETag를 다뤄 봤거나 DB의 MVCC를 이해한다면 익숙할 것이다.
커스텀 컬럼형 포맷을 만들기보다는 표준 Parquet 파일을 사용한다. 각 Parquet 파일은 여러 row group으로 구성되고, 각 row group 내부에서 테이블의 모든 컬럼이 컬럼형 형태로 저장된다. id, event_time, payload 컬럼을 가진 테이블이라면 다음과 같을 수 있다.
data/2025/10/04/14/f81d4fae.parquet (256 MB, ~60 row groups)
data/2025/10/04/14/a1b2c3d4.parquet (256 MB, ~60 row groups)
data/2025/10/04/14/b5e6f7g8.parquet (256 MB, ~60 row groups)
각 파일은 컬럼형 인코딩(딕셔너리, RLE, 비트 패킹)과 압축(ZSTD)을 사용하는 표준 Parquet 파일이다. 푸터에는 row group별/컬럼별 통계가 들어 있어 프레디킷 푸시다운을 가능하게 한다. 내부적으로 Parquet는 각 row group 내에서 컬럼 데이터를 분리해 저장하므로, 독자는 HTTP range 요청으로 필요한 컬럼과 row group만 가져올 수 있다.
파일 크기는 256512MB, row group은 압축 기준 14MB를 목표로 한다. 이는 병렬성(많은 파일을 동시에 읽기)과 오버헤드(매니페스트 엔트리 수 감소, S3 요청 수 감소) 사이의 균형이다. row group 크기는 HTTP range 요청의 그라뉼러리티를 결정한다.
f81d4fae.parquet (256 MB compressed)
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Row Group 0 Row Group 1 Row Group 2 ... ┃
┃ offset: 1024 offset: 4194304 offset: 8388608 ┃
┃ rows: 200K rows: 200K rows: 200K ┃
┃ size: 4 MB size: 4 MB size: 4 MB ┃
┃ ┃
┃ Columns within each row group: ┃
┃ ┌────────────────────────────────────────────────────┐ ┃
┃ │ id column (compressed INT64) │ ┃
┃ │ min: 1000000, max: 1199999 │ ┃
┃ ├────────────────────────────────────────────────────┤ ┃
┃ │ event_time (compressed timestamp) │ ┃
┃ │ min: 2025-10-04T13:00:00Z │ ┃
┃ │ max: 2025-10-04T13:05:00Z │ ┃
┃ ├────────────────────────────────────────────────────┤ ┃
┃ │ payload (compressed binary) │ ┃
┃ └────────────────────────────────────────────────────┘ ┃
┃ ┃
┃ Footer: schema, row group directory, column statistics ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
푸터 메타데이터는 프레디킷에 따라 어떤 row group을 가져올지, 그리고 필요한 컬럼에 대해 어떤 바이트 범위를 요청할지 알려준다. 예를 들어 쿼리가 id BETWEEN 1000000 AND 1100000으로 필터하고 event_time 컬럼만 필요하다면, Row Group 1과 2는 통째로 건너뛰고, Row Group 0 내부에서도 event_time 컬럼 바이트만 가져오면 된다.
매니페스트는 완전한 테이블 스냅샷을 설명하는 JSON 문서(혹은 더 컴팩트하게 MessagePack 같은 바이너리 포맷)다.
{
"version": 123,
"previous": 122,
"created_at": "2025-10-04T13:45:12Z",
"schema": {
"columns": [
{ "name": "id", "type": "int64" },
{ "name": "event_time", "type": "timestamp[us]" },
{ "name": "payload", "type": "binary" }
]
},
"data_files": [
{
"path": "s3://mytable/data/2025/10/04/13/f81d4fae.parquet",
"size_bytes": 268435456,
"row_group_count": 60,
"total_rows": 12000000,
"min": { "event_time": "2025-10-04T13:00:00Z", "id": 1000000 },
"max": { "event_time": "2025-10-04T13:30:00Z", "id": 12999999 }
},
{
"path": "s3://mytable/data/2025/10/04/13/a1b2c3d4.parquet",
"size_bytes": 268435456,
"row_group_count": 60,
"total_rows": 12000000,
"min": { "event_time": "2025-10-04T13:30:00Z", "id": 13000000 },
"max": { "event_time": "2025-10-04T14:00:00Z", "id": 24999999 }
}
],
"tombstones": ["s3://mytable/tombstone/2025/10/04/13/abc123.del"]
}
독자는 항상 _latest_manifest를 먼저 가져와 현재 버전을 알아내고, 그 버전의 매니페스트를 가져온다. 이렇게 하면 해당 매니페스트 버전이 참조하는 데이터 파일과 톰브스톤이 단일 시점(point-in-time) 뷰를 구성하는 일관된 스냅샷을 제공한다.
previous 포인터는 버전의 연결 리스트를 만들어 보존(retention) 기간 내에서 타임 트래블을 가능하게 한다. 10버전 전 테이블을 보고 싶다면, 아직 GC되지 않았다는 가정 하에 manifest/v00000113.json을 직접 가져오면 된다. 매니페스트는 유지 비용이 매우 싸다(데이터 파일 엔트리당 200500바이트 정도). 그래서 3090일 히스토리를 유지해도 비용은 거의 없다.
독자는 _latest_manifest를 가져온 순간의 테이블 상태를 본다. 독자가 데이터 파일을 스캔하는 동안 삭제나 append가 커밋되더라도, 그 변화는 해당 독자에게는 보이지 않는다. 이는 각 독자가 고정된 스냅샷에서 동작하는 전형적인 MVCC 동작이다. 일관성 관점에서 “stale read” 문제는 없다. 독자는 단지 더 이른 커밋 버전을 보게 될 뿐이며, 이는 스냅샷 격리가 보장하는 올바른 동작이다.
삭제는 데이터 파일을 건드리지 않는다. 대신 어떤 행이나 row group을 읽을 때 제외해야 하는지를 표시하는 작은 톰브스톤 파일을 쓴다.
// tombstone/2025/10/04/13/abc123.del
{"file": "f81d4fae.parquet", "row_group": 0}
{"file": "f81d4fae.parquet", "row_group": 5}
{"file": "a1b2c3d4.parquet", "pk_min": 15000000, "pk_max": 15999999}
톰브스톤 파일의 각 라인은 삭제 연산을 나타낸다.
{"file": "...", "row_group": N}: 파일 내 특정 row group 전체를 삭제로 표시{"file": "...", "pk_min": ..., "pk_max": ...}: 기본키 범위를 삭제로 표시row group 내부의 진짜 행 단위(row-level) 삭제가 필요하다면 다음도 가능하다.
{ "file": "f81d4fae.parquet", "row_group": 3, "deleted_rows": [0, 5, 17, 1042] }
톰브스톤 파일은 빠른 읽기를 위해 작게(보통 ≤32MB) 유지한다. 작성자가 행을 삭제해야 할 때는:
_latest_manifest + ETag 가져오기.tombstones[]에 추가한 manifest_vNext 구성이 과정은 작은 PUT 1번과 아주 작은 PUT 2번으로 끝나며, 데이터 재작성은 필요 없다. 삭제 지연시간은 데이터 양이 아니라 S3 요청 지연에 의해 제한된다.
독자는 다음과 같은 단순한 프로토콜을 구현한다.
_latest_manifest를 GET해 현재 버전과 ETag를 알아냄WHERE id BETWEEN 15M AND 16M이면 id 범위가 겹치지 않는 파일은 가지치기(prune)톰브스톤은 보통 데이터 파일보다 훨씬 작다. 톰브스톤 파일이 100개 있어도 전체 크기는 몇 MB일 수 있다. 독자는 이를 빠르게 가져와 파싱한 뒤 Parquet 디코딩 중 삭제 마스크를 적용한다. 정기적인 컴팩션을 하면 톰브스톤 파일 목록도 작게 유지된다.
효율적인 필터링을 위해 삭제된 행을 roaring bitmap으로 표현할 수 있다. Roaring bitmap은 희소한 삭제를 매우 잘 압축한다. 100만 행 row group에서 1%를 삭제하더라도 몇 KB면 충분할 수 있다.
여러 작성자가 동시에 새 행을(Parquet 파일로) append하려 할 때, 커밋을 서로 덮어쓰거나 불일치 상태를 만들지 않도록 해야 한다. 각 작성자는 Parquet 파일을 독립적으로 업로드하지만, 이후 매니페스트 포인터를 업데이트하는 경쟁을 한다. append 프로토콜은 _latest_manifest에 대한 CAS를 사용해 커밋을 직렬화한다. CAS 경쟁에서 이긴 쪽이 커밋하고, 진 쪽은 새 상태를 반영해 변경을 머지한 뒤 재시도한다.
Writer A Writer B S3
│ (v122를 봄) │ (v122를 봄) │
│ uuid1.parquet 작성 │ uuid2.parquet 작성
│ │ │
│ v123으로 CAS 시도... │ │
│ PUT _latest_manifest │ │
│ If-Match: "etag_v122" │ │
│──────────────────────────────────────────────────────▶ │
│◀─────────────────────────────────────────────── 200 OK │
│ (v123 커밋 성공) │ │
│ │ v123으로 CAS 시도... │
│ │ PUT _latest_manifest │
│ │ If-Match: "etag_v122" │
│ │───────────────────────▶│
│ │◀────────── 412 CONFLICT│
│ │ │
│ │ 재시도 로직 시작... │
│ │ GET _latest_manifest │
│ │◀───────────────────────│ (v123을 봄)
│ │ │
│ │ v124로 CAS 시도... │
│ │ PUT _latest_manifest │
│ │ If-Match: "etag_v123" │
│ │───────────────────────▶│
│ │◀───────────────── 200 OK│
│ │ (v124 커밋 성공)
편집(2025년 10월 6일): 시퀀스 다이어그램을 더 정확하게 업데이트
이 재시도 루프는 직렬화 가능한 격리를 제공한다. 데이터 파일 업로드는 경합 없이 병렬로 일어나지만, 매니페스트 커밋은 CAS 포인터로 선형화되어 동시 쓰기와 일관된 스냅샷을 함께 제공한다.
이 개념을 위한 작은 POC가 있고, 현재 생각하는 공개 API는 대략 다음과 같다. 매니페스트, 톰브스톤, CAS 의미론 같은 세부사항을 단순한 연산 뒤로 숨긴다.
type Table struct {
Bucket string
Prefix string
s3 *s3.Client
}
func Open(bucket, prefix string, cfg aws.Config) *Table
// Append writes new row groups and commits a new manifest version
func (t *Table) Append(ctx context.Context,
cols []arrow.Array,
opts AppendOptions) error
// Delete marks rows as deleted without rewriting data files
func (t *Table) Delete(ctx context.Context,
predicate DeletePredicate) error
// Scan returns an Arrow RecordReader with column projection
// and predicate pushdown
type Scanner struct {
Columns []string
Filter arrow.Expression
}
func (t *Table) Scan(ctx context.Context,
opt Scanner) (arrow.RecordReader, error)
내부적으로는:
Append: Parquet 컬럼 파일을 만들고 7단계 CAS 프로토콜을 따른 뒤, 충돌 시 재시도한다.Delete: 영향을 받는 row group을 구체화하고 톰브스톤을 쓴 다음, 매니페스트를 원자적으로 업데이트한다.Scan: 현재 매니페스트를 가져와 통계로 row group을 가지치기하고, 톰브스톤을 가져오며, 컬럼 범위를 병렬로 다운로드하고, 삭제된 행을 필터링한다.호출자는 매니페스트, 톰브스톤, CAS 의미론을 이해할 필요가 없다. 그냥 append, delete, scan만 하면 된다.
일반적인 분석 워크로드를 생각해 보자. 대량 ingestion(append 위주), 보존(retention)이나 GDPR 준수를 위한 가끔의 벌크 삭제, 그리고 시간 범위로 필터링하며 일부 컬럼만 프로젝션하는 스캔. 대부분 쿼리는 최근 데이터를 읽고, 쓰기가 읽기보다 훨씬 많다. 이는 PUT이 요청 수의 대부분을 차지하지만 저렴하고, 데이터 전송은 읽을 때만 발생하는 S3 과금 모델과 잘 맞는다.
이 설계는 요청 수와 데이터 전송량을 모두 최소화한다.
| 작업 | S3 요청 | 데이터 전송 | 비고 |
|---|---|---|---|
| 12M 행 Append | PUT 3회 | 업로드 256 MB | Parquet 1개 + 매니페스트 + 포인터 |
| 100K 행 Delete | PUT 3회 | 업로드 ~10 KB | 톰브스톤 + 매니페스트 + 포인터 |
| 1M 행 Scan(2 cols) | GET 3~5회 + range GETs | 다운로드 ~20 MB | 매니페스트 + 톰브스톤 + 컬럼 범위 |
하루 6TB ingest, 2TB 삭제, 하루 5만 쿼리인 워크로드의 경우:
PUT 요청: ~38만/일(≈ 4 req/s) = $1.88/일
GET 요청: 매우 가변적(파티셔닝 효과에 따라 다름)
스토리지: 데이터 $0.023/GB/월 + 매니페스트
위와 같은 전형적인 사례에서 매니페스트는 메타데이터만 저장하므로(데이터가 아님) 수천 개 데이터 파일이 있어도 보통 <32MB로 작다. 톰브스톤은 더 작고, 100만 행 삭제를 roaring bitmap으로 저장하면 4KB일 수도 있다. 공격적인 쿼리 패턴에서도 요청 비용은 하루 $3 이하로 유지된다.
참고: 이 비용 추정은 대략적인 숫자를 넣고 LLM 모델의 도움을 받아 수행했다.
| 시나리오 | 동작 | 복구 |
|---|---|---|
| 데이터 업로드 후, 매니페스트 커밋 전 작성자 크래시 | 고아(orphan) 데이터 파일 | 나중에(참조되지 않으므로) GC로 정리 |
| CAS 재시도 중 작성자 크래시 | 부분 매니페스트 작성 | 다음 작성자의 CAS가 성공; 고아는 GC |
| 두 작성자가 동시에 커밋 | 하나는 성공, 하나는 412 | 패자는 새 ETag로 재시도 |
| 작성 중간에 독자가 매니페스트 fetch | 이전 스냅샷을 봄 | 일관됨; 커밋 전까지 새 쓰기는 보이지 않음 |
실패 모델은 단순하다. 어떤 매니페스트 버전이 커밋되어(모든 독자에게) 보이거나, 아니면 보이지 않거나 둘 중 하나다. 부분 가시성은 없다. 커밋되지 않은 데이터 파일은 무해하며, 어떤 매니페스트도 참조하지 않는 한 쓰레기일 뿐이다.
가비지 컬렉션은 주기적으로 돌면서 고아 오브젝트와 오래된 매니페스트를 정리한다. _latest_manifest에서 시작해 보존 기간(예: 30일 또는 1000버전)만큼 previous 체인을 거슬러 올라간다. 유지할 매니페스트가 참조하는 데이터 파일과 톰브스톤을 모두 마킹한다. 7일보다 오래된(실패한 쓰기에서 생긴) 미참조 데이터 파일은 삭제한다. 선택적으로 보존 기간을 넘는 매니페스트와 그가 참조하는 오브젝트도 삭제한다.
시간이 지나면 톰브스톤 파일이 누적된다. 결국에는 작은 톰브스톤 100개를 하나로 합치거나, row group에서 삭제 비율이 50%를 넘으면 데이터 파일을 다시 써서 더 압축(compaction)하고 싶어진다.
이 설계에서 컴팩션은 백그라운드 작업으로, 현재 매니페스트를 읽고 선택된 row group을 다시 쓰고 새 파일을 가리키는 새 매니페스트를 커밋한다. 예전 파일은 GC가 제거할 때까지 남아 있다.
이 글은 조건부 쓰기를 통한 조정, 상수 시간 삭제를 위한 톰브스톤, 스냅샷 격리를 위한 단일 오브젝트 트랜잭션 포인터를 사용해 Parquet와 S3 프리미티브만으로 컬럼형 테이블 포맷을 만드는 가상의 탐구였다. 제대로 된 DB 대신 이것으로 프로덕션을 돌릴 거냐고? 그건 여러분이 판단할 일이다 :D. 특정 append 위주의 분석 워크로드에는 가능성이 있다고 생각하지만, 운영 복잡성, 실패 모드, 엣지 케이스(스키마 진화가 그중 하나) 같은 대규모에서만 드러나는 요소들을 내가 놓치고 있을 가능성은 분명하다.
대안을 비교하면 트레이드오프가 분명해진다. Iceberg나 Delta Lake에 비해 외부 카탈로그, 메타스토어, 락 서비스 등을 덜어낼 수 있지만, 그 대가로 성숙한 스키마 진화와 실전에서 검증된 운영 도구를 잃는다. 순수 Parquet와 비교하면 매니페스트 관리와 컴팩션 오버헤드를 떠안는 대신 상수 시간 삭제와 MVCC를 얻는다. PostgreSQL과 비교하면 서브초 단일 키 조회와 복잡한 트랜잭션 대신 탄력적인 스토리지와 더 단순한 운영을 택하는 셈이다(물론 이는 데이터 패턴과 autovacuum 같은 운영 과제에 크게 좌우된다).
가장 잘 맞는 지점은 append 위주의 분석 워크로드에 가끔 벌크 삭제가 있는 경우다. 예를 들어 이벤트 로그, 시계열 데이터, 또는 상류 시스템에서 온 삭제를 “역사를 다시 쓰지 않고” 적용해야 하는 CDC 스트림 같은 것들.
이 설계에는 자연스러운 확장 한계가 있다. 매니페스트는 파일 수에 선형적으로 커지고 결국 계층적 구조가 필요해진다. 톰브스톤은 시간이 지나며 누적되어 주기적 컴팩션이 필요하다. 단일 포인터는 극단적인 쓰기 동시성 하에서 핫스팟이 될 수 있다. 다만 중간 규모의 분석 워크로드에서는 탐구할 가치가 있는 좋은 프리미티브들이 있다고 본다.
그럼에도, (위의 스케일링 병목 중 일부에 대해) 더 나은 방향으로 발전시킬 여지는 있다고 생각한다. Iceberg와 Delta Lake는 이 아키텍처가 작동함을 증명했고, 동시에 더 적은 구성 요소로 이루어진 아키텍처가 가능할지 나 역시 진심으로 궁금하다.
무엇보다도, 오브젝트 스토리지 프리미티브로 어디까지 밀어붙일 수 있는지를 보여주는 재미있는 설계 연습이었다.
다음에 또 보자.