Lakehouse Table Format — Understanding Apache Iceberg Through Its Metadata
Writing concurrently without locks — what optimistic concurrency protects, and what it doesn't
한국어 원문으로 표시합니다.
한 줄 요약
Iceberg 의 작가는 잠금 없이 각자 새 metadata 를 만든 뒤 카탈로그에서 조건부 교체를 시도하고, 지면 새 상태를 읽어 다시 얹는다. 추가처럼 늘 다시 얹어도 되는 변경은 자동으로 풀리고, 덮어쓰기처럼 그 사이의 변경과 겹칠 수 있는 것은 검증에 걸려 멈춘다. 하지만 여러분 코드가 예전에 읽은 값으로 계산한 결과는 누구도 검증해 주지 않는다.
왜 잠금을 쓰지 않나
표 하나에 쓰는 잡은 보통 여럿이다. 스트리밍 적재가 몇 분마다 추가하고, 야간 배치가 어제 파티션을 고치고, 정리 작업이 작은 파일을 압축한다. 표 전체에 잠금을 걸면 가장 느린 잡이 모두를 세운다. 게다가 객체 저장소 위에는 믿을 만한 분산 잠금이 없다.
스펙의 낙관적 동시성 절은 다른 길을 택한다. 작가는 자기 커밋 전까지 current 가 바뀌지 않을 것이라 가정하고 metadata 를 만든 뒤, 바탕 판에서 새 판으로 포인터를 바꾼다. 바탕 스냅샷이 더는 current 가 아니면 새 current 를 바탕으로 다시 한다. 읽는 쪽은 자기가 연 스냅샷을 끝까지 보므로 잠글 필요가 없다.
어떻게 동작하나 — 가정과 행동
Reliability 문서는 커밋을 가정과 행동으로 설명한다. 충돌이 나면 작가는 가정이 지금 상태에서도 맞는지 확인하고, 맞으면 행동을 다시 적용해 커밋한다. 스펙의 충돌 해결 절이 연산마다 그 가정을 정한다.
| 연산 | 다시 얹기 전에 확인하는 것 |
|---|---|
| append | 없음 — 늘 다시 얹을 수 있다 |
| replace(압축 등) | 지우려던 파일이 아직 표에 있는가 |
| delete(특정 파일) | 지우려던 파일이 아직 표에 있는가 |
| 스키마·스펙 변경 | 그 사이 스키마가 바뀌지 않았는가 |
추가가 늘 다시 얹힐 수 있는 이유는 '새 파일을 더한다' 는 행동이 그 사이 무엇이 들어왔든 여전히 옳기 때문이다. 문서는 재시도 비용도 줄여 두었다고 적는다 — 추가는 새 매니페스트를 한 번 써 두고 시도마다 다시 쓰지 않는다.
재시도 횟수와 간격은 표 속성이다. commit.retry.num-retries 기본 4, commit.retry.min-wait-ms 100, commit.retry.max-wait-ms 60000, 전체 제한 commit.retry.total-timeout-ms 30분. 재시도를 다 쓰면 커밋 실패 예외가 올라온다.
격리 수준 — 무엇을 충돌로 볼 것인가
스펙은 '어떤 조건을 검증하느냐가 격리 수준' 이라고 적는다. Spark 의 DELETE·UPDATE·MERGE 는 표 속성 write.delete.isolation-level(update·merge 도 같은 이름)으로 고르고 기본은 serializable 이다. serializable 은 내가 읽고 고친 범위에 그 사이 누군가 추가하거나 지운 것이 있으면 실패하고, snapshot 은 지운 것만 충돌로 본다. Spark 쓰기 옵션의 DataFrame 덮어쓰기에도 같은 두 단계가 있다. 느슨할수록 덜 실패하지만, 그 사이 들어온 행을 모른 채 덮어쓸 수 있다.
라이브러리가 지켜 주지 않는 것
카운터를 생각해 보자. 두 작업자가 hits = 10 을 읽고 각자 +5 를 해서 15 를 덮어쓴다. 앞선 쪽이 이긴다. 진 쪽은 조건부 교체에서 지고, 재시도는 '내가 덮어쓰려던 조건(name = hits)에 맞는 파일이 그 사이 들어왔다' 는 검증에 걸려 멈춘다. 여기까지는 라이브러리가 해 준다.
문제는 그다음이다. 예외를 잡고 예전에 계산한 15 를 그대로 다시 쓰면 이번에는 커밋이 성공한다. 표는 15 이고 한쪽의 +5 가 사라졌다 — 잃어버린 갱신이다. 라이브러리는 파일 수준의 가정을 검증할 뿐, 여러분의 값이 어떤 읽기에서 나왔는지는 모른다. 올바른 재시도는 새로 읽고, 다시 계산하고, 그 읽기를 전제로 다시 커밋하는 것이다.
현장에서 만나는 모습
압축과 적재가 부딪힌다. 압축(replace)은 지우려던 파일이 아직 있으면 적재가 끼어들어도 다시 얹힌다. 반대로 MERGE 가 같은 파일을 고쳤다면 압축이 실패하고, 다음 주기에 다시 하면 된다.
실패한 커밋의 흔적. 커밋에 진 작가가 이미 써 둔 데이터 파일은 어느 스냅샷에도 들어가지 않은 채 남는다. 표에는 영향이 없지만 공간을 차지하고, 이런 파일을 치우는 것이 고아 파일 정리다.
재시도 설정을 0 으로. 한 표에 쓰는 잡이 딱 하나라고 믿고 재시도를 끄면, 가끔 도는 정리 작업과 부딪히는 날 잡이 이유 없이 실패한다.
실무에서 진짜 중요한 것
- 추가는 경쟁해도 둘 다 들어간다. 재시도가 새 상태 위에 다시 얹는다.
- 덮어쓰기·삭제·MERGE 는 검증에 걸릴 수 있다. 기본 격리 수준은 serializable 이다.
- 읽고-계산하고-쓰는 코드는 재시도 때 다시 읽어야 한다. 예전 값으로 다시 쓰면 잃어버린 갱신이다.
- 실패한 커밋은 고아 파일을 남긴다. 정리 작업이 필요하다.
다음 실습에서 할 것
pyiceberg 로 두 작가를 한 프로세스 안의 두 Table 객체로 만들어 같은 metadata 를 읽게 한 뒤 차례로 추가해, 진 쪽이 자동으로 다시 얹혀 스냅샷이 한 줄로 이어지는 것을 본다. 재시도를 0 으로 꺼 같은 경쟁을 다시 하고 예외 이름과 진 쪽이 남긴 고아 파일을 찾는다. 마지막으로 카운터 하나를 두고 덮어쓰기를 경쟁시켜 검증 예외를 보고, 새로 읽고 다시 계산해 카운터가 정확히 20 이 되게 한다.