LLM WikiAccess-protected knowledge portal
← 스터디 홈
3편 · 약 20분

Backfill 안전장치: data interval, catchup, replay window, 중복 방지

Backfill이 평범한 재실행과 다른 이유

"어제 파이프라인이 실패했으니 오늘 다시 실행하면 된다"는 생각은 대부분 맞지만, 그 전제 위에 보이지 않는 위험이 있다. backfill은 단순히 과거 날짜를 재처리하는 것이 아니라, 스케줄러가 그 구간에 대한 DagRun을 새로 생성하고 task를 실행하는 과정이다. 이 과정에서 중복 데이터가 생기거나, 서로 다른 구간이 겹치거나, pool 한도를 초과해 다른 파이프라인을 밀어내는 일이 벌어질 수 있다.

이 편에서는 Airflow가 시간과 데이터 구간을 어떻게 표현하는지부터 시작해, catchup과 backfill의 차이, 중복을 막는 쓰기 패턴, 그리고 Airflow 3.0의 scheduler-managed backfill까지 다룬다.


1. data_interval: Airflow가 시간 구간을 표현하는 방식

Airflow 2.2 이전에는 execution_date라는 개념이 있었다. 이 값은 DAG run의 실행 시각이 아니라 처리해야 하는 데이터 구간의 논리적 시작점이었다. 이름이 직관적이지 않아 많은 혼란을 일으켰고, Airflow 2.2에서 logical_date로 이름이 바뀌면서 동시에 data_interval_startdata_interval_end가 도입됐다.

DagRun 1 2026-07-01 data_interval_start 2026-07-02 data_interval_end / logical_date DagRun 2 2026-07-03 DagRun 3 (미래) 처리 대상: 2026-07-01 00:00 ~ 2026-07-02 00:00 task 코드에서 {{ data_interval_start }} ~ {{ data_interval_end }} 사용
data_interval 구조

logical_date(= data_interval_end)는 DAG run이 "완료된 시점"이 아니라 "처리 대상 구간의 끝점"이다. 일별 DAG에서 logical_date = 2026-07-02라면 그 DAG run은 7월 1일 데이터를 처리한다. 이 구조를 이해하지 못하면 backfill 시 날짜 범위를 잘못 지정하는 실수가 발생한다.

1.1 template 변수로 경계를 명시한다

task 코드에서 현재 시각(datetime.now(), today())을 쓰는 순간 그 task는 backfill 안전성을 잃는다. 실행 시점에 따라 결과가 달라지기 때문이다. 항상 오케스트레이터가 보장하는 template 변수를 사용한다.

# 나쁜 패턴: 실행 시각 기준 조회 → backfill 시 잘못된 범위 처리
SELECT * FROM events WHERE created_at >= NOW() - INTERVAL 1 DAY

# 좋은 패턴: data_interval 기준 조회 → backfill에서도 동일하게 동작
SELECT * FROM events
WHERE created_at >= '{{ data_interval_start }}'
  AND created_at <  '{{ data_interval_end }}'

Python 코드(PythonOperator 등)에서는 context["data_interval_start"]context["data_interval_end"]로 접근한다.


2. catchup vs backfill: 비슷해 보이지만 다르다

catchup=True (자동 따라잡기)
DAG start_date부터 현재까지
스케줄러가 자동으로 DagRun 생성
배포 지연·다운타임 구간을
자동으로 메운다
⚠ 대량 DagRun 동시 생성 위험
max_active_runs 설정 필수
airflow backfill (명시적 재처리)
운영자가 범위와 옵션을
명시적으로 지정해서 실행
start_date 이전 구간도
재처리 가능
reprocessing_behavior로
중복 실행 여부 제어
권장: 기본값 catchup=False, 재처리는 명시적 backfill 명령으로
catchup vs backfill 비교

2.1 catchup

catchup=True는 DAG가 처음 활성화되거나 재시작될 때 start_date부터 현재까지 건너뛴 구간의 DagRun을 자동으로 생성한다. 편리하지만 수백 개의 DagRun이 한꺼번에 생성될 수 있다. max_active_runs를 설정하지 않으면 pool, parallelism 한도를 초과해 다른 DAG까지 영향을 받는다.

대부분의 팀은 catchup=False를 기본으로 설정하고, Airflow 전역 설정 catchup_by_default=False를 통해 강제한다. 이 방식이 의도치 않은 대량 재처리를 막는 가장 확실한 방어선이다.

2.2 backfill 명령

명시적 재처리가 필요할 때는 airflow backfill create 명령을 사용한다.

# Airflow 3.x 방식
airflow backfill create \
  --dag-id my_etl_dag \
  --start-date 2026-06-01 \
  --end-date 2026-06-30 \
  --reprocessing-behavior failed \
  --max-active-runs 5

# Airflow 2.x 방식 (레거시)
airflow dags backfill \
  -s 2026-06-01 \
  -e 2026-06-30 \
  my_etl_dag

--reprocessing-behavior 옵션이 중복 방지의 핵심이다.

동작
none이미 DagRun이 있으면 상태와 무관하게 새 run 미생성 (기본값)
failed실패 상태의 run만 새로 생성
completed성공 또는 실패 run 모두 새로 생성 (전체 재처리)

운영 실수로 가장 많이 일어나는 문제가 completed 옵션으로 이미 성공한 구간을 재처리하는 것이다. 이 경우 중복 데이터가 생기므로 task의 idempotency가 반드시 보장되어야 한다.


3. 중복을 막는 쓰기 패턴

backfill의 안전성은 결국 "같은 구간을 두 번 처리해도 결과가 한 번처럼 보이는가"에 달려 있다. 이를 보장하는 쓰기 패턴을 데이터 저장소별로 정리한다.

3.1 관계형 DB: DELETE + INSERT 또는 UPSERT

-- 패턴 1: 해당 파티션 삭제 후 재삽입
DELETE FROM orders_daily
WHERE dt = '{{ ds }}';

INSERT INTO orders_daily (dt, order_id, amount, status)
SELECT DATE(created_at), order_id, amount, status
FROM raw_orders
WHERE DATE(created_at) = '{{ ds }}';

-- 패턴 2: UPSERT (PostgreSQL)
INSERT INTO orders_daily (dt, order_id, amount, status)
SELECT DATE(created_at), order_id, amount, status
FROM raw_orders
WHERE DATE(created_at) = '{{ ds }}'
ON CONFLICT (dt, order_id) DO UPDATE SET
  amount = EXCLUDED.amount,
  status = EXCLUDED.status;

DELETE + INSERT 패턴은 단순하고 명확하지만 트랜잭션으로 묶어야 부분 실패 시 데이터 손실이 없다. UPSERT는 트랜잭션이 필요 없지만 유일 키 설계가 선행되어야 한다.

3.2 분석 DB / Object Storage: 파티션 덮어쓰기

BigQuery, Hive, Spark + S3 계열에서는 파티션 전체를 교체하는 방식이 표준이다.

-- BigQuery: WRITE_TRUNCATE + partition filter
INSERT INTO `project.dataset.orders_daily`
PARTITION (dt = '{{ ds }}')
SELECT ...
FROM `project.dataset.raw_orders`
WHERE DATE(created_at) = '{{ ds }}'
-- SDK에서 write_disposition=WRITE_TRUNCATE 설정

-- Spark: 특정 파티션만 덮어쓰기
spark.conf.set("spark.sql.sources.partitionOverwriteMode", "dynamic")
df.write.mode("overwrite").partitionBy("dt").saveAsTable("orders_daily")

-- Delta Lake: replaceWhere로 범위 교체
df.write.format("delta").option("replaceWhere", "dt = '{{ ds }}'")
  .mode("overwrite").save("/data/orders_daily")

partitionOverwriteMode=dynamic은 쓰는 파티션만 교체하므로 다른 날짜 파티션에 영향을 주지 않는다. 이 설정을 빠뜨리면 테이블 전체가 삭제될 수 있다.

3.3 집계 테이블 갱신 시 주의점

집계 task가 기존 값에 더하는 방식이면 재실행 시 값이 두 배가 된다.

# 나쁜 패턴: 누적 합산 → 재실행 시 중복
UPDATE revenue_daily
SET total = total + {{ new_amount }}
WHERE dt = '{{ ds }}'

# 좋은 패턴: 매 실행마다 전체 재계산
INSERT INTO revenue_daily (dt, total)
SELECT '{{ ds }}', SUM(amount)
FROM orders_daily
WHERE dt = '{{ ds }}'
ON CONFLICT (dt) DO UPDATE SET total = EXCLUDED.total

4. Replay window: 재처리 범위 설계

backfill을 안전하게 운영하려면 "어느 구간까지 재처리할 수 있는가"를 미리 설계해야 한다. 이를 replay window라고 부른다.

짧은 윈도우 (7일 이내)
원본 데이터 보존 기간 짧음
또는 스트리밍 소스
7일 이상 된 데이터 재처리 불가
→ 소스 시스템 보존 정책 확인 필수
중간 윈도우 (30~90일)
대부분 데이터 플랫폼 표준
raw layer 보존 기간 = 재처리 가능 범위
backfill 시 raw → 중간 → mart
순서대로 재처리 필요
무제한 윈도우
cold storage에 원본 영구 보존
비용과 재처리 시간이 트레이드오프
스키마 변경 이력 없으면
오래된 구간 재처리 불가능할 수 있음
replay window = min(소스 보존 기간, 스키마 호환 기간, 비용 허용 범위)
Replay window 설계

4.1 Replay window를 결정하는 세 가지 제약

소스 보존 기간: Kafka topic retention이 7일이면 8일 전 이벤트는 재처리할 소스가 없다. S3 생명주기 정책이 30일이면 31일 전 raw 파일이 없다. replay window는 소스 시스템이 허용하는 기간을 초과할 수 없다.

스키마 호환성: 3개월 전 raw 데이터를 현재 파이프라인 코드로 재처리할 때 컬럼이 추가·변경됐다면 파싱 오류가 발생한다. 스키마 변경 이력과 backward compatibility 설계가 replay window에 영향을 준다.

downstream SLA 영향: 대규모 backfill은 pool을 소비해 실시간 파이프라인을 지연시킨다. max_active_runs를 낮게 설정하거나 별도 backfill pool을 만들어서 프로덕션 트래픽과 분리한다.

4.2 backfill 전용 pool 설계

# backfill 전용 pool 생성 (슬롯 3개로 제한)
airflow pools set backfill_pool 3 "backfill only – limits resource usage"

# DAG에서 backfill run 시에만 pool 지정
from airflow.models import DagRun

def get_pool(**context):
    run_type = context["dag_run"].run_type
    return "backfill_pool" if run_type == "backfill" else "default_pool"

Airflow 3.0에서는 backfill이 스케줄러 관리 객체로 격상되어 UI에서 pool 설정을 직접 제어할 수 있다.


5. max_active_runs: 동시 DagRun 수 제어

catchup이나 backfill로 대량의 DagRun이 생성될 때, max_active_runs는 동시에 실행 중인 DagRun 수의 상한을 설정한다. 이 값이 너무 크면 pool을 독점하고 다른 DAG를 굶긴다. 너무 작으면 재처리가 오래 걸린다.

dag = DAG(
    "daily_etl",
    schedule="@daily",
    catchup=False,
    max_active_runs=3,           # 최대 3개 DagRun 동시 실행
    max_active_tasks=8,          # 이 DAG의 최대 동시 task 수
    ...
)

backfill 명령에서도 --max-active-runs 옵션으로 별도 제어할 수 있다. 프로덕션 클러스터에서 백필을 실행할 때는 동시 DagRun 수를 1~5로 제한하고, 점진적으로 늘려가면서 클러스터 부하를 확인하는 것이 안전하다.


6. Airflow 3.0의 scheduler-managed backfill

Airflow 3.0(AIP-78)에서 backfill이 first-class 스케줄러 개념으로 격상됐다. 이전에는 CLI 프로세스가 직접 DagRun을 생성하고 끝나면 프로세스가 종료되는 방식이었다면, 3.0부터는 스케줄러가 backfill 객체를 관리한다.

주요 변화는 다음과 같다.

  • UI에서 backfill 생성, 모니터링, 일시정지, 취소가 가능하다.
  • backfill이 non-blocking 방식으로 실행되어 여러 backfill을 동시에 큐에 넣을 수 있다.
  • backfill run도 pool 한도를 존중한다 — 이전 버전에서는 backfill이 pool을 우회하는 문제가 있었다.
  • reprocessing_behavior 옵션이 API와 UI에서도 설정 가능하다.
# Airflow 3.x CLI
airflow backfill create \
  --dag-id orders_etl \
  --start-date 2026-06-01 \
  --end-date 2026-06-30 \
  --reprocessing-behavior none \
  --max-active-runs 5

# backfill 목록 확인
airflow backfill list --dag-id orders_etl

# backfill 취소
airflow backfill cancel <backfill_id>

7. 실무 체크리스트: backfill 전에 확인할 것

backfill을 실행하기 전에 아래 항목을 점검한다.

항목확인 내용
idempotencytask 쓰기 로직이 DELETE+INSERT 또는 UPSERT인가? datetime.now() 사용 없는가?
소스 데이터 존재재처리 구간의 raw 데이터가 소스에 실제로 있는가?
스키마 호환성오래된 구간 데이터가 현재 파싱 코드와 호환되는가?
max_active_runs프로덕션 파이프라인에 영향을 주지 않을 동시 run 수인가?
pool 여유backfill이 점유할 pool 슬롯이 충분한가, 분리 pool이 필요한가?
downstream 파이프라인backfill 완료 후 downstream DAG도 재처리 필요한가?
reprocessing_behavior이미 성공한 run을 다시 실행해야 하는가, 실패 run만인가?

마무리: 안전한 backfill은 설계에서 시작한다

backfill을 안전하게 만드는 것은 backfill 명령 옵션이 아니라 task 코드의 idempotency와 data_interval 사용에 달려 있다. 파이프라인을 처음 설계할 때부터 "이 task를 30일 전 날짜로 재실행하면 어떻게 되는가"라는 질문을 던지는 것이 가장 효과적인 예방이다. 그 질문에 자신 있게 "같은 결과가 나온다"고 답할 수 있는 파이프라인이 backfill 안전 파이프라인이다.

References

  • Apache Airflow Documentation, "Backfill": https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/backfill.html
  • Apache Airflow Documentation, "DAG Runs": https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/dag-run.html
  • Apache Airflow Wiki, "AIP-78 Scheduler-managed backfill": https://cwiki.apache.org/confluence/display/AIRFLOW/AIP-78+Scheduler-managed+backfill
  • Astronomer Documentation, "Rerun Airflow DAGs and tasks": https://docs.astronomer.io/learn/rerunning-dags
  • Astronomer Blog, "Intro to Airflow 3": https://www.astronomer.io/blog/intro-to-airflow-3/
  • Medium (Chanon Krittapholchai), "Apache Airflow Useful Practices: Idempotent DAG": https://medium.com/@chanon.krittapholchai/apache-airflow-useful-practices-idempotent-dag-6d52b1594704
  • ML4Devs, "Backfilling Historical Data With Idempotent Data Pipelines": https://www.ml4devs.com/what-is/backfilling-data/
  • Galaxy, "Airflow Backfill without Duplicates": https://www.getgalaxy.io/learn/glossary/airflow-backfill-without-duplicates
  • DataCamp, "Apache Airflow 3.0 Is Here: The Most Significant Release Yet": https://www.datacamp.com/blog/apache-airflow-3-0