본문으로 건너뛰기
  1. 글 목록/

Iceberg - 왜 CDC 테이블의 컴팩션이 까다로운가

·9 분
작성자
nanta
Apache Kafka, Airflow, Trino, StarRocks 등 데이터 엔지니어링과 모던 데이터 인프라에 대한 실무 경험을 공유하는 블로그입니다.
목차

Iceberg CDC 테이블에 컴팩션을 돌리면 이런 에러가 뜬다.

org.apache.iceberg.exceptions.ValidationException:
Cannot commit, found new position delete for replaced data file

append-only 테이블에서는 안 나는 에러다. CDC에서만 난다. 왜 그런지 이해하려면 Iceberg v2의 delete 메커니즘부터 알아야 한다.


Iceberg v2의 row-level delete
#

데이터를 지우는 두 가지 방법. COW vs MOR
#

Iceberg에서 행을 삭제하거나 업데이트하는 방법은 두 가지다.

Copy-on-Write (COW). 변경이 생기면 데이터 파일을 통째로 다시 쓴다. 삭제할 행을 빼고 나머지를 새 파일에 복사하는 식이다. 읽기 성능은 좋지만 쓰기 비용이 크다. 배치성 대량 갱신에 적합하다.

Merge-on-Read (MOR). 데이터 파일은 건드리지 않는다. 대신 “이 행은 삭제되었다"는 정보를 별도 delete file에 기록한다. 쓰기가 빠른 대신 읽을 때 원본과 delete file을 병합해야 하므로 읽기 비용이 올라간다. CDC/Upsert 테이블에 적합하다.

CDC 파이프라인은 업데이트가 쉴 새 없이 들어오니까 MOR이 맞다. 문제는 이 MOR 경로에서 생기는 delete file이 컴팩션과 충돌한다는 점이다.

Delete file의 두 종류
#

MOR에서 사용하는 delete file에는 두 가지가 있다.

Equality delete. 삭제할 행의 키값만 기록한다. “이 PK를 가진 행을 지워라"는 뜻이다. 여러 데이터 파일에 걸쳐 있어도 delete file 하나로 표현할 수 있다. 대신 읽기 시 키 매칭 비용이 든다.

Position delete. 특정 데이터 파일의 특정 위치(행 번호)를 기록한다. “A.parquet 파일의 42번째 행을 지워라"는 식이다. 읽을 때 정확히 그 위치만 건너뛰면 되니까 equality delete보다 읽기 성능이 좋다.

유형기록 내용읽기 비용적합한 경우
Equality delete키 값높음 (키 매칭)PK 기반 대량 삭제
Position delete파일 경로 + 행 번호낮음 (위치 점프)스트림 내 빈번한 업데이트

엔진마다 지원 범위가 다르다
#

Iceberg 스펙에는 두 종류 모두 정의되어 있지만 모든 엔진이 전부 구현한 건 아니다.

엔진Equality delete 읽기Position delete 읽기Equality delete 쓰기Position delete 쓰기
SparkOOXO
FlinkOOO (UPSERT 모드)상황에 따라
TrinoOOXO
AthenaOOXO
Hive/ImpalaOOXO

Spark은 equality delete를 읽을 수는 있지만 쓰지는 못한다. Trino와 Athena도 마찬가지. 행 삭제 시 position delete만 기록한다. UPSERT 모드에서 equality delete 쓰기를 지원하는 건 Flink뿐이다.

이 차이가 컴팩션 전략에 영향을 준다.


Delta Writer. 같은 커밋 안에서 delete 전략이 갈린다
#

CDC 싱크에서 iceberg-core의 BaseTaskWriter가 레코드를 처리하는 로직이 흥미롭다.

public void deleteKey(T key) throws IOException {
    if (!internalPosDelete(asStructLikeKey(key))) {
        eqDeleteWriter.write(key);
    }
}

한 커밋을 만드는 과정에서 삭제 요청이 들어오면 먼저 이번 스트림(커밋)에 포함된 데이터인지 확인한다.

  • 이번 스트림에 있는 레코드 → position delete로 기록
  • 이번 스트림에 없는 레코드 → equality delete로 기록

왜 전부 equality delete로 통일하지 않을까? 읽기 성능 때문이다. position delete가 MOR 읽기에서 훨씬 효율적이라 가능한 한 position delete를 쓰려고 한다. iceberg-core 쓰기 로직에 읽기 성능 최적화가 녹아있는 셈이다.

이 설계가 컴팩션에서 문제를 일으킨다.


스냅샷 타입과 커밋 충돌
#

Iceberg의 네 가지 스냅샷 operation
#

Iceberg 스냅샷에는 operation 필드가 있다. 어떤 종류의 변경이 일어났는지를 나타낸다.

Operation의미대표 작업
append데이터 파일 추가배치 적재, INSERT
replace데이터/삭제 파일 교체컴팩션
overwrite논리적 덮어쓰기업서트, UPDATE, DELETE
delete데이터 파일 제거 또는 삭제 파일 추가DROP PARTITION 등

컴팩션은 replace다. 작은 파일 여러 개를 큰 파일로 합치고 기존 걸 교체한다. CDC 싱크는 overwrite다. 업서트 과정에서 delete file을 만든다.

낙관적 동시성 제어
#

Iceberg는 낙관적 동시성(optimistic concurrency)으로 커밋 충돌을 처리한다. Git이랑 비슷하다.

  1. 현재 스냅샷을 기준으로 새 메타데이터 트리를 만든다
  2. 원자적 커밋(atomic swap)을 시도한다
  3. 그 사이에 다른 커밋이 끼어들었으면 검증(validation) 을 수행한다
  4. 검증 통과하면 커밋 성공. 실패하면 재시도

핵심은 3번의 검증 규칙이다. 어떤 스냅샷 타입끼리 충돌하고 어떤 조합은 자동으로 병합되는가.


Append-only vs CDC. 왜 차이가 나는가
#

Append-only 테이블은 충돌이 거의 없다
#

Append-only 테이블은 데이터를 추가만 한다. delete file이 없다. 컴팩션(replace)이 돌아가는 동안 새 데이터가 들어와도(append) 서로 다른 파일을 건드리니까 자동 병합된다. Git에서 서로 다른 파일을 수정한 두 브랜치가 충돌 없이 머지되는 것과 같다.

CDC 테이블은 position delete가 문제다
#

CDC 테이블은 다르다. 업서트가 일어나면 delete file이 생긴다. 컴팩션이 데이터 파일 A를 새 파일 B로 교체하려는 순간 CDC 싱크가 파일 A에 대한 position delete를 만들어 버리면 어떻게 될까?

시간 순서:
1. 컴팩션 시작: 파일 A를 읽어서 새 파일 B를 만드는 중
2. CDC 싱크: 파일 A의 42번째 행을 삭제 (position delete 생성)
3. 컴팩션 완료: 파일 A → 파일 B 교체 커밋 시도
4. 검증 실패: "파일 A에 대한 새 position delete가 생겼는데 파일 A를 교체하면 그 delete가 유실된다"
ValidationException: Cannot commit, found new position delete for replaced data file

컴팩션 입장에서는 파일 A를 이미 읽어서 새 파일을 만들었는데 그 사이에 파일 A에 대한 삭제가 추가된 거다. 이 삭제를 반영하지 않고 교체하면 데이터 정합성이 깨지니까 Iceberg가 커밋을 거부한다.

Equality delete는 왜 덜 부딪히나
#

Equality delete는 “이 키를 가진 행을 지워라"라는 논리적 선언이다. 특정 파일에 바인딩되지 않는다. 컴팩션이 파일을 교체해도 키 기반 삭제는 새 파일에도 그대로 적용할 수 있다.

실제로 Iceberg에는 컴팩션 시 시작 시점의 sequence-number를 유지하는 메커니즘이 있다(PR #3480). 이걸로 equality delete와의 충돌을 자동 우회한다. 기본 옵션으로 활성화되어 있다.

position delete는 그게 안 된다. 특정 파일의 특정 위치를 가리키기 때문에 파일이 바뀌면 위치도 의미를 잃는다.


커밋 인터벌 딜레마
#

“그럼 싱크 커밋 인터벌을 조정하면 되지 않나?” 싶을 수 있다. 쉽지 않다.

인터벌을 길게 잡으면 한 커밋에 포함되는 레코드가 많아진다. 같은 레코드가 INSERT 후 UPDATE되거나 DELETE되는 확률이 올라간다. delta writer가 이번 스트림 내 레코드를 position delete로 처리하니까 position delete 발생이 늘어난다.

인터벌을 짧게 잡으면 position delete 발생은 줄어들지만 커밋이 자주 일어난다. 컴팩션이 완료되기 전에 새 커밋이 끼어들 확률이 높아진다. position delete가 한 건만 있어도 충돌이 발생한다.

어느 쪽이든 충돌 확률을 0으로 만들 수는 없다.


v2에서의 개선 시도 — 전부 실패했다
#

이 문제를 해결하려는 PR이 여러 개 올라왔지만 전부 머지되지 못하고 닫혔다.

PR접근 방식결과
#4703컴팩션 검증 시 position delete를 선택적 무시리뷰어들이 “위험하다” 판단, 닫힘
#4748Flink upsert에서 position delete와 데이터 파일의 sequence number가 같다는 점을 이용닫힘
#5760manifest entry에 min-data-sequence-number 필드를 추가해 비충돌 delete를 필터링stale bot이 닫음
#7249position-deletes-within-commit-only 스냅샷 프로퍼티로 같은 커밋 내 position delete 선언stale bot이 닫음

결국 v2 안에서는 깔끔한 해법이 안 나왔다. 커뮤니티 방향은 v3에서 구조적으로 해결하는 쪽으로 수렴했다.


Iceberg v3. Deletion Vector가 바꾸는 것
#

v3 스펙은 2025년 초 확정되었고 Iceberg 1.8.0(2025년 2월)부터 구현이 들어가기 시작했다. 핵심 변경은 Deletion Vector(DV) 도입이다.

Position delete file → Deletion Vector
#

v3에서 position delete file은 새로 만들 수 없다. DV가 그 자리를 대신한다.

Position delete files must not be added to v3 tables, but existing position delete files are valid.

DV는 Puffin 파일에 저장되는 Roaring bitmap이다. 데이터 파일 하나당 “몇 번째 행이 삭제되었는지"를 비트맵으로 표현한다.

항목v2 Position deletev3 Deletion Vector
저장 형식Parquet (파일 경로 + 행 번호 컬럼)Puffin (Roaring bitmap)
데이터 파일당 수무제한 (N개 누적 가능)최대 1개
새 삭제 발생 시별도 delete file 추가기존 DV를 읽어서 병합 후 교체

왜 컴팩션 충돌이 줄어드는가
#

v2에서 충돌이 나는 이유는 position delete file이 데이터 파일과 독립적으로 존재하기 때문이었다. 컴팩션이 데이터 파일을 교체하는 동안 CDC 싱크가 같은 파일에 대한 새 position delete file을 만들면 교체 후에 그 delete가 가리키는 파일이 사라진다.

DV는 구조가 다르다.

  1. 데이터 파일에 종속된다. DV는 데이터 파일의 sidecar다. 컴팩션이 데이터 파일을 새로 쓰면 DV 삭제분도 함께 반영되고 기존 DV는 제거된다.
  2. 독립적으로 누적되지 않는다. 데이터 파일 하나에 DV는 최대 하나다. 새 삭제가 들어오면 기존 DV를 읽어서 병합한 뒤 교체한다. v2처럼 delete file이 쌓이면서 “파일 A에 대한 새 position delete” 문제가 생길 여지가 줄어든다.
  3. 컴팩션 빈도 자체가 줄어든다. DV는 compact한 비트맵이라 v2 position delete file처럼 소파일 문제가 없다. 컴팩션을 덜 돌려도 되니까 충돌 윈도우도 줄어든다.

Row Lineage와 행 수준 충돌 검출
#

v3는 DV 외에 Row Lineage도 도입한다. 모든 행에 고유 _row_id_last_updated_sequence_number가 부여된다. DV + Row Lineage를 결합하면 행 수준(row-level) 충돌 검출이 가능해진다(Issue #14613).

v2에서는 같은 데이터 파일을 건드리면 무조건 충돌이었다. v3에서는 같은 파일이라도 서로 다른 행을 수정했으면 자동 병합할 수 있다. CDC 싱크가 42번째 행을 삭제하고 컴팩션이 다른 행을 정리하는 상황이라면 충돌 없이 커밋이 가능해진다.

아직 완벽하지는 않다
#

OCC(낙관적 동시성)는 여전히 적용된다. 두 writer가 같은 데이터 파일의 DV를 동시에 갱신하면 한쪽은 재시도해야 한다. 그래도 v2와는 다른 점이 있다.

  • 재시도 비용이 낮다. 비트맵 병합만 다시 하면 된다. 데이터를 처음부터 스캔할 필요가 없다.
  • 충돌 범위가 좁다. 파일 단위가 아니라 DV 단위다.
  • Row lineage 기반 행 수준 충돌 검출이 엔진에 구현되면 같은 파일 내 다른 행 수정은 충돌에서 제외된다.

엔진 지원 현황 (2025년 기준)
#

엔진v3 DV 지원
Spark (Iceberg 1.8.0+)지원
AWS EMR / Athena / Glue2025년 11월 발표, 지원
Databricks지원 (row-level concurrency 포함)
Trino (Starburst)지원 추가 중
Flink구현 진행 중

v3 마이그레이션은 기존 v2 테이블에서 ALTER TABLE ... SET TBLPROPERTIES ('format-version' = '3')로 전환할 수 있다. 기존 position delete file은 유효하게 유지되며 새 delete부터 DV로 기록된다.


현재 시점의 운영 우회책. CDC를 잠시 멈추고 컴팩션하기
#

현실적으로 가장 안정적인 방법은 컴팩션 윈도우 동안 CDC 싱크를 일시 중단하는 것이다.

컴팩션 파이프라인:

1. 새벽 저부하 시간대에 CDC 싱크 커넥터를 pause
2. 컴팩션 실행 (rewrite_data_files)
3. 컴팩션 완료 후 CDC 싱크 재개

카카오 테크 블로그에서도 비슷한 운영을 시사하고 있다. 12시간 간격으로 실시간 CDC 싱크를 중단하고 컴팩션을 돌리는 구조다.

주의사항
#

  • 컴팩션 시간이 예상보다 길 수 있다. 하루치 CDC 데이터가 쌓인 테이블을 컴팩션하면 생각보다 오래 걸린다. 윈도우 길이를 실측으로 결정해야 한다.
  • 파티션별 분할 실행을 고려하라. 전체 테이블을 한 번에 컴팩션하지 말고 파티션 단위로 나눠서 실행하면 시간을 줄일 수 있다.
  • delete file 정리도 별도로 해야 한다. rewrite_position_delete_files로 delete 소파일을 정리하는 minor compaction도 주기적으로 돌려야 한다. 단 버전별 버그가 보고되어 있으니 호환성을 확인하라.

운영 체크리스트
#

  1. 런타임 버전 확인. 사용 중인 EMR/Spark/Flink/Trino 버전에서 rewrite_data_files 검증 규칙과 rewrite_position_delete_files 지원 여부를 확인한다
  2. 충돌률 모니터링. CDC 커밋 주기 대비 컴팩션 수행 시간을 실측하고 충돌 빈도를 추적한다
  3. 메타데이터 대시보드. 스냅샷/매니페스트 테이블에서 파일 수, 평균 크기, delete file 누적량을 시각화한다
  4. 컴팩션 윈도우 확보. 야간 CDC pause 또는 파티션별 분할 컴팩션 전략을 수립한다
  5. 커뮤니티 패치 추적. PR #4703, #7249 같은 개선안 반영 여부를 버전별로 점검한다

마치며
#

정리하면 이렇다.

  • Iceberg v2 MOR 경로에서 position delete는 읽기 성능을 위한 최적화
  • 그런데 position delete는 특정 파일에 바인딩되어 있어서 컴팩션(replace)과 충돌한다
  • Equality delete는 sequence-number 메커니즘으로 자동 우회되지만 position delete는 안 된다
  • v2 안에서 이 문제를 해결하려는 PR은 전부 머지되지 못했다
  • v3 Deletion Vector가 구조적 해결책이다. Position delete file 대신 데이터 파일당 하나의 비트맵으로 삭제를 관리하고 Row Lineage로 행 수준 충돌 검출까지 가능해진다
  • v3 전환 전까지는 컴팩션 윈도우 동안 CDC를 잠시 멈추는 게 현실적인 답이다

v3 스펙은 확정되었고 엔진 지원도 빠르게 확대되고 있다. v3로 전환하면 이 글에서 다룬 운영 부담 대부분이 사라진다. 아직 v2를 쓰고 있다면 v3 마이그레이션 계획을 세우는 게 장기적으로 맞다.

참고 자료: