4932 words
25 minutes
[Iceberg]파티션이 다른데 왜 커밋이 충돌할까

02. 동시 쓰기와 단일 커밋 지점#

01. 도입 및 배경#

다루는 주제#

여러 작업(writer)이 하나의 Iceberg 테이블에 동시에 쓸 때 발생하는 커밋 충돌 CommitFailedException을 다룬다. 목적은 두 가지다.

  • 쓰는 파티션이 서로 겹치지 않아도 충돌하는지 그 메커니즘을 이해한다.
  • 해결책 4가지를 놓고 각각 무엇을 포기하는지 비교해 선택 기준을 세운다.

사전 지식#

Iceberg는 데이터 파일을 직접 수정하지 않는다. 새 파일을 쓴 뒤, **“지금 유효한 파일 목록은 이것”**이라고 가리키는 스냅샷 포인터를 옮기는 것이 커밋이다.

여기서는 “포인터”가 단수라는 사실이 만드는 결과를 파고든다.

상황적 가정#

  • 카탈로그는 Hive Metastore, 클라이언트는 PyIceberg를 가정한다. (Glue·REST 카탈로그도 낙관적 커밋이라는 점은 같다 — 6장에서 다시 언급)
  • 오케스트레이터는 Airflow이고, 동적 태스크 매핑(.expand())으로 추출을 병렬화한다.
  • 코드는 개념을 보이기 위한 최소 형태다. 예외 처리와 로깅은 생략했다.

02. 시스템 요구사항 및 시나리오#

외부 API에서 주문 데이터를 받아 Bronze 레이어에 적재하는 파이프라인을 만든다고 하자.

요구사항#

구분내용
처리량하루 윈도우 30일치를 한 번의 백필로 적재할 수 있어야 한다
병렬성API 추출이 느리므로 여러 작업을 동시에 돌려야 한다
멱등성같은 윈도우를 다시 돌려도 결과가 같아야 한다 (재실행 안전)
정합성태스크가 성공했으면 데이터가 실제로 들어가 있어야 한다

설계#

요구사항을 그대로 옮기면 이런 그림이 나온다.

@task
def extract_window(window: str):
rows = api.fetch(window) # 외부 API 추출
table = catalog.load_table("bronze.orders")
for day in window.days(): # 하루 단위로 끊어서
table.overwrite( # 파티션 overwrite = 멱등
rows.filter(day),
overwrite_filter=f"order_date = '{day}'",
)
# 동시 4개까지 병렬 실행
extract_window.partial(max_active_tis_per_dag=4).expand(window=windows)

설계 의도는 합리적이다.

  • 파티션 단위 overwrite → 같은 날짜를 다시 쓰면 덮어쓰므로 멱등성 확보
  • 동시 4개 → 느린 API 추출을 병렬화해 처리량 확보
  • 각 작업이 담당하는 날짜 파티션은 서로 겹치지 않음 → 안전해 보임

기본적으로 파티션이 안 겹치니 충돌할 리 없다는 가정.


03. 기존 방식의 한계#

3-1. 증상: 추출은 성공하는데 커밋만 실패#

백필을 돌리면 이런 로그가 나온다.

WARN Wait on lock for bronze.orders ... (수 초간 반복)
ERROR CommitFailedException: Requirement failed:
branch main has changed: expected id A, found B

번역하면 **“당신이 시작할 때 본 테이블과 지금 테이블이 다릅니다”**이다.

중요한 건 실패 지점이다. API 호출도, 파일 쓰기도 전부 끝났다. 마지막 한 걸음인 스냅샷 등록에서만 터진다. 그리고 실패한 작업은 자기가 맡은 윈도우를 통째로 날린다.

3-2. 원인: 포인터는 테이블당 하나뿐이다#

여러 명이 같은 위키 문서를 편집할 때 뜨는 그 메시지와 구조가 같다.

“다른 사람이 먼저 수정했습니다. 새로고침 후 다시 저장하세요.”

위키의 동작은 이렇다.

  1. 문서를 연다 → 지금이 7판임을 기억한다
  2. 한참 글을 쓴다
  3. 저장 → “아직도 7판인가?” 확인 → 맞으면 8판으로 저장, 아니면 거절

여기서 내가 몇 장을 고쳤는지는 아무 상관이 없다. 3장을 고쳤든 9장을 고쳤든, 판 번호가 하나라서 충돌한다.

Iceberg 테이블에는 완전히 다른 두 층이 있고, 이 둘을 섞으면 문제가 이해되지 않는다.

정체개수동시에 써도 되나
데이터 파일실제 데이터가 든 Parquet파티션마다 따로된다. 여기선 경합이 없다
스냅샷 포인터”지금 유효한 파일 목록은 이것”테이블당 딱 1개안 된다. 여기가 전부다

위키의 판 번호가 곧 스냅샷 포인터다. 커밋은 이 순서로 흐른다.

1. load_table() → 지금 포인터가 A 임을 기억
2. 데이터 파일 쓰기 → 여기까진 진짜 병렬. 경합 없음
3. 포인터 이동 → "아직도 A?" 맞으면 성공, 아니면 CommitFailedException

1번과 3번 사이에 남이 커밋하면 나는 죽는다. 파티션이 겹치는지는 처음부터 무관한 정보였다.

3-3. “테이블 락이 있는데 왜 충돌하죠?”#

가장 많이 나오는 질문이다. Hive Metastore는 커밋할 때 테이블 단위 락을 잡는다. 한 번에 하나씩만 커밋하는데도 충돌한다. 이유는 이렇다.

락은 3번만 감싼다. 1번은 락 밖이다.

[1. 포인터 읽기] ← 락 없음. 여기서 A 를 기억
(락 대기줄에 섬) ← 기다리는 동안 남이 커밋하면 내 A 는 낡는다
[3. 락 잡고 확인] ← "아직 A?" → 아니오 → 실패

줄 서 있는 4명 중 1등만 성공하고 나머지 3명은 전부 실패한다. 락은 커밋을 안전하게 만들 뿐 충돌을 막지 않는다. 오히려 대기줄이 길수록 낡은 번호를 든 writer가 늘어난다.

3-4. 장애 시 정합성 문제: 재시도가 만드는 구멍#

“충돌하면 재시도하면 되지”가 첫 대응이다. 하지만 재시도에는 상한이 있고, 상한을 넘으면 태스크는 실패한다. 여기서 두 가지 정합성 문제가 생긴다.

(1) 부분 적재. 한 작업이 30일치를 하루씩 커밋하다가 17일째에 재시도를 소진하면, 16일치는 커밋되고 14일치는 안 된 상태로 끝난다. Bronze는 절반만 채워진다.

(2) 조용한 유실. 실패를 DLQ에 넣고 파이프라인을 계속 진행시키면, 하류 입장에서는 그냥 데이터가 적은 것으로 보인다. 에러가 아니라 숫자가 작아지는 형태라 알아채기 어렵다.

커밋 충돌 실패는 성능 지표가 아니라 데이터 유실 지표로 다뤄야 한다.

3-5. 장기적 문제: 데이터가 늘면 더 나빠진다#

이 설계는 데이터가 늘수록 비선형으로 악화된다.

첫째, 충돌 기회는 곱으로 늘어난다.

충돌 기회 = (동시 writer 수) × (writer 하나가 커밋하는 횟수)

사람들은 보통 앞의 항만 본다. “동시에 4개니까 4번 부딪히겠네.” 하지만 각 작업이 30일치를 하루씩 커밋하면 4 × 30 = 120번이다. 30배를 놓친다. 기간이 늘면(1개월 → 3개월) 두 번째 항이 그대로 3배가 된다.

둘째, 같은 테이블을 쓰는 DAG가 늘어난다. 내 DAG만 봐서는 writer 수를 셀 수 없다. 시간이 지나며 다른 파이프라인이 같은 Bronze 테이블에 붙으면 경합은 조용히 커진다.

셋째 — 그리고 가장 반직관적인 것 — 동시성을 올리면 오히려 느려진다.

병목이 커밋 지점이므로, writer를 늘려도 줄이 길어질 뿐 처리량은 늘지 않는다. 오히려 충돌과 재시도가 늘어 전체 시간이 증가한다. 실제 측정치는 6장에 있다.


04. 몇가지 대안들#

대안은 넷이다. 위쪽일수록 싸고 약하며, 아래로 갈수록 비싸고 확실하다.

대안 A. refresh + 백오프 재시도#

충돌하면 잠깐 쉬고, 포인터를 다시 읽고, 다시 커밋한다. 구조를 바꾸지 않는 가장 값싼 방어.

[W1] [W2] [W3] [W4] ─── 각자 커밋 시도
↓ 충돌
쉬었다 재시도 (포인터 다시 읽고)
  • 성격: 확률적 완화. 충돌을 줄이지만 없애지는 못한다.

대안 B. 커밋 횟수 줄이기#

하루씩 30번 커밋하던 것을, 윈도우 전체를 하나의 트랜잭션으로 묶어 1번 커밋한다. 위 공식의 두 번째 항이 30 → 1이 되므로 경합이 구조적으로 30배 준다.

before: [W1] ─커밋─커밋─커밋─ ... (30회)
after: [W1] ────────── 커밋 (1회)
  • 성격: 구조적 완화. 병렬성은 그대로 두고 경합만 줄인다.

대안 C. 커밋 직렬화 (동시성 1)#

매핑 태스크 동시 실행을 1로 내린다. writer가 하나면 충돌은 구조적으로 불가능하다.

[W1] → [W2] → [W3] → [W4] 한 번에 하나씩
  • 성격: 결정적 제거. 단, 추출까지 같이 직렬화된다.

대안 D. fan-in 단일 committer#

추출은 병렬로 두고, 커밋만 한 태스크가 도맡는다. 병렬성과 안전성을 동시에 얻는 유일한 구조다.

추출 W1 ─┐
추출 W2 ─┤ (병렬, 경합 없음)
추출 W3 ─┼──▶ staging ──▶ [단일 committer] ──▶ 포인터
추출 W4 ─┘ 파티션당 1커밋 충돌 0
  • 성격: 근본 처치. 대신 파이프라인 구조를 바꿔야 한다.

선택 기준#

이런 조건이면선택이유
충돌이 가끔, 재시도가 안전A제일 싸다. 확률적 완화로 충분한 구간
태스크 하나가 커밋을 여러 번 한다B구조 변경 없이 경합을 자릿수로 줄인다
커밋이 전체 시간의 큰 비중C어차피 커밋이 병목이라 잃을 게 적다
추출이 무겁고 구조가 오래 갈 것D유일하게 안전과 속도를 다 갖는다

실무에서는 겹쳐 쓴다. A를 기본 방어로 깔고, 경합이 심한 경로에 C를 얹고, 오래 갈 파이프라인은 D로 옮기는 식이다. A 하나로 끝내려는 것이 가장 흔한 실수다.


05. 핵심 로직#

5-1. 안티패턴#

먼저 흔한 잘못된 재시도부터 보자. 세 가지가 동시에 틀렸다.

table = catalog.load_table("bronze.orders") # 루프 밖에서 한 번만 로딩
for attempt in range(5):
try:
table.overwrite(rows, overwrite_filter=predicate)
return
except Exception: # 모든 예외를 재시도
continue # 쉬는 시간 없음
  • load_table이 루프 밖 → 낡은 포인터를 들고 5번 재시도한다. 5번 다 실패한다.
  • except Exception → 타임아웃처럼 성공했는지 모르는 실패까지 재시도해 중복 적재를 만든다.
  • sleep 없음 → 충돌한 writer들이 곧장 같은 대기줄에 다시 선다.

5-2. 대안 A 구현 — refresh + 백오프 + 예외 좁히기#

위 세 가지를 그대로 뒤집으면 된다.

import random, time
from pyiceberg.exceptions import CommitFailedException
def commit_with_retry(catalog, ident, rows, predicate, max_attempts=5):
for attempt in range(max_attempts):
table = catalog.load_table(ident) # ① 매 시도마다 최신 포인터 재확인
try:
table.overwrite(rows, overwrite_filter=predicate)
return
except CommitFailedException: # ② 이 예외만 재시도
if attempt == max_attempts - 1:
raise # ④ 삼키지 말고 올린다
ceiling = min(10.0, 0.3 * 2**attempt)
time.sleep(random.uniform(0, ceiling)) # ③ 백오프 + 지터

각 줄이 왜 필요한지가 요점이다.

무엇을 막나
① 루프 에서 load_table낡은 포인터로 재시도하는 것
CommitFailedException결과가 모호한 실패를 재시도해 중복 적재하는 것
③ 지수 백오프 + 지터충돌한 writer들이 lock-step으로 다시 부딪히는 것
④ 마지막에 raise유실이 조용히 지나가는 것 (3-4절)

②가 특히 중요하다. 재시도 범위는 넓을수록 안전한 게 아니라 정확할수록 안전하다. CommitFailedException은 “확실히 커밋 안 됨”이라 재시도해도 되지만, 타임아웃은 됐는지 안 됐는지 모르기 때문에 재시도하면 이미 성공한 커밋을 또 적용한다.

5-3. 대안 B 구현 — 트랜잭션으로 커밋 묶기#

PyIceberg의 transaction()은 여러 연산을 하나의 스냅샷으로 커밋한다. 30번 커밋하던 것이 1번이 된다.

table = catalog.load_table("bronze.orders")
with table.transaction() as tx: # 블록 전체가 커밋 1회
for day in window.days():
tx.overwrite(rows.filter(day), overwrite_filter=f"order_date = '{day}'")

여러 커밋을 하나로 합칠 때 빈 입력이 기존 데이터를 지우는 사고가 흔하다. rows.filter(day)가 0건일 때 그 파티션을 통째로 비울 것인지 건너뛸 것인지를 명시적으로 정해야 한다.

5-4. 대안 C 구현 — 동시성 1#

Airflow에서는 설정 한 줄이다.

# 같은 테이블에 커밋하는 leg만 직렬화
extract_window.partial(max_active_tis_per_dag=1).expand(window=windows)

직렬화 범위가 그 태스크에만 걸린다는 점이 중요하다. 다른 테이블에 쓰는 leg는 락이 분리되므로 여전히 병렬로 돈다.

5-5. 대안 D 구현 — fan-in 단일 committer#

추출과 커밋을 다른 태스크로 분리하는 것이 핵심이다.

@task # 병렬 실행 (동시성 제한 없음)
def extract_to_staging(window: str) -> str:
rows = api.fetch(window)
path = f"s3://staging/{window}.parquet"
write_parquet(rows, path) # 커밋 안 함. 파일만 만든다
return path
@task # 단 하나. 커밋 지점은 여기뿐
def commit_all(paths: list[str]):
table = catalog.load_table("bronze.orders")
with table.transaction() as tx: # 파티션당 1커밋
for path in paths:
tx.overwrite(read_parquet(path), overwrite_filter=predicate_of(path))
commit_all(extract_to_staging.expand(window=windows))

병렬도를 줄인 게 아니라 병렬도가 도달하는 지점을 바꾼 것이다. 추출은 그대로 4개가 돌고, 직렬화 지점 앞에 staging을 끼워 넣어 커밋 주체만 1로 만들었다.


06. 장단점 분석#

말로만 하면 감이 안 오니, 3장의 메커니즘(락 밖 포인터 읽기 + 락 안 검증)만 그대로 구현한 시뮬레이터로 측정했다.

아래는 시뮬레이션 측정치다. 절대 시간값은 의미가 없고 전략 간 비교만 의미가 있다. 작업량은 120커밋으로 고정하고 전략만 바꿨다.

6-1. 전략별 비교#

전략충돌재시도 소진 실패총 소요시간
재시도만 (쉬는 시간 없음)2360543
A. 백오프 + 지터943.3401
C. 커밋 직렬화001164
D. fan-in 단일 committer00291
  • 쉬는 시간 없는 재시도는 확실히 나쁘다 (236 → 94). 5-1절 안티패턴 그대로다.
  • 백오프는 충돌을 0으로 못 만든다. 재시도 소진 실패가 3.3건 남는다 → 데이터 유실.
  • 직렬화는 안전하지만 2.9배 느리다 (401 → 1164). 이것이 C의 대가다.
  • fan-in이 가장 빠르면서 충돌도 0이다. 커밋만 줄 세웠기 때문이다.

6-2. 동시성을 올리면 어떻게 되나#

3-5절에서 예고한 반직관적 결과다. 같은 작업량에서 writer 수만 늘렸다.

동시 writer충돌재시도 소진 실패총 소요시간
1001164
210585
4943.3401
1245969.7773

4 → 12로 3배 올렸더니 오히려 느려졌다 (401 → 773). 더 중요한 건 오른쪽 칸이다. 실패가 3.3건에서 69.7건으로 폭증했다. 느려지는 걸 넘어 데이터가 조용히 빠지기 시작한 것이다.

동시성을 올리기 전에 직렬화 지점이 어디인지 먼저 측정해야 하는 이유다.

6-3. 통념 검증 — 지터는 생각만큼 효과적이지 않았다#

“고정 시간만큼 쉬면 다 같이 깨어나니 랜덤하게 쉬는(full jitter) 게 낫다”는 통념이 있다. 검증해보니 일관되게 낫지는 않았다. 고정 백오프 96 vs 지터 94로 사실상 동률이고, 부하 조건을 바꾸면 승자가 뒤집혔다.

이유는 단순하다. 대기줄이 이미 꽉 차 있으면 언제 깨어나든 결국 같은 줄 뒤에 선다. 지터가 없애주는 건 “동시에 깨어남”이지 “줄 서기”가 아니다.

여기서 얻을 교훈은 지터를 빼라는 게 아니다. 재시도 정책은 튜닝 손잡이지 해결책이 아니라는 것이다.

6-4. 각 대안의 한계#

대안언제 잘못된 선택이 되나
A 백오프경합이 지속적이면 재시도를 소진한다. 확률을 낮출 뿐 0으로 못 만든다
B 커밋 묶기커밋 단위가 커져 실패 시 날아가는 범위가 커진다. 메모리도 더 쓴다
C 직렬화추출이 무겁고 커밋이 가벼우면 엉뚱한 곳을 줄 세우는 것이다 (2.9배 손해)
D fan-in설정 한 줄로 안 된다. staging 저장소와 그 정리 책임이 생긴다

한 가지 덧붙이면, 카탈로그를 바꿔도 충돌은 남는다. “Hive 락이 문제니 REST 카탈로그로 가자”는 반만 맞다. 락 경합은 줄지만 포인터가 하나라는 사실은 변하지 않는다. 카탈로그 교체는 완화지 해결이 아니다.

6-5. 재시도의 안전성은 쓰기 방식이 정한다#

마지막으로 가장 놓치기 쉬운 지점. “충돌했으니 다시 하면 되지”가 성립하려면 다시 해도 결과가 같아야 한다. 그런데 이건 재시도 코드가 아니라 sink의 쓰기 의미가 정한다.

쓰기 방식재시도 안전성
파티션 overwrite안전. 같은 자리를 덮으므로 몇 번 해도 같다
append안전하지 않다. 할 때마다 쌓인다

append 경로에서 재시도가 괜찮아 보이는 건 하류에 중복 제거가 있기 때문이지 append 자체가 멱등해서가 아니다. 중복 제거 없는 append 테이블에서 재시도만 늘리면 조용히 중복이 생긴다.


07. 마무리#

핵심은 “파일이 안 겹치니 안전하다”는 직관이 틀렸다는 것이다. 겹치는 것은 파일이 아니라 스냅샷 포인터이고, 그것은 테이블당 하나뿐이다.

정리하면:

  1. 병렬 설계를 볼 때는 “무엇이 병렬인가”가 아니라 “무엇이 공유되는가”를 먼저 묻는다. Iceberg에서 공유되는 것은 테이블당 하나뿐인 포인터다.
  2. 락은 커밋을 안전하게 만들지 충돌을 막지 않는다. 값을 읽는 시점이 락 밖이기 때문이다.
  3. 경합의 세기는 동시성 값이 아니라 동시성 × 커밋 횟수다. 두 번째 항을 대부분 잊는다.
  4. 재시도와 백오프는 확률 손잡이다. “줄인다”와 “없앤다”를 같은 말로 쓰면 안 된다.
  5. 줄을 세워야 한다면 최대한 좁게 세운다. 커밋만 직렬화하고 추출은 병렬로 두는 fan-in이, 전체를 직렬화하는 것보다 거의 항상 낫다.
  6. 재시도 소진 실패는 성능 지표가 아니라 데이터 유실 지표다. 별도로 관측하고 경보해야 한다.

이 논리는 Iceberg에만 해당하지 않는다. Delta Lake, Hudi는 물론이고 여러 주체가 하나의 직렬화된 공유 상태를 갱신하는 모든 설계에 그대로 적용된다. 기술은 바뀌어도 “병렬화는 직렬화 지점을 없애지 못하고 그 앞에 줄을 세울 뿐”이라는 사실은 남는다.

[Iceberg]파티션이 다른데 왜 커밋이 충돌할까
https://yjinheon.netlify.app/posts/02de/00-storage/otf-02-concurrent-commit/
Author
Datamind
Published at
2026-08-09
License
CC BY-NC-SA 4.0