Blog
pysparksparkpartitionbackfillidempotenticebergdelta

PySpark 동적 파티션 덮어쓰기와 멱등 백필 — 재처리를 안전하게

특정 날짜만 다시 계산해 덮어쓰고 싶은데 전체 테이블이 날아가는 사고를 막는 법. dynamic partition overwrite 모드, 멱등(idempotent) 백필 설계, Iceberg/Delta 의 안전한 부분 덮어쓰기와 replaceWhere 패턴을 정리합니다.

Data Dynamics2026年6月5日12 min read
This post is not yet translated. The original Korean version is shown below.

병원 창고 담당자가 어제 입력한 재고 수량이 잘못됐다고 그 날 데이터만 수정하려 했는데, 실수로 창고 전체 기록을 지워버렸다면 어떨까요? 데이터 파이프라인에서 정확히 이런 사고가 일어납니다. "지난 화요일 데이터가 잘못됐으니 그 날짜만 다시 계산해 덮어써라" — 순진하게 mode("overwrite") 로 쓰면 그 날짜만이 아니라 테이블 전체가 날아갑니다. 반대로 덮어쓰기를 피하려다 같은 데이터를 두 번 적재해 중복을 만들기도 합니다.

이 글은 특정 파티션만 안전하게 덮어쓰는 법, 재실행해도 결과가 같은 멱등 백필(idempotent backfill) 설계, 그리고 Iceberg/Delta 의 안전한 부분 덮어쓰기 패턴을 정리합니다.

이 글에서 배우는 것

  • 기본 overwrite 가 왜 테이블 전체를 삭제하는지, 그리고 dynamic 모드의 차이
  • Iceberg/Delta 의 replaceWhere / overwritePartitions 로 조건부 원자 덮어쓰기
  • 멱등 백필: 재실행해도 결과가 달라지지 않는 파이프라인 설계 원칙
  • 대량 기간 백필을 날짜 단위로 안전하게 오케스트레이션하는 방법
  • append·static overwrite·동시 쓰기 충돌 등 흔한 함정과 회피법

1. 사고의 시작 — overwrite 가 전체를 지운다

"하루치만 다시 쓴다"는 의도였지만, 아래 코드는 그 의도를 전혀 따르지 않습니다. 실제로 어떤 일이 생기는지 살펴보세요.

# 위험: dt=2026-06-03 만 다시 쓰려 했는데...
(reprocessed_one_day
    .write
    .mode("overwrite")            # ← 파티션 테이블 전체를 덮어씀! 💥
    .partitionBy("dt")
    .parquet("/warehouse/events"))

기본 overwrite대상 경로 전체를 지우고 새로 씁니다. 하루치만 가진 DataFrame 을 overwrite 하면, 나머지 364일이 사라집니다. 이 사고는 실제로 매우 흔합니다.

2. 해법 ① Dynamic Partition Overwrite

다행히 Spark 에는 "들어온 데이터에 존재하는 파티션만 덮어쓰는" 동적 모드가 있습니다. 설정 한 줄로 사고를 막을 수 있습니다.

spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")
 
(reprocessed_one_day              # dt=2026-06-03 만 포함
    .write
    .mode("overwrite")
    .partitionBy("dt")
    .parquet("/warehouse/events"))
# → dt=2026-06-03 파티션만 교체, 나머지 날짜는 그대로
모드동작
static(기본)대상 경로 전체 덮어씀
dynamicDataFrame 에 있는 파티션만 덮어씀

핵심: dynamic 모드에서는 DataFrame 에 등장하는 파티션 값만 교체됩니다. 5일치를 쓰면 그 5일만 교체되고 나머지는 보존됩니다. 백필의 기본 도구입니다.

dynamic 모드의 한계: 파티션 컬럼이 있어야 하고, "파티션 단위"로만 교체됩니다. 파티션 내 일부 행만 바꾸는 건 안 됩니다.

3. 해법 ② Iceberg / Delta — replaceWhere / 조건부 덮어쓰기

Lakehouse 포맷을 쓴다면 더 안전하고 유연한 방법이 있습니다. 조건(predicate)으로 덮어쓸 범위를 명시하기 때문에 "실수로 전체를 지우는" 사고가 구조적으로 어렵습니다.

Delta replaceWhere

(reprocessed
    .write
    .format("delta")
    .mode("overwrite")
    .option("replaceWhere", "dt >= '2026-06-01' AND dt <= '2026-06-03'")
    .save("/warehouse/events"))
# → 조건에 맞는 데이터만 원자적으로 교체

Iceberg overwritePartitions / dynamic

# Iceberg: DataFrameWriterV2 의 overwritePartitions (동적)
(reprocessed
    .writeTo("analytics.events")
    .overwritePartitions())       # 들어온 파티션만 교체
 
# 또는 조건부 덮어쓰기 (SQL)
spark.sql("""
  DELETE FROM analytics.events WHERE dt = DATE '2026-06-03'
""")  # 그 후 append, 또는 MERGE 사용
방식안전성입도
dynamic overwrite중(파티션 단위)파티션
Delta replaceWhere높음(조건 명시)조건 범위
Iceberg overwritePartitions높음(원자적)파티션
MERGE높음(행 단위)

Lakehouse 의 장점: 덮어쓰기가 원자적(atomic) 입니다. 중간에 실패해도 깨진 부분 상태가 남지 않습니다. 또 조건을 명시하니 "전체가 날아가는" 사고가 구조적으로 어렵습니다.

4. 멱등 백필 — 재실행해도 같은 결과

백필의 본질은 "같은 날짜를 여러 번 재처리해도 결과가 동일해야 한다"는 멱등성입니다. 운영 환경에서 재시도·중복 실행은 피할 수 없기 때문에, 파이프라인 자체가 여러 번 실행되어도 결과가 흔들리지 않아야 합니다.

한 문장으로: 멱등 백필은 "몇 번을 실행해도 같은 파티션에 같은 결과"가 보장되는 파이프라인입니다.

멱등하지 않은 파이프라인:
  append 로 적재 → 재실행하면 같은 데이터가 두 번 쌓임 (중복!)
 
멱등한 파이프라인:
  "해당 파티션을 교체" → 몇 번 실행해도 결과 동일

멱등 백필의 두 가지 패턴:

패턴 A — 파티션 교체(overwrite)

def backfill_day(dt):
    result = compute_for_day(dt)       # dt 하루치 재계산
    (result.write
        .format("delta").mode("overwrite")
        .option("replaceWhere", f"dt = '{dt}'")   # 그 날짜만 교체
        .save(TABLE))
    # 몇 번 실행해도 dt 파티션은 같은 결과로 교체 → 멱등

패턴 B — MERGE (행 단위 멱등)

# 키 기준 upsert, updated_at 비교로 멱등 (별도 글 "PySpark 대규모 중복 제거와 SCD2")
spark.sql("""
  MERGE INTO analytics.events t USING updates s
  ON t.id = s.id
  WHEN MATCHED AND s.updated_at > t.updated_at THEN UPDATE SET *
  WHEN NOT MATCHED THEN INSERT *
""")
패턴적합
파티션 교체파티션 전체를 다시 계산 가능할 때(일배치)
MERGE증분·키 기반, 일부 행만 변경

5. 백필 오케스트레이션

과거 1년치를 한꺼번에 재처리하려다 절반에서 실패하면 어디서부터 다시 해야 할지 막막해집니다. 대량 기간 백필은 날짜별로 독립적으로 나눠 실행하는 것이 핵심입니다.

from datetime import date, timedelta
 
def date_range(start, end):
    d = start
    while d <= end:
        yield d
        d += timedelta(days=1)
 
for d in date_range(date(2025, 1, 1), date(2025, 12, 31)):
    backfill_day(d.isoformat())   # 각 날짜는 멱등 → 실패 시 그 날만 재실행

운영 팁:

  • 날짜를 독립 단위로: 각 날짜 백필이 멱등·독립이면, 실패한 날짜만 재실행하면 됩니다.
  • Airflow 등 스케줄러에서 날짜별 태스크로 병렬화하되, 동시 쓰기 충돌(같은 테이블)에 주의.
  • 데이터 양이 크면 한 번에 전체 기간을 한 잡으로 하지 말고 청크로.

6. 흔한 함정

함정결과회피
static overwrite 로 부분 쓰기전체 테이블 소실dynamic 모드 / replaceWhere
append 로 백필중복 누적overwrite/MERGE
파티션 컬럼 없이 dynamic동작 안 함partitionBy 필수
동시 백필 충돌커밋 충돌·깨짐테이블 포맷 트랜잭션, 직렬화
덮어쓰기 후 작은 파일누적정기 컴팩션(별도 글)

static overwrite 사고를 구조적으로 막으려면, Lakehouse 의 replaceWhere/overwritePartitions 를 기본 도구로 삼으세요. 조건을 명시하게 강제되어 "전체 삭제" 실수가 어렵습니다.

7. 정리

도구핵심
dynamic partition overwrite들어온 파티션만 교체
Delta replaceWhere조건 범위만 원자적 교체
Iceberg overwritePartitions파티션 원자적 교체
MERGE행 단위 멱등 upsert
백필 설계날짜 독립·멱등 단위로

파티션 덮어쓰기와 백필의 핵심은 두 가지입니다. 첫째, static overwrite 의 "전체 삭제" 사고를 인지하고 dynamic 모드나 조건부 덮어쓰기를 쓸 것. 둘째, 백필을 멱등하게 설계해 재실행·중복 실행에도 결과가 흔들리지 않게 할 것. Iceberg/Delta 의 원자적·조건부 덮어쓰기를 기본 도구로 삼으면, "어제 데이터만 다시 계산"이라는 일상적 요청이 더 이상 위험한 작업이 아니게 됩니다.

마치며 — 핵심 요약

  • 기본 mode("overwrite") 는 대상 경로 전체를 삭제합니다. 부분 백필에는 절대 단독으로 쓰지 마세요.
  • spark.sql.sources.partitionOverwriteMode = dynamic 으로 DataFrame 에 있는 파티션만 교체할 수 있습니다.
  • Lakehouse 환경에서는 replaceWhere / overwritePartitions 가 더 안전합니다. 조건을 강제로 명시하기 때문에 "전체 삭제" 실수가 구조적으로 어렵습니다.
  • 백필은 날짜를 독립·멱등 단위로 설계하세요. 실패하면 그 날짜만 재실행하면 됩니다.
  • append 로 백필하면 중복이 쌓입니다. 반드시 overwrite/MERGE 로 교체 방식을 취하세요.
  • 올바른 설계 하나가 야근 몇 번을 줄여줍니다. 처음부터 멱등하게 만드는 것이 가장 빠른 길입니다.

이 글은 Spark 3.5 + Iceberg/Delta 기준으로 작성되었습니다. 백필·재처리 파이프라인의 안전한 설계가 필요하시면 언제든 문의해 주세요.

— Data Dynamics 엔지니어링 팀