Iceberg 테이블에 UPDATE · DELETE · MERGE INTO 를 걸었을 때 "지원하지 않는다"는 오류가 나거나, 갱신은 되는데 조회가 느려지는 일이 자주 생긴다. 원인은 대부분 두 가지다 — 테이블의 포맷 버전이 행 단위 삭제를 지원하지 않거나, 쓰기 모드(merge-on-read / copy-on-write)가 워크로드와 맞지 않는 것이다.
Iceberg 테이블은 메타데이터 스펙 버전(format-version)을 갖는다. 버전이 올라갈 때마다 저장 형식 자체가 확장되므로, 어떤 기능을 쓸 수 있는지가 이 값으로 결정된다.
| 버전 | 행 단위 삭제 | 비고 |
|---|---|---|
| 1 | 불가. append 와 전체 파일 재작성만 가능 | 초기 스펙 |
| 2 | 가능. position delete · equality delete 파일 도입 | UPDATE · DELETE · MERGE INTO 의 전제 |
| 3 | 가능. deletion vector 로 delete 파일을 대체, row lineage · 새 타입 추가 | 비교적 최근 스펙이라 엔진 지원 범위를 먼저 확인해야 한다 (확인 필요) |
현재 값은 테이블 속성으로 확인한다.
SHOW TBLPROPERTIES my_db.my_table;
올리는 것은 ALTER TABLE 한 줄이다.
ALTER TABLE my_db.my_table SET TBLPROPERTIES ('format-version'='2');
되돌릴 수 없다. 낮은 버전만 읽을 수 있는 구형 엔진이 같은 테이블을 붙고 있다면 그 엔진은 테이블을 읽지 못하게 된다. 클러스터에 붙어 있는 엔진 버전을 먼저 훑고 올린다.
포맷 버전 2 이상이면 갱신을 어떻게 기록할지 고를 수 있다.
연산별로 따로 정한다.
ALTER TABLE my_db.my_table SET TBLPROPERTIES (
'write.delete.mode'='merge-on-read',
'write.update.mode'='merge-on-read',
'write.merge.mode'='merge-on-read'
);
write.merge.mode 는 MERGE INTO 전용이라 빼먹기 쉽다. 세 개를 같이 적지 않으면 연산에 따라 동작이 갈린다.
delete 파일의 형식은 write.delete.format.default 로 정한다. 흔히 쓰이는 delete.file.format 이라는 속성은 없다.
ALTER TABLE my_db.my_table SET TBLPROPERTIES ('write.delete.format.default'='parquet');
읽기 쪽에 iceberg.read.merge-mode 같은 세션 설정은 없다. merge-on-read 로 느려진 조회는 설정으로 되돌리는 것이 아니라 컴팩션으로 delete 파일을 걷어내서 회복한다.
CALL my_catalog.system.rewrite_data_files(table => 'my_db.my_table');
수집이 잦아 delete 파일이 계속 쌓이는 테이블은 merge-on-read 로 두고 컴팩션을 주기 작업으로 돌린다. 하루 한두 번 배치로만 바뀌고 조회가 많은 테이블은 copy-on-write 가 단순하다.
문법 자체는 SQL 표준의 MERGE INTO 다.
MERGE INTO my_db.customer t
USING staging_updates s
ON t.customer_id = s.customer_id
WHEN MATCHED THEN UPDATE SET t.name = s.name, t.email = s.email
WHEN NOT MATCHED THEN INSERT (customer_id, name, email) VALUES (s.customer_id, s.name, s.email);
지원 여부는 엔진과 버전을 탄다. "Trino 는 MERGE 를 못 쓴다" · "Impala 는 Iceberg DML 이 안 된다" 같은 오래된 설명이 아직도 돌아다니는데, 둘 다 지금은 맞지 않는다.
| 엔진 | INSERT |
UPDATE · DELETE |
MERGE INTO |
|---|---|---|---|
| Spark (Iceberg runtime) | 가능 | 가능 | 가능 |
| Flink | 가능 | 가능 | 가능 |
| Trino | 가능 | 가능 | 지원한다. 도입된 정확한 릴리스는 확인 필요 |
| Impala | 가능 | 가능 | 비교적 최근 버전부터 지원. 쓰려는 버전에서 확인 필요 |
쓰려는 엔진에서 MERGE INTO 가 막혀 있으면 스테이징 테이블을 두고 삭제 후 삽입으로 대신한다. 트랜잭션이 하나로 묶이지 않으므로 중간에 조회가 들어오면 빠진 데이터를 볼 수 있다는 점은 감안해야 한다.
CREATE TABLE my_db.temp_updates LIKE my_db.main_table;
INSERT INTO my_db.temp_updates SELECT * FROM source;
DELETE FROM my_db.main_table WHERE id IN (SELECT id FROM my_db.temp_updates);
INSERT INTO my_db.main_table SELECT * FROM my_db.temp_updates;
원본이 이미 파일로 있으면 굳이 행 단위로 넣지 않는다. 엔진이 읽을 수 있는 형식이면 INSERT ... SELECT 한 문장이 가장 단순하다.
INSERT INTO my_catalog.my_db.my_table
SELECT * FROM parquet.`s3a://bucket/path/to/parquet/`;
Spark DataFrame 에서는 writeTo 를 쓴다.
df = spark.read.parquet("s3a://bucket/path/to/parquet/")
df.writeTo("my_catalog.my_db.my_table").append()
파티션 컬럼은 따로 지정하지 않는다. 대상 테이블의 파티션 스펙에 맞춰 Iceberg 가 배치한다. 파티션 기준 컬럼으로 미리 정렬해 두면 파일 수가 줄어 이후 조회가 빨라진다.
REST 카탈로그를 쓰고 있어도 적재는 된다. REST 카탈로그는 메타데이터 커밋 경로일 뿐이라 "읽기 전용"이 아니다.
무엇을 고르든 커밋 절차는 같다.
4단계가 원자적이라 중간 상태가 조회에 보이지 않는다. 여러 작업이 같은 테이블을 동시에 커밋하면 포인터 교체에서 충돌을 감지하고 재시도한다. 그래서 같은 파티션을 동시에 갱신하는 작업이 많으면 커밋 재시도로 시간이 길어진다 — 동시성이 높은 테이블은 갱신 범위를 파티션 단위로 갈라 놓는 편이 낫다.