배치 파이프라인 운영: backfill, retry, idempotency, checkpoint
배치 파이프라인은 “다시 실행할 수 있는가”로 운영성이 갈린다
배치 파이프라인은 평소에는 단순해 보인다. 정해진 시간에 원천 데이터를 읽고, staging을 만들고, mart를 갱신하고, 품질 검사를 통과하면 끝난다. 그런데 운영자의 일은 성공한 날보다 실패한 날에 더 많이 드러난다. 네트워크 timeout으로 task가 재시도되고, 어제 배포한 SQL 버그 때문에 3개월치를 다시 계산해야 하고, 원천 시스템이 늦게 파일을 내려줘 일부 partition만 비어 있는 상황이 생긴다.
이때 중요한 질문은 “DAG가 한 번 실행됐는가”가 아니다. 같은 기간을 다시 실행해도 최종 데이터가 정확히 같은 상태로 수렴하는가, 실패한 위치를 찾을 수 있는가, 어느 범위까지만 안전하게 재처리할 수 있는가다. 그래서 backfill, retry, idempotency, checkpoint는 따로 외우는 기능이 아니라 하나의 운영 설계로 묶어서 봐야 한다.
운영 가능한 배치 파이프라인의 기본 흐름
file / DB / API
run_id, partition
deterministic SQL
atomic swap / merge
row count / freshness
위 그림에서 핵심은 publish 경계다. stage나 transform 중간 결과는 다시 만들 수 있어도 괜찮다. 하지만 최종 mart, dashboard source, downstream export처럼 소비자가 보는 결과는 부분적으로 덮이거나 중복되면 안 된다. 실패가 발생해도 “아직 publish 전이므로 버려도 되는 상태”와 “publish 완료라서 downstream이 읽어도 되는 상태”를 구분해야 한다.
운영자는 파이프라인을 task 목록이 아니라 상태 전이로 본다. 입력 partition을 확정하고, 임시 영역에 계산하고, 검증한 뒤, 원자적으로 publish하고, 성공 marker를 남긴다. 이 구조가 있어야 retry와 backfill이 사고 대응 도구가 된다.
1. Backfill은 과거 날짜 실행이 아니라 통제된 재처리다
Airflow 문서에서 backfill은 과거 logical date 범위에 대해 DAG run을 생성하는 기능이다. 시작일과 종료일을 주면 DAG의 schedule에 맞춰 해당 범위의 run을 만든다. 단, 시간 기반 schedule이 없는 DAG에는 backfill 개념이 맞지 않는다. 운영상 더 중요한 부분은 reprocessing behavior다. 이미 run이 있는 날짜를 건너뛸지, 실패한 run만 다시 만들지, 완료된 run까지 다시 만들지 선택해야 한다.
Backfill을 안전하게 쓰려면 먼저 “왜 다시 처리하는가”를 분류해야 한다.
| 상황 | backfill 범위 | 주의점 |
|---|---|---|
| 원천 지연 | 늦게 도착한 날짜 또는 최근 N일 | watermark와 freshness SLA를 함께 확인 |
| 변환 로직 버그 | 버그가 배포된 기간 전체 | 수정 배포 후 같은 코드 경로로 재처리 |
| 신규 컬럼 추가 | 필요한 historical range | downstream schema compatibility 확인 |
| 품질 규칙 변경 | 검증 대상 기간 | 데이터 자체 변경인지 품질 판정만 변경인지 구분 |
| 장애 복구 | 실패하거나 누락된 partition | 이미 성공한 partition을 건드릴 필요가 있는지 확인 |
--max-active-runs 같은 동시성 설정은 단순 속도 옵션이 아니다. 6개월치 backfill을 빨리 끝내려고 병렬도를 크게 올리면 warehouse slot, object storage request, source DB replica, metadata DB를 동시에 압박할 수 있다. 반대로 너무 낮으면 복구 시간이 길어져 downstream 보고서가 오래 틀린 상태로 남는다. 그래서 backfill 실행 전에는 날짜 범위, 예상 row 수, downstream 영향, 동시 실행 수, 중단 기준을 정해야 한다.
Airflow의 backfill dry run은 실제 run을 만들기 전에 어떤 날짜가 대상인지 확인하는 데 유용하다. 운영 절차에서는 dry run 결과를 change ticket이나 incident note에 붙여 “이번 재처리는 2026-06-01부터 2026-06-07까지이며, failed run만 생성한다”처럼 범위를 명시하는 편이 좋다.
2. Retry는 transient failure만 가린다
Retry는 실패한 task를 다시 실행하는 기능이다. 네트워크 일시 장애, object storage 503, warehouse connection reset, rate limit처럼 다시 시도하면 성공할 수 있는 오류에는 효과적이다. 하지만 retry가 모든 실패를 해결하지는 않는다. SQL이 잘못되어 row가 두 배로 늘어나는 버그, schema mismatch, 권한 누락, 원천 파일 자체 오류는 여러 번 실행해도 같은 방식으로 실패하거나 더 나쁜 중복을 만든다.
따라서 retry 정책은 오류 성격과 side effect를 보고 정해야 한다.
| 오류 유형 | retry 적합도 | 운영 판단 |
|---|---|---|
| HTTP 429, 5xx, 일시적 connection reset | 높음 | exponential backoff와 최대 지연 설정 |
| object storage eventual listing 지연 | 중간 | sensor timeout, file manifest, marker file 병행 |
| source schema breaking change | 낮음 | 즉시 실패시키고 owner 확인 |
| partial insert 후 timeout | 위험 | idempotent publish가 없으면 retry 금지에 가깝다 |
| 데이터 품질 rule 실패 | 낮음 | 자동 retry보다 원천/변환 원인 확인 |
Airflow task에는 execution_timeout을 줄 수 있고, timeout 이후에도 retry 가능 횟수가 남아 있으면 재시도될 수 있다. 이 동작은 편리하지만, task가 timeout 전에 외부 시스템에 쓰기를 성공했는지 알 수 없는 경우가 있다. 예를 들어 warehouse INSERT는 완료됐지만 client connection이 끊겨 orchestrator가 실패로 기록하는 상황이다. 이때 다음 retry가 단순 INSERT를 반복하면 duplicate가 생긴다.
그래서 retry를 켜기 전에 물어야 할 질문은 하나다. “이 task가 중간에 죽어도 다시 실행했을 때 같은 최종 상태가 되는가?” 답이 아니면 retry 횟수를 늘리는 것이 아니라 publish 방식을 바꿔야 한다.
3. Idempotency는 partition 단위로 설계한다
Airflow best practices는 task를 database transaction처럼 다루고, 재실행해도 같은 결과를 만들어야 한다고 설명한다. 특히 task re-run에서 단순 INSERT는 중복 row를 만들 수 있으므로 UPSERT를 쓰거나, 특정 partition을 읽고 쓰며, datetime.now()처럼 실행 시점마다 달라지는 값을 핵심 계산에 쓰지 말라고 권한다.
배치 파이프라인에서는 이 원칙을 partition 단위로 구현하는 경우가 많다.
-- 나쁜 패턴: 실행할 때마다 결과가 달라질 수 있고 중복도 생긴다.
insert into mart.daily_orders
select current_date as run_date, customer_id, count(*) as orders
from stg.orders
where created_at >= current_date - interval '1 day'
group by 1, 2;
-- 더 나은 패턴: 처리 partition을 명시하고 같은 partition을 교체한다.
begin;
delete from mart.daily_orders
where order_date = :target_date;
insert into mart.daily_orders (order_date, customer_id, orders)
select cast(created_at as date) as order_date, customer_id, count(*) as orders
from stg.orders
where cast(created_at as date) = :target_date
group by 1, 2;
commit;DELETE + INSERT가 항상 정답은 아니다. 테이블 크기, partitioning, isolation level, downstream reader, warehouse 기능에 따라 MERGE, INSERT OVERWRITE PARTITION, copy-on-write table format의 atomic commit, 임시 테이블 후 rename/swap이 더 안전할 수 있다. 중요한 것은 같은 target_date를 여러 번 실행해도 최종 결과가 하나로 수렴해야 한다는 점이다.
Idempotent batch 설계 체크리스트
| 설계 항목 | 좋은 기준 |
|---|---|
| 입력 범위 | data_interval_start, data_interval_end, target_date처럼 logical time으로 고정 |
| 출력 범위 | 하나의 partition 또는 명확한 key range만 교체 |
| 중복 방지 | primary key, unique key, MERGE, dedup rule을 publish 경계에 둠 |
| 임시 데이터 | run_id 또는 attempt별 staging 경로를 사용하고 성공 후 정리 |
| 시간 함수 | 핵심 계산에는 now()보다 logical date를 사용 |
| 원자성 | 검증 전 결과가 소비자에게 보이지 않도록 stage와 publish를 분리 |
| 관측성 | 처리 row 수, 입력 파일 목록, checksum, quality result를 run metadata로 저장 |
Idempotency는 코드 한 줄로 끝나지 않는다. source file이 같은 이름으로 덮어써지는지, API pagination이 안정적인지, late-arriving data를 어느 기간까지 다시 볼지, downstream mart가 어떤 key로 중복을 제거할지까지 포함한다. 특히 append-only table에 “나중에 dedup하면 된다”는 식으로 미루면 장애 시점에는 어느 run이 정답인지 추적하기 어려워진다.
4. Checkpoint는 두 종류가 있다: 처리 엔진의 checkpoint와 운영 상태 checkpoint
Checkpoint라는 말은 문맥에 따라 다르게 쓰인다. Spark에서 RDD checkpoint는 RDD를 checkpoint directory에 저장하고 parent RDD reference를 제거해 lineage를 끊는다. Spark 문서는 checkpoint를 호출하기 전에 checkpoint directory를 SparkContext.setCheckpointDir()로 설정해야 하며, recomputation을 줄이기 위해 해당 RDD를 memory에 persist하는 것을 강하게 권장한다. 이 checkpoint는 긴 lineage나 반복 계산에서 장애 복구와 재계산 비용을 줄이는 처리 엔진 레벨의 기능이다.
하지만 배치 파이프라인 운영에서 더 자주 필요한 것은 “운영 상태 checkpoint”다. 예를 들어 다음 정보를 외부 metadata store에 남기는 것이다.
| checkpoint 항목 | 왜 필요한가 |
|---|---|
| 처리 대상 partition | retry와 backfill 범위를 정확히 재현 |
| 입력 파일 목록과 version | 같은 logical date라도 입력이 바뀌었는지 확인 |
| source offset 또는 high watermark | incremental extract가 어디까지 읽었는지 기록 |
| staging path/table | 실패 후 cleanup 또는 재사용 판단 |
| publish marker | downstream이 읽어도 되는 완료 상태 표시 |
| 품질 검사 결과 | backfill 후 정상화 여부 증명 |
이 상태는 orchestrator의 task state만으로 대체하기 어렵다. Airflow task가 success라고 해서 mart에 정확한 row가 publish됐다는 뜻은 아니다. 반대로 task가 failed여도 외부 warehouse에는 일부 결과가 이미 들어갔을 수 있다. 그래서 중요한 배치 파이프라인은 별도의 run ledger를 둔다.
create table ops.pipeline_run_ledger (
pipeline_name text,
target_date date,
run_id text,
status text, -- started, staged, validated, published, failed
input_manifest_uri text,
output_partition text,
row_count bigint,
checksum text,
started_at timestamp,
finished_at timestamp,
primary key (pipeline_name, target_date, run_id)
);이 ledger는 단순 로그보다 강한 운영 도구다. backfill 전에는 어떤 날짜가 이미 published인지 확인하고, retry 전에는 이전 attempt가 어디까지 갔는지 판단하며, 장애 후에는 partial output cleanup 대상을 찾는다. 다만 ledger 업데이트 자체도 publish와 순서가 맞아야 한다. 데이터는 publish됐는데 marker가 없거나, marker는 있는데 데이터가 없으면 더 큰 혼란이 생긴다. 가능하면 데이터 publish와 marker 업데이트를 같은 transaction 또는 같은 atomic commit 경계에 묶는다.
5. Backfill runbook: 범위를 좁히고, 같은 코드 경로로, 검증까지 한다
Backfill은 운영자가 가장 조심해야 하는 버튼 중 하나다. 잘못 누르면 과거 데이터를 고치는 대신 현재 정상 데이터를 덮어쓸 수 있다. 안전한 절차는 대략 다음 순서다.
- 원인과 범위를 확정한다. 버그가 언제 배포됐고, 어떤 source와 downstream이 영향을 받았는지 lineage와 품질 결과로 확인한다.
- 코드를 먼저 고친다. 틀린 로직 그대로 backfill하면 잘못된 데이터를 더 빠르게 다시 만들 뿐이다.
- 대상 partition을 dry run으로 확인한다. 날짜 범위와 reprocessing behavior를 기록한다.
- 동시성을 제한한다. warehouse, source replica, downstream SLA를 보고
max_active_runs나 job queue를 조절한다. - 같은 DAG 경로로 실행한다. 운영 DAG와 다른 임시 스크립트는 품질 검사, lineage, 알림을 우회하기 쉽다.
- partition별 품질 검사를 수행한다. row count, null rate, duplicate key, reconciliation을 확인한다.
- downstream 재처리를 결정한다. mart만 고치면 되는지, dashboard extract나 ML feature까지 다시 만들어야 하는지 확인한다.
- 완료 후 ledger와 incident note를 남긴다. 어떤 날짜가 언제 재처리됐고, 어떤 검증을 통과했는지 남긴다.
Backfill을 빠르게 끝내는 것보다 중요한 것은 되돌릴 수 있는 상태를 유지하는 것이다. 대량 재처리 전에는 대상 table snapshot, partition backup, transaction log rollback 가능성, downstream cache invalidation 방식을 확인해야 한다. 특히 serving table을 직접 덮어쓰는 구조라면 작은 범위로 canary backfill을 먼저 실행하고 결과를 비교하는 편이 안전하다.
6. Late-arriving data는 정책 문제다
배치 파이프라인에서 “어제 데이터”는 생각보다 고정된 개념이 아니다. 결제 취소가 이틀 뒤에 들어오고, 모바일 이벤트가 오프라인 상태였다가 늦게 업로드되고, 파트너사가 파일을 다음날 다시 보내는 일이 생긴다. 이때 매번 전체 history를 backfill할 수는 없다. 그래서 late-arriving data 정책이 필요하다.
일반적인 방식은 sliding window 재계산이다. 예를 들어 매일 최근 7일 partition을 다시 만들고, 그보다 오래된 기간은 명시적 backfill 요청이 있을 때만 수정한다.
-- 최근 7일 partition만 반복 재계산하는 예시
begin;
delete from mart.daily_revenue
where order_date >= :run_date - interval '7 days'
and order_date < :run_date;
insert into mart.daily_revenue (order_date, revenue)
select cast(order_created_at as date), sum(amount)
from stg.orders
where cast(order_created_at as date) >= :run_date - interval '7 days'
and cast(order_created_at as date) < :run_date
group by 1;
commit;여기서 7일은 기술적으로 정해지는 값이 아니라 비즈니스와 운영의 합의다. 늦게 들어오는 데이터가 대부분 48시간 안에 도착한다면 3일 window면 충분할 수 있다. 정산이나 규제 보고처럼 한 달 뒤 수정도 중요한 데이터라면 별도의 correction pipeline이 필요하다. 정책 없이 “최근 며칠 다시 돌리자”로 운영하면 비용과 정확성 사이의 판단 기준이 매번 달라진다.
7. 재처리 안전성을 측정하는 지표
배치 파이프라인은 성공/실패만 보면 운영 품질을 놓친다. retry와 backfill이 잦은 플랫폼에서는 다음 지표를 같이 봐야 한다.
| 지표 | 의미 | 알림 또는 리뷰 기준 |
|---|---|---|
| retry rate | 일시 장애나 불안정한 dependency 비율 | 특정 task retry 급증 시 원인 분석 |
| backfill volume | 정상 schedule 외 재처리 규모 | 반복 backfill은 upstream 계약 또는 품질 문제 신호 |
| partition freshness | partition별 최신 완료 시각 | SLA 초과 시 소비자 공지 |
| duplicate key count | idempotency 실패 여부 | publish 후 0이 아니면 즉시 차단 |
| row count delta | 이전 run 또는 source와의 차이 | 예상 범위 밖이면 publish 보류 |
| orphan staging size | 실패 후 남은 임시 데이터 | storage 비용과 cleanup 누락 확인 |
| ledger mismatch | marker와 실제 output 불일치 | run state 복구 필요 |
좋은 대시보드는 DAG graph보다 partition 상태를 더 잘 보여준다. “오늘 DAG가 초록색인가”보다 “2026-06-01부터 2026-06-07까지 모든 partition이 published이고, row count가 source와 reconcile됐으며, downstream mart가 갱신됐는가”가 운영 질문에 더 가깝다.
마무리: 재실행은 기능이 아니라 계약이다
배치 파이프라인 운영의 핵심은 실패하지 않는 코드를 만드는 것이 아니다. 실패해도 같은 범위를 다시 처리할 수 있고, 중복 없이 publish할 수 있으며, 무엇이 완료됐는지 증명할 수 있는 구조를 만드는 것이다. Backfill은 과거 run을 만드는 버튼이고, retry는 task를 다시 실행하는 기능이지만, 둘 다 idempotent publish와 checkpoint가 없으면 위험한 자동화가 된다.
운영자가 마지막으로 확인해야 할 질문은 단순하다. “이 partition을 오늘 세 번 다시 실행해도 최종 테이블은 한 번 성공했을 때와 같은가?” 이 질문에 자신 있게 답할 수 있으면 배치 파이프라인은 단순한 스케줄러 작업을 넘어 운영 가능한 데이터 제품이 된다.
References
- Apache Airflow, “Backfill,” https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/backfill.html
- Apache Airflow, “Best Practices,” https://airflow.apache.org/docs/apache-airflow/stable/best-practices.html
- Apache Airflow, “Tasks,” https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/tasks.html
- Apache Spark, “pyspark.RDD.checkpoint,” https://spark.apache.org/docs/3.5.6/api/python/reference/api/pyspark.RDD.checkpoint.html
- dbt Labs, “About incremental models,” https://docs.getdbt.com/docs/build/incremental-models