LabHub
시작하기
배우기 러닝패스 코스

레이크하우스 표 형식 — Apache Iceberg 를 metadata 로 이해한다

두 작가가 같은 표에 동시에 커밋한다 — 누가 이기고, 무엇이 남고, 무엇을 잃나

LabHub 에서 이어서 보기

목표

pyiceberg 로 두 작가가 같은 metadata 를 읽은 뒤 차례로 커밋하는 경쟁을 결정적으로 재현한다. 추가(append)는 자동 재시도로 둘 다 들어가고, 재시도를 끄면 한쪽이 실패하며 쓰다 만 파일이 고아로 남는 것을 본다. 조건부 덮어쓰기(overwrite)는 재시도해도 검증에 걸리고, 그 뒤 새로 읽고 다시 계산해야만 잃어버린 갱신이 생기지 않는다는 것을 카운터 하나로 확인한다.

왜 중요한가

Iceberg 에는 잠금이 없다. 작가는 각자 파일을 다 쓰고 새 metadata 를 만든 다음, 카탈로그에서 '내가 읽은 main 이 아직 그대로면 바꾼다' 는 조건부 교체를 시도한다. 둘이 동시에 오면 하나만 이기고, 진 쪽은 새 metadata 를 다시 읽어 자기 변경을 다시 얹는다. 이것이 낙관적 동시성이다 — 대부분은 부딪히지 않는다고 믿고, 부딪히면 다시 한다. 문제는 '다시 얹어도 되는 변경' 과 '그러면 안 되는 변경' 이 있다는 것이다. 새 파일을 더하는 것은 사이에 누가 무엇을 했든 다시 얹어도 된다. 그런데 '이 조건에 맞는 행을 이 값으로 바꾼다' 는 변경은 그 사이 누군가 같은 조건의 행을 바꿨다면 틀린 결과가 된다. 라이브러리는 파일 수준에서 그것을 검증해 거절해 주지만, 여러분의 코드가 예전에 읽은 값으로 다시 계산하지 않고 그대로 다시 쓰면 아무도 막아 주지 못한다.

단계

  1. /root/ice/conc/common.py 에 하루치 CSV 를 pyarrow 로 읽는 도우미를 두고(order_ts 는 UTC 시간대), /root/ice/conc/setup.pylake.conc.orders 를 만들어 2026-03-01 을 넣으세요.
  2. /root/ice/conc/race.py 로 두 Table 객체 a·b 를 먼저 둘 다 읽은 뒤 a 가 03-02 를, b 가 03-03 을 추가하게 하고 결과를 /root/ice/conc/out/race.json 에 쓰세요.
  3. /root/ice/conc/noretry.py 로 표 속성 commit.retry.num-retries0 으로 두고 같은 경쟁(03-04 · 03-05)을 다시 한 뒤, 진 쪽의 예외 이름을 /root/ice/conc/out/noretry.txt 에 쓰세요.
  4. /root/ice/conc/orphans.py 로 표 위치의 data 디렉터리에 있지만 어느 스냅샷도 가리키지 않는 Parquet 파일을 찾아 /root/ice/conc/out/orphans.json 에 쓰세요.
  5. /root/ice/conc/conflict.py 로 카운터 표 lake.conc.counters(hits = 10)를 만들고, 두 작업자가 같은 값을 읽은 뒤 각자 +5 를 덮어쓰게 해 진 쪽의 예외 이름을 /root/ice/conc/out/conflict.txt 에 쓰세요.
  6. /root/ice/conc/retry.py 로 진 쪽의 +5 를 올바르게 다시 해서 카운터를 20 으로 만드세요.
  7. /root/ice/conc/report.md## 자동 재시도 ## 실패한 커밋의 흔적 ## 잃어버린 갱신 세 절을 쓰세요.

참고

파이썬으로 만든 표

/root/ice/conc/common.pyday("YYYY-MM-DD") 가 그날 CSV 를 pyarrow 표로 돌려주게 하고(amount int32, order_ts 는 UTC 시간대의 timestamp), /root/ice/conc/setup.pylake.conc.ordersformat-version 2 로 만들어 2026-03-01 을 넣으세요.

pyiceberg 의 create_table(이름, schema=pyarrow_스키마) 가 열마다 필드 ID 를 매깁니다. 시각에 시간대를 붙이면 Spark 가 만드는 표와 같은 timestamptz 가 됩니다. 채점기는 첫 스냅샷이 3월 1일 행 수를 더했는지 봅니다.

경쟁 — 추가는 다시 얹으면 된다

/root/ice/conc/race.py 에서 a = load_table(…), b = load_table(…)둘 다 먼저 만든 뒤 a.append(03-02), b.append(03-03) 순서로 커밋하고, 스냅샷 수와 행 수를 /root/ice/conc/out/race.json{"snapshots", "rows"} 로 쓰세요.

b 는 a 의 커밋 전 metadata 를 들고 있어 첫 커밋 시도가 조건에 걸립니다. 추가는 사이에 무엇이 들어왔든 그 위에 다시 얹어도 되므로 pyiceberg 가 새로 읽고 다시 시도해 성공합니다. 채점기는 스냅샷 셋이 한 줄로 이어졌는지(갈라지지 않았는지)와 행 수를 봅니다.

재시도를 끄면 진 쪽은 실패한다

/root/ice/conc/noretry.pylake.conc.orders 의 속성 commit.retry.num-retries"0" 으로 바꾼 뒤, 2단계처럼 a·b 를 먼저 만들고 a 가 03-04, b 가 03-05 를 추가하게 하세요. b 의 예외 이름을 /root/ice/conc/out/noretry.txt 첫 줄에 쓰세요.

재시도가 0 이면 조건부 교체에 진 순간 예외가 올라옵니다. 그런데 b 는 커밋을 시도하기 전에 데이터 파일을 이미 다 썼습니다. 채점기는 예외 이름, 속성 값, 3월 4일은 들어가고 3월 5일은 표에 없는지 봅니다.

실패한 커밋이 남긴 파일

/root/ice/conc/orphans.py 로 모든 스냅샷이 가리키는 파일 목록과 표 위치(t.location()) 아래 data 디렉터리의 실제 Parquet 파일을 견주어, 목록에 없는 파일(고아)의 경로를 /root/ice/conc/out/orphans.json{"orphans": [경로, …]} 로 쓰세요.

고아 파일은 표의 일부가 아니라서 읽히지는 않지만 공간을 차지합니다. 지금 스냅샷이 아니라 모든 스냅샷이 가리키는 파일과 견줘야 합니다 — 옛 스냅샷의 파일은 타임트래블용으로 살아 있는 파일입니다. 이런 파일을 치우는 것이 다음 모듈의 remove_orphan_files 입니다.

덮어쓰기는 검증에 걸린다

/root/ice/conc/conflict.pylake.conc.counters(name STRING, value BIGINT)hits = 10 한 줄로 (있으면 지우고) 새로 만들고, a·b 가 둘 다 값을 읽은 뒤 a읽은 값 + 5overwrite(…, overwrite_filter=EqualTo("name", "hits")) 로 쓰고 b 도 같은 식으로 쓰게 하세요. b 의 예외 이름을 /root/ice/conc/out/conflict.txt 첫 줄에 쓰세요.

b 의 첫 시도는 조건부 교체에 지고, 재시도에서는 '그 사이 내 조건(name = hits)에 맞는 파일이 새로 들어왔다' 는 검증에 걸려 멈춥니다. 추가와 달리 덮어쓰기는 그대로 다시 얹으면 a 의 결과를 지워 버리기 때문입니다. 채점기는 예외 이름과, 카운터가 한때 15 였던 스냅샷이 있는지 봅니다.

새로 읽고 다시 계산한다

/root/ice/conc/retry.py 로 b 의 +5 를 다시 하세요. 매 시도마다 표를 새로 읽고, 그때 읽은 값에 5 를 더해 조건부 덮어쓰기를 시도하고, 실패하면 처음부터 다시 합니다. 끝나면 hits20 이어야 합니다.

라이브러리의 재시도는 커밋만 다시 얹을 뿐, 여러분이 예전에 읽은 값을 다시 읽어 주지 않습니다. 예전에 읽은 10 으로 계산한 15 를 그대로 다시 쓰면 커밋은 성공하고 a 의 +5 가 사라집니다 — 잃어버린 갱신입니다. 채점기는 카운터가 정확히 20 인지 봅니다.

동시 쓰기 규칙을 팀 규칙으로

/root/ice/conc/report.md## 자동 재시도 ## 실패한 커밋의 흔적 ## 잃어버린 갱신 세 절을 쓰세요. 둘째 절에는 4단계에서 찾은 고아 파일 수를, 셋째 절에는 최종 카운터 값을 숫자로 넣으세요.

같은 표에 쓰는 잡이 둘 이상이라면 어떤 잡은 재시도에 맡겨도 되고 어떤 잡은 읽기부터 다시 해야 하는지, 실패한 잡의 파일은 누가 언제 치우는지를 적어 보세요.