데이터 파이프라인 · 백필과 이중 계산 · 이론
과거를 다시 돌린다 — 백필이 숫자를 두 배로 만들 때
한 줄 요약
백필은 "지난 구간을 다시 돌리는 일" 이 아니라 "정기 실행과 같은 코드로, 같은 파티션을 두 번 건드리지 않으면서, 이미 나간 숫자는 덮지 않고 다시 돌리는 일" 이다.
왜 이게 필요했나
버그가 있었다. 지난 2주치 집계가 틀렸다. 고친 코드로 그 구간을 다시 돌린다. 여기까지는 누구나 한다.
문제는 그다음에 온다. 백필이 도는 동안 정기 실행도 계속 돈다. 둘이 같은 날짜를 동시에 쓰면 어느 쪽이 이겼는지 아무도 모른다. 누적 집계는 백필이 더한 만큼 부풀어 오른다. 그리고 그중 사흘치는 이미 고객에게 보고서로 나갔다.
이 셋은 서로 다른 문제이고, 서로 다른 장치로 막는다. 이 코스의 앞 실습이 다룬 행 단위 멱등은 여기서 필요조건일 뿐 충분조건이 아니다. 같은 행을 두 번 넣어도 결과가 같아지게 만들어 놓아도, 구간 단위의 조율은 따로 있어야 한다.
어떻게 동작하나
첫째 원칙은 백필이 정기 실행과 같은 코드여야 한다는 것이다. 백필 전용 스크립트를 따로 두면 두 코드가 서서히 갈라지고, 어느 날 백필 결과와 정기 결과가 다르다는 것을 아무도 설명하지 못한다. 그래서 실행기는 "구간을 받아 그 구간의 파티션을 다시 계산한다" 하나만 할 줄 알면 된다. 정기 실행은 그 구간이 어제 하루인 경우일 뿐이다. [Airflow 의 백필](https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/dag-run.html)도 같은 DAG 를 과거 구간에 대해 실행하는 것이지 다른 DAG 를 부르는 것이 아니다.
둘째는 파티션 단위로 통째로 치환한다는 것이다. 한 파티션의 결과를 지우고 다시 넣으면, 통째로 돌리든 하루씩 나눠 돌리든 같은 답이 나온다. 이 성질이 없으면 백필을 중간에 멈췄다 다시 시작할 수가 없다. 그리고 파티션을 쪼개 돌리는 편이 대개 낫다 — 실패했을 때 어디까지 됐는지가 파티션 경계로 드러나기 때문이다.
셋째는 구간을 먼저 예약한다는 것이다. 백필이 어느 날짜들을 쓸지 상태 저장소에 적어 두고, 정기 실행은 남이 잡고 있는 날짜를 건드리지 않고 건너뛴 뒤 그 사실을 보고한다. 조용히 기다리거나 조용히 덮어쓰는 것보다 건너뛰고 말하는 편이 낫다. 잠금을 쓸 때 [SQLite 의 BEGIN IMMEDIATE](https://www.sqlite.org/lang_transaction.html)처럼 쓰기 의도를 처음부터 밝히는 방식이 늦게 실패하는 것보다 낫다는 것도 같은 이야기다.
넷째는 누적 집계에 백필을 섞지 않는다는 것이다. 여기가 가장 자주 틀린다.
누적을 '더하는' 방식 1) 2월 1일부터 14일까지 정기 실행 누적 += 5,182만원 → 5,182만원 2) 2월 9일부터 11일까지 백필 daily 는 제자리에 치환됨 3) 백필 구간을 누적에 더함 누적 += 842만원 → 6,024만원 ← 842만원이 두 번누적을 '다시 세는' 방식 daily 표 전체를 합쳐 넣는다 누적 = 5,182만원 ← 몇 번 돌려도 같다파티션 표가 멱등해도 누적 표가 멱등하지 않으면 소용이 없다. 누적은 더하지 말고 파생시킨다. 원본 파티션에서 다시 세면 백필을 몇 번 돌려도 값이 흔들리지 않는다.
다섯째는 되돌아가면 안 되는 자리가 있다는 것이다. 이미 보낸 보고서, 이미 나간 알림, 이미 정산된 금액은 자료가 아니라 사건이다. 그 구간은 봉인하고, 새 값이 나오면 덮어쓰는 대신 정정으로 따로 기록한다. 그래야 "그때 우리가 무엇을 보냈는가" 와 "지금 무엇이 맞는가" 를 둘 다 말할 수 있다.
현장에서 만나는 모습
첫째, 백필 스크립트를 따로 만든다. 급할 때 한 번 쓰려고 만든 스크립트가 남아서 반년 뒤에도 돌고 있다. 그 스크립트만 아는 예외 처리가 생기고, 두 경로의 답이 갈라진다.
둘째, 구간을 통째로 한 트랜잭션에 넣는다. 14일치를 한 번에 커밋하면 13일째에 실패했을 때 처음부터 다시 해야 하고, 그동안 상태 저장소는 아무것도 모른다. 파티션마다 끝내면 재시작 지점이 공짜로 생긴다.
셋째, 예약 없이 "지금은 정기 실행이 안 돌 시간" 이라고 믿는다. 재시도·수동 실행·시차 때문에 그 믿음은 깨진다. 예약은 코드가 지키는 약속이고, 시간대는 사람이 지키는 약속이다.
넷째, 백필 범위를 로그로만 남긴다. 무엇을 언제 누가 다시 돌렸는지가 상태 저장소에 남아 있지 않으면, 숫자가 이상할 때 되짚을 근거가 없다. 그리고 실행 시간으로 백필 여부를 추정하지 말 것 — 같은 기계에서도 실행 시간은 두 배까지 흔들린다.
실무에서 진짜 중요한 것
- 백필과 정기 실행은 같은 함수다. 다른 것은 구간 인자뿐이어야 한다.
- 파티션 단위로 치환하고, 파티션마다 커밋한다. 재시작 지점이 생긴다.
- 구간을 예약하고, 남의 구간은 건너뛰고 보고한다. 기다리거나 덮어쓰지 않는다.
- 누적은 더하지 말고 파생시킨다. 더하는 누적은 백필을 만나면 반드시 틀린다.
- 이미 나간 것은 봉인하고 정정으로 남긴다. 덮어쓰면 그때의 사실이 사라진다.
다음 실습에서 할 것
열나흘치 주문 파티션을 만든 뒤, 실행기 runner.py 를 한 단계씩 키운다. 구간을 받아 파티션 단위로 치환하게 만들고, 통째로 돌린 답과 하루씩 돌린 답이 같은지 확인하고, 더하는 누적과 파생시키는 누적이 백필 뒤에 얼마나 벌어지는지를 숫자로 적는다. 그다음 구간 예약을 붙여 정기 실행이 남의 구간을 건너뛰고 보고하게 하고, 마지막으로 이미 내보낸 구간을 봉인해 늦게 온 전표를 덮어쓰는 대신 정정으로 남긴다. 채점기는 매번 다른 날짜와 금액으로 자기 파티션을 차려 여러분의 실행기를 실제로 돌리고 상태 저장소를 직접 읽어 대조한다.