모든 글
약 25분 분량 개인 프로젝트/lakehouse

7일이면 버려지는 스냅샷을 장기 이력으로 살려낸 dbtower-lakehouse 실측 총정리

목차

0. 이 글 하나로

이 글은 dbtower-lakehouse 시리즈 다섯 편(1~5편)의 총정리입니다. 시리즈를 안 읽어도 이 한 편으로 전체가 파악되게 썼고, 깊이가 필요한 지점마다 해당 편과 저장소 파일을 링크했습니다. 면접에서 이 글 하나를 펴 놓고 화면을 짚어가며 설명할 수 있게 구성했습니다.

한 줄로 요약하면 DBTower가 7일 뒤 버리는 쿼리 스냅샷을, 버려지기 직전에 컬럼형 저장소로 내려(ELT) 장기 이력으로 만들고, 그 위에서 DBA가 매일 내리는 판정을 자동화한 데이터 파이프라인입니다. 오케스트레이션은 Airflow, 적재는 MinIO의 Parquet입니다. 변환은 dbt가 맡고 질의는 DuckLake로 합니다. 코드는 GitHub에 공개되어 있습니다.

이 프로젝트가 다루는 문제는 DB 운영(DBA·DBRE)의 일상과 그대로 겹칩니다. 어떤 쿼리가 지난달보다 느려졌는지, 실행 플랜이 언제 뒤집혔는지, 용량이 언제 임계에 닿는지, 백업이 며칠째 비었는지, 어떤 인덱스를 지워도 되는지, 어떤 파라미터 변경 뒤 성능이 나빠졌는지, 그리고 그 DB가 목표만큼 떠 있었는지. 라이브 관제 도구는 이 판정들을 구조적으로 못 합니다. 시야가 7일뿐이기 때문입니다. 그래서 장기 이력을 별도 계층에 쌓고, 그 계층이 판정 컬럼까지 계산하게 만들었습니다.

워크로드·규모 가정 한 줄

이 파이프라인의 설계 목표는 관리 대상 인스턴스 수백 대, 수년치(low-TB) 관측 이력을, 하루 한 번 배치로, 단일 노드 DuckDB 하나가 감당하는 것입니다. TPS를 다투는 서빙 API가 아니라 일 배치 분석 계층이라, 관심 지표는 초당 처리량이 아니라 “이력이 몇 배로 늘어도 매일 배치가 초 단위로 끝나는가”입니다. 이 가정을 두 축(시간축 1년치·인스턴스축 300대)으로 실측해 4절에서 검증합니다. 분산(Spark·다중 노드)은 이 선을 실측으로 넘기 전까지 이야기하지 않습니다.

숫자부터 놓고 시작하겠습니다. 전부 직접 측정했고, 재현 명령과 원천 대조가 담긴 기록(VERIFICATION.md)이 저장소에 있습니다.

항목수치
검증 원칙원천 PG = Parquet = DuckDB 3자 일치 (닫힌 창 07-05=149,259 · 07-06=79,894행)
멱등성같은 날짜 2회 실행 → 행수·오브젝트 수 불변 (79,894행 / 파티션 6개)
품질 게이트4축 fail-closed로 장애 주입 시 dbt를 아예 실행하지 않음 (exit 2)
lake→houseDuckLake 타임트래블·롤백·PG 카탈로그 격리 (과거 버전 행수·행 값 복원)
규모 병목1년치(2,190파일·54.5M행) 합성 실측 → fct 전체 재빌드 407.62s가 유일 병목
증분 전환그 수치가 정당화 → 407.62s → 4s (~100배)
셀프호스트--profile demo로 DBTower 없이 offload→게이트→dbt→발행 e2e, 번들 스택 외부 의존 0
판정층창고만 할 수 있는 판정 6종(용량 D-day·플랜 회귀·백업 공백·미사용 인덱스·설정 변경 상관·가용성 SLO), 발행 21테이블
가용성 실측최근 창 MSSQL 2대 63~64% breach(ping 2~7초) / PG·Mongo·Oracle 99.9% meets
테스트 / CIpytest 57 + dbt build PASS=129(unit test 다수·계약·데이터 테스트), GitHub Actions 3관문

측정 환경은 macOS + Docker 위의 로컬 스택입니다. 원천(DBTower)이 라이브로 수집 중이라, 검증은 값이 더 자라지 않는 닫힌 UTC 구간으로만 했습니다. 이 규율은 5절에서 다시 설명합니다.

1. 답할 수 없던 질문이라는 문제

DBTower를 석 달쯤 운영하고 나서, 누군가 물었다고 해봅시다. “지난달보다 이번 달에 느려진 쿼리 있어?”

답할 수 없습니다. DBTower의 쿼리 스냅샷은 7일 뒤 삭제됩니다. 이건 버그처럼 보이지만 의도된 설계입니다. 관제 도구가 관측 데이터를 무한히 쌓으면 메타 DB가 관리 대상보다 먼저 포화됩니다. 그래서 보존을 7일로 뒀습니다(SnapshotRetentionJob + retention-days: 7). AWS Performance Insights의 무료 티어가 정확히 7일 보존인 것과 같은 선례입니다.

문제는 관제의 시야가 “지금~최근 7일”이라는 점입니다. 이건 관제 도구로서는 올바른 설계입니다. 하지만 이런 질문들은 그 설계로는 구조적으로 못 답합니다.

  • “지난 분기 대비 가장 악화된 쿼리 TOP 10은?”
  • “이 인스턴스의 3개월 성장 추세로 볼 때 용량 증설 시점은?”
  • “기종별로 장기 레이턴시 분포가 어떻게 다른가?”

전부 장기 이력이 있어야 답할 수 있는 질문입니다. 판단은 실무에서 프로덕션 DB(OLTP)와 데이터 웨어하우스(분석)를 분리하는 그 원칙 그대로입니다. 관제(DBTower)는 지금에 최적화된 채로 두고 장기 분석은 별도 계층으로 뺍니다. 관제 DB에 무거운 분석 쿼리를 얹으면 관제 자체가 느려지고, 보존을 늘리면 메타 DB가 포화됩니다. 두 워크로드는 접근 패턴(짧은 최신 조회 vs 긴 전체 스캔)도 보존 정책도 달라서, 한 저장소에 억지로 두면 둘 다 손해입니다. 방법은 버려지기 직전의 스냅샷을 컬럼형 저장소로 내리는 것, 데이터 엔지니어링에서 ELT(Extract, Load, Transform)라 부르는 흐름입니다.

query_snapshot을 Airflow로 추출·적재하고 dbt로 집계해 DuckDB/DuckLake로 질의하며 그 사이에 데이터 품질 게이트를 둔 dbtower-lakehouse 파이프라인 구조

즉 이 프로젝트는 버려지는 관측 데이터의 두 번째 삶입니다. DBTower가 7일 만에 버리는 데이터를, 분석계가 받아 오래 기억하는 것입니다. 스택은 2026년 기준 소규모 팀의 정석 계보(Airflow + dbt + Parquet/객체스토리지 + DuckDB + DuckLake + Metabase)이고, 각 선택의 근거는 뒤에서 수치로 답합니다.

단계 관점으로 다시 그리면 셋으로 접힙니다. 추출·적재(EL)가 원천을 다치지 않게 내립니다. 검증과 변환은 게이트와 dbt의 몫이고, 발행부터 서빙·감시까지는 DuckLake와 Metabase가 받습니다. 각 단에 그 단만의 안전장치(자기파괴 가드, fail-closed 게이트, deadman)가 붙어 있는 게 이 파이프라인의 성격입니다.

파이프라인 흐름을 추출·적재, 검증·변환, 발행·서빙·감시 3단과 각 단의 안전장치로 나눠 그린 그림

구조를 한 단계 더 뜯어보면 이렇습니다. 컨테이너 경계와 포트(원천 PG 15432 · MinIO 19000 · Airflow 8080 · Metabase 13001), 다섯 태스크 체인 offload → quality_gate → transform → publish → heartbeat, 품질 FAIL 시 webhook으로 빠지는 분기, 성공 신호(heartbeat)를 역방향으로 감시하는 deadman, 주간 CHECKPOINT 유지보수까지 담은 것이 위 그림의 상세판입니다.

Airflow 태스크 체인과 FAIL 분기, MinIO 경로 레이아웃, DuckLake 카탈로그(PG)와 데이터(S3) 분리, Metabase read-only를 담은 상세 서버·데이터 아키텍처

데이터가 어떤 모양으로 흐르는지도 컬럼 단위로 그렸습니다. 원천 두 테이블(query_snapshot·database_instance)에서 raw parquet(dt·instance_id 파티션 키), staging의 SUM 정규화, fct_query_daily의 일간 델타, 그리고 mart_query_regression의 롤링 윈도우 컬럼과 운영 테이블(pipeline_run_log·pipeline_heartbeat)까지의 계보입니다. fct_query_daily의 일간 델타는 누적 카운터가 일간 발생량으로 접히는 변환 지점입니다.

원천에서 마트·운영 테이블까지의 컬럼 계보와 누적에서 일간 델타로 접히는 변환 지점을 표기한 데이터 모델

2. 파이프라인을 짓는 공정 (상황 → 만든 것 → 핵심 실측)

시리즈는 “어떤 상황에서 무엇이 깨지고, 그래서 무엇을 만드는가”의 개선 아크로 씁니다. 전/후가 있는 건 전/후로 실측했습니다. 표의 단계 번호는 저장소 ROADMAP의 아크 번호라서 블로그 편 번호와 다릅니다. 스물한 단계를 본편 다섯 편(1~5편)으로 묶었고 각 행에 해당 편을 링크했습니다. 아래 표는 파이프라인을 짓는 단계(1~10)까지고, 그 뒤 셀프호스트와 판정층은 3절 이후에서 이어서 짚습니다.

단계상황만든 것핵심 실측
1 EL: 1편6일 뒤 삭제 전 안전하게 내려야인덱스 선두를 타는 인스턴스별 추출 + 파티션 통째 덮어쓰기원천·적재·조회 3자 일치, 같은 날짜 2회 실행에 79,894행·오브젝트 6 불변
2 변환: 1편누적값 raw로는 “느려진 쿼리”에 답 못 함하루 first-vs-last 차분 + GREATEST(0,…) 리셋 클램프지문 충돌 12,743키를 SUM으로 접고, 순리셋 219그레인을 0에 클램프(음수 0건)
3 품질 게이트: 1편반쪽 파티션 위 랭킹이 조용히 오답reconciliation·completeness·freshness·스키마 드리프트 4축 fail-closedinst 3 파티션(20,158행) 삭제 주입 → 2축 동시 FAIL, dbt 미실행(exit 2)
5 DuckLake: 1편덮어쓰기만으론 ACID·타임트래블 없음PG 카탈로그 + S3 데이터 테이블 포맷과거 버전 행수·행 값 복원(0.55→1000.55), ROLLBACK로 229,153 원복
6 운영 경화: 1편막았는데 아무도 모름 + transform이 수동컨테이너 내 dbt e2e·webhook·retry·CHECKPOINT3태스크 한 컨테이너 success, CHECKPOINT 스냅샷 11→2(행수 불변)
4·7 대시보드: 1편마트는 있는데 소비자가 없음Metabase가 DuckLake를 read-only로DuckDB 파일 직결 실격 판정, 악화 랭킹 instance 8 +149.1% 3자 대조
8 감사 소탕: 2편장치가 자기 원칙을 자신엔 안 씀아카이브 자기파괴 가드·게이트 인덱스·발행 원자성게이트 Seq Scan 332ms → Index 20ms, pytest 35
9 신뢰: 2편테스트는 로컬 자산, 침묵은 못 잡음CI 3관문·deadman heartbeat·dbt contracts30h 침묵 경보 발화, 계약 위반 주입 시 빌드 차단(data type mismatch)
10 규모: 3편며칠치로는 “버틴다”를 증명 못 함365dt 합성 → 증분 전환·롤링 윈도우·운영 대시보드fct 전체 재빌드 407.62s → 4s, 파일 평균 177KB(128MB 타깃의 1/741)

아래에서 각 공정의 핵심을 DB 관점의 원리까지 짚습니다.

부하가 되지 않게 내리는 EL, 그리고 인덱스 선두 컬럼

DBTower의 “진단이 부하가 되면 안 된다” 원칙을 파이프라인에도 옮겼습니다. 추출은 관측 전용 메타 PG에서만, 세션을 readonly=True로 열어 쓰기 자체를 차단하고 운영 대상 DB(mysql/oracle 등)는 건드리지 않습니다. 분석계가 운영계를 절대 오염시키지 않는다는 선이 이 프로젝트 전체의 안전 논거입니다.

추출 쿼리를 어떻게 짜느냐가 원천 부하를 가릅니다. 원천 인덱스는 idx_snapshot_instance_time(instance_id, captured_at) 복합 인덱스입니다. 복합 B-Tree 인덱스는 선두 컬럼(instance_id)부터 정렬되므로, WHERE captured_at BETWEEN … 처럼 선두를 건너뛴 조건은 인덱스 선두를 못 타고 Seq Scan으로 떨어집니다. 그래서 전 인스턴스를 한 번에 긁는 대신 인스턴스별 등치 조건(instance_id = ?)으로 루프해 매 쿼리가 선두 컬럼을 타게 했습니다. 결과 전체를 클라이언트 메모리에 올리지 않도록 서버사이드 커서로 스트리밍해서, 인스턴스 수와 무관하게 메모리 사용이 일정합니다.

멱등성은 닫힌 구간에서 검증했습니다. dt=2026-07-06을 두 번 돌려도 원천 PG count·parquet·DuckDB가 전부 79,894행으로 불변이고 파티션 오브젝트도 인스턴스당 1개(총 6개)로 누적되지 않습니다. 파티션을 통째로 덮어쓰기 때문에 backfill이 안전합니다. Airflow 스케줄러 e2e(airflow dags test)까지 원천→parquet→조회가 정확히 흘렀고(dt=07-05 = 149,259행 일치), data interval 정렬로 “어제”의 의미가 어긋나지 않는 것도 실증했습니다.

Airflow에서 offload부터 heartbeat까지 다섯 태스크가 순서대로 성공한 DAG 실행 그래프

내려온 결과는 MinIO에 raw/query_snapshot/dt=…/instance_id=…/part-000.parquet 형태의 hive 파티션으로 쌓입니다. 파티션 키를 경로에 박아 두면 뒤에서 특정 dt만 읽을 때 파일을 열지 않고 경로만으로 걸러낼 수 있습니다. 이 파티션 프루닝이 6절 증분 전환의 핵심 지렛대가 됩니다.

MinIO 콘솔에 dt·instance_id로 파티션된 raw parquet 오브젝트가 쌓인 모습

적재가 맞았는지는 원천 PG의 행수, parquet 행수, DuckDB로 다시 읽은 행수를 나란히 세어 셋이 정확히 같은지로 확인합니다. 이 3자 일치가 “유실도 중복도 없다”의 정의입니다.

원천 PG count와 DuckDB로 읽은 parquet count가 같은 값(79,894)으로 일치하는 대조 출력

누적 스냅샷을 일간 델타로 바꾸는 변환

raw를 시간순으로 늘어놓으면 calls가 302→55→302→56처럼 감소가 섞여 가짜 리셋으로 보입니다. 원인은 (instance_id, query_id, captured_at) 중복 12,743키입니다. 같은 지문에 둘 이상의 누적 계열이 얽혀 있습니다(“SHOW REPLICA STATUS”가 302 계열과 55 계열로 동시 존재). id는 스냅샷마다 새로 발번되는 전역 PK라 계열 식별자가 못 됩니다. 해법은 staging에서 지문·시점별로 누적값을 SUM해 단조 비감소 계열을 복원하는 것.

차분 방식은 하루 first-vs-last(양 끝 차분)를 택했습니다. DBTower ComparisonServiceMath.max(0, end.calls - start.calls)와 같은 원리라 교차검증이 됩니다. 대안인 인접 델타 합산(Prometheus rate 방식)은 쿼리가 사라졌다 재등장할 때 유령 증가분을 과대계상합니다. 실측으로 총 delta_calls가 22,264,704 vs first-vs-last 3,126,579로 벌어졌습니다. SUM-dedup 후에도 남는 순리셋 219그레인은 GREATEST(0,…)로 0에 클램프했고 결과 최솟값이 0(음수 0건)인 것으로 확인했습니다. dbt는 staging(뷰)→fct→mart 3계층이라 변환 로직이 한 군데 SQL 파일로 모여 있고, 계보가 문서로 자동 생성됩니다.

staging에서 fct_query_daily, mart_query_regression까지 이어지는 dbt 모델 계보 그래프

이 변환이 실제로 1절의 질문에 답하는 마트를 만듭니다. 인스턴스와 쿼리별로 구간 첫날 대비 마지막 날 평균 지연을 비교해 악화 순으로 정렬한 결과입니다.

dbt가 구운 악화 쿼리 랭킹 마트 결과. instance 8의 쿼리가 상단에 오른 표

품질 게이트, 조용히 틀린 데이터는 없는 것보다 나쁘다

수집 장애로 빈 파티션 위에 만든 “악화 쿼리 랭킹”은 조용히 오답을 냅니다. 다운스트림(dbt) 앞에 검문소를 세웠습니다. 네 축입니다. reconciliation(원천 PG 행수 == parquet 행수), completeness(레지스트리 기대 인스턴스가 전부 존재), freshness(수집 중단 탐지), 스키마 드리프트(유실·타입 변경 FAIL)입니다.

동작은 fail-closed입니다. 한 dt라도 FAIL이면 dbt를 아예 호출하지 않고 종료코드 2로 빠집니다. 장애 주입으로 dt=07-06의 instance_id=3 파티션(20,158행)을 통째로 삭제하자, reconciliation(79,894 vs 59,736)과 completeness(누락 [3])가 동시에 잡고 dbt가 SKIPPED됐습니다. 정상 dt(07-05·07-07)는 통과, 문제 dt만 차단. Airflow에서도 quality_gate 태스크가 예외를 던져 transformupstream_failed로 실행되지 않는 것을 실측했습니다.

품질 게이트가 정상은 통과시키고 장애 주입 시엔 FAIL로 차단하는 모습

Airflow에서 quality_gate가 FAIL이 되자 transform이 upstream_failed로 실행되지 않은 DAG 그래프

lake를 house로 끌어올린 DuckLake와 스냅샷 격리

raw 파티션 덮어쓰기는 정확·멱등하지만 ACID도 타임트래블도 없어 엄밀히는 “lake”입니다. 그 위에 테이블 포맷 DuckLake를 얹었습니다. 카탈로그는 PostgreSQL(로컬에 이미 PG가 있어 서비스 추가 0, 단 DBTower 메타 DB와 분리된 ducklake_catalog), 데이터 파일은 MinIO(S3). 카탈로그 테이블 30개가 PG에 생기고 DBTower 메타 DB 안의 ducklake_% 테이블은 0개라 원천 메타는 오염되지 않습니다.

타임트래블이 lake와 house를 가르는 지점입니다. 카탈로그가 스냅샷(버전)마다 어떤 parquet 파일 집합이 그 시점의 테이블인지를 기록하기 때문에, 같은 테이블·같은 쿼리인데 버전 지정(AT (VERSION => n))만으로 과거 상태를 그대로 되살렸습니다.

count @ v2 (07-06만 적재 직후) = 79,894
count @ v3 (07-05까지 적재 직후) = 229,153
total_time_ms @ v3(과거) = 0.55 -- UPDATE 이전
total_time_ms @ v4(현재) = 1000.55 -- UPDATE 이후

DuckLake에서 같은 테이블을 버전별로 조회해 과거 값과 현재 값이 다르게 나오는 타임트래블 실행 결과

원자성도 확인했습니다. 트랜잭션 안에서 149,259행을 지워도 ROLLBACK 하면 229,153행으로 흔적 없이 되돌아가고 스냅샷(버전)도 남기지 않습니다. raw 덮어쓰기로는 불가능했습니다. 이 스냅샷 격리는 서빙에서 한 번 더 값을 합니다. 발행이 쓰는 중에도 읽는 쪽은 커밋 전이나 후의 온전한 버전만 보기 때문에, 파일 잠금 없이 읽기와 쓰기가 공존합니다.

운영 경화, 실패해도 아무도 모르는 파이프라인은 미완성

fail-closed 게이트가 반쪽 데이터를 잘 막았는데, 막았다는 사실을 아무도 모르면 마트가 조용히 낡아갑니다. 게다가 5단계까지 transform 태스크는 컨테이너에 dbt가 없어 실제 빌드가 호스트 수동 실행이었습니다. DAG는 3단계인데 마지막이 사람 손이면 반쪽 오케스트레이션입니다. Dockerfile로 분리 venv(/opt/dbt-venv)에 dbt-duckdb를 얹어 세 태스크가 처음으로 한 컨테이너 안에서 끝났습니다.

offload, transform, publish 세 태스크가 한 컨테이너 안에서 순서대로 성공한 e2e DAG 실행

차단에 통보를 붙였습니다. on_failure_callback으로 webhook에 JSON을 POST하고(로컬 수신기에 HTTP 200 실수신 확인), retry는 지수 백오프(2→4→8분)를 두되 quality_gateretries=0(품질 FAIL은 결정적이라 재시도 무의미). 모든 PG DSN에 connect_timeout=5를 넣어 원천이 죽었을 때 무한 대기 대신 5초에 실패시켰는데 이건 실제로 원천 스택 다운 시 무한 대기 사고를 겪고 넣은 방어입니다. DuckLake CHECKPOINT(@weekly)는 churn을 만든 뒤 스냅샷 11→2, S3 오브젝트 7→3, 바이트 7,337,540→2,828,889로 정리하면서 테이블 행수는 229,153으로 불변인 것을 전/후 대조했습니다. 이 파이프라인의 운영 상태(성공·실패·소요·행수)는 로그 마트로 남아 한 화면에서 읽힙니다.

파이프라인 실행 로그와 태스크별 상태·소요를 모은 운영 대시보드

마트에 소비자를 붙이는 대시보드, 그리고 파일 직결이 실격인 이유

1절의 질문을 던진 사람은 DuckDB 셸에 SQL을 치는 사람이 아닙니다. Metabase를 붙였는데 함정이 셋이었습니다. (1) 공식 이미지가 Alpine(musl)이라 DuckDB JDBC 네이티브 라이브러리가 안 떠서 Debian 기반 커스텀 이미지로 교체했고, (2) dbt의 DuckDB 파일 직결은 읽히긴 하지만 서빙 계층 실격입니다. DuckDB 파일은 프로세스 간 단일 쓰기(single-writer) 모델이라, 같은 호스트에선 transform이 파일 잠금 충돌로 죽고, 컨테이너 경계(virtiofs)에선 잠금이 전파되지 않아 쓰기 도중 읽기가 무방비가 됩니다(시끄럽게 죽는 것보다 나쁨). 그래서 마트를 DuckLake로 발행하고 Metabase는 DuckLake만 읽게 해 동시성 중재를 파일 잠금에서 PG 트랜잭션(스냅샷 격리)으로 넘겼습니다.

수치는 세 경로(DuckDB 파일 직독·Metabase API·대시보드 화면)로 대조했고 전부 일치했습니다. 악화 1위는 instance 8로 first→last 25.89→64.50ms, +149.1%였습니다. 발행(쓰기) 중 0.3초 간격 연속 41회 읽기가 전부 온전한 22행을 봤고(DuckLake 스냅샷 격리), 파일 케이스와 정반대 결과입니다.

Metabase 대시보드에 뜬 악화 쿼리 랭킹과 일별 추이

Metabase에서 특정 인스턴스로 필터링한 악화 쿼리 상세 화면

3. 심층 사례 1: 아카이브가 자기 자신을 지우는 경로

포트폴리오용 요약은 접고 실제 결함 하나를 통째로 보여드리겠습니다(2편 상세). 내가 만든 걸 스스로 감사하다 잡은 것 중 제일 무서운 결함이었습니다. DBA에게 “백업이 스스로를 지운다”만큼 위험한 문장은 없습니다.

정상 상태에서 offload는 멱등을 위해 파티션을 통째로 덮어씁니다. delete-first, 즉 기존 파티션 오브젝트를 지우고 새로 씁니다. 원천에 데이터가 있는 한, 지운 자리에 같은 데이터가 다시 써지니 안전합니다.

문제 상황은 이렇습니다. 원천 보존(7일) 밖의 dt를 backfill이나 Clear로 재실행하면 어떻게 되는지 봅시다. 그 dt는 원천 PG에 이미 0행입니다. 하지만 MinIO의 parquet는 유일본으로 남아 있습니다(그게 아카이브의 존재 이유니까). 그런데 코드에서 _delete_prefix()if table is None: continue(원천 0행이면 스킵)보다 먼저 실행됐습니다.

INFO 기존 파티션 오브젝트 1개 삭제 (raw/query_snapshot/dt=2026-06-01/instance_id=1/)
INFO instance 1: 해당 날짜 데이터 없음 → 스킵
INFO 적재 완료 dt=2026-06-01 총 0행 # exit 0 — '성공'
# after: (dt=2026-06-01 파티션 아래 오브젝트 없음) ← 유일본 소멸, 복구 불가

유일본을 지우고 아무것도 안 쓰고 성공(exit 0)으로 끝납니다. 아카이브의 존재 이유를 정면으로 부정하는 치명 결함입니다. 실데이터와 무관한 dt=2026-06-01에 가짜 파티션(3행, 2,665바이트)을 심어 재현했습니다(원천은 읽기 전용 유지).

구조적 원인을 보면 멱등 덮어쓰기의 delete→write는 “쓸 데이터가 반드시 있다”를 암묵 전제합니다. 보존 창 안에서는 참이지만, 창 밖에서는 그 전제가 깨지면서 delete만 남고 write가 사라집니다. fail-closed 원칙을 게이트(읽기 경로)에는 적용했으면서, 정작 쓰기 경로에는 적용하지 않은 뒷면이었습니다.

수정했습니다. 원천 0행 + 파티션 존재 시 삭제 없이 시끄럽게 실패시킵니다.

ArchiveSelfDestructError: 원천 0행인데 기존 파티션 오브젝트가 존재 — 보존 창 밖
재적재로 판단. 이 파티션이 유일본일 수 있어 삭제를 거부한다(fail-closed). ...
# exit 1 → Airflow 재시도·webhook 알림 경로 탑승
# after: s3://lakehouse/raw/.../part-000.parquet 2665 bytes ← 유일본 보존

원천 0행 + 파티션도 없음 → 기존처럼 스킵. 원천 N행 → 기존 멱등 경로 그대로. 세 갈래를 갈라 위험한 한 갈래만 fail-closed로 막았습니다. 같은 감사 라운드에서 게이트 자신이 원천을 Seq Scan하던 결함(EXPLAIN으로 확인, 332ms → Index Only Scan 20ms)과 발행 혼합 버전 결함(개별 커밋 → 단일 트랜잭션)도 잡아 pytest 35개로 고정했습니다. 게이트가 원천을 무겁게 세면 게이트 자체가 원천 부하가 되니, 검문소마저 인덱스를 타야 한다는 게 이 라운드의 교훈이었습니다.

4. 심층 사례 2: 며칠치로는 “버틴다”를 증명하지 못하는 규모 문제

9단계까지 모든 실측은 닫힌 dt 3개(수십만 행)에서 돌았습니다. 그 규모에선 전부 초 단위라 “규모에서도 버틴다”고 말하고 싶어지는데, 며칠치 수치로는 증명이 안 됩니다. 희망일 뿐입니다. fct 마트는 매일 O(전체 이력)을 다시 계산하는데, 이력이 3일이라 안 아팠을 뿐입니다. 그래서 1년치를 실제로 만들어 어디가 먼저 무너지는지 쟀습니다(3편).

닫힌 dt parquet를 날짜 시프트 복제해 365dt × 6인스턴스 = 2,190파일(54,479,535행, 396.6MB)을 격리 프리픽스에 생성했습니다(실데이터·원천 무접촉, 끝나고 2,196 오브젝트 전부 정리).

단계규모(365dt·2,190파일)병목
dbt fct 전체 재빌드407.62s1순위
DuckDB 전체 글롭+count3,471ms (54.5M행)O(이력)
S3 list_objects_v2 전체2,182ms글롭
dbt mart 재빌드0.31s아니오(사전집계)
게이트/verify per-dt8–22ms아니오(O(1 파티션))
DuckLake CHECKPOINT(1년 커밋)0.47s (366→1파일)아니오
파일 크기평균 177KB128MB 타깃의 1/741

병목은 명백히 fct 전체 재빌드(407.62s) 하나입니다. 나머지는 규모에서도 초 단위입니다. mart는 사전집계라 0.31s, 게이트는 dt별 파티션만 봐서 이력과 무관, CHECKPOINT는 1년치 366커밋이 쌓은 소파일을 1파일로 컴팩션하는 데 0.47s입니다. 파일 평균 177KB는 실무 합의 타깃(128MB~1GB)의 1/741로 소파일 폭증이 있지만, 그 고통은 글롭 리스팅과 커밋 누적으로 나타나고 후자는 CHECKPOINT가 값싸게 흡수합니다.

수치가 요구하니 전환했습니다. 전체 재빌드 407.62s는 매일 돌리면 날마다 7분씩 쓰는 셈입니다. 그런데 fct의 grain은 dt 단위로 완전 독립(하루 발생량 = 그날 파티션 양 끝 차분)이라 새 dt만 계산해도 결과가 같습니다. delete+insert + unique_key=(instance,query,dt)로 증분화했습니다. 함정이 하나 있었습니다. where dt >= (select max(dt) from {{ this }}) 스칼라 서브쿼리로는 DuckDB가 hive 파티션 프루닝을 못 해 2,190파일을 전부 스캔했습니다(증분인데 2분+ 타임아웃). 파티션 프루닝은 쿼리 플래너가 컴파일 시점에 파티션 경로를 보고 읽을 파일을 걸러내는 최적화인데, 서브쿼리 값은 실행 시점에야 정해져 플래너가 못 씁니다. 워터마크를 run_query컴파일 타임 리터럴로 구워 넣자 파티션 경로 프루닝이 걸려 최신 dt만 읽었습니다.

fct 전체 재빌드fct 증분(1 dt 추가)
소요(wall)407.62s4s
읽는 파일2,190워터마크≥ dt만(~6–12)

100배+. microbatch 전략은 event_time 필수·unique_key로 파티션 교체 불가 제약이 있어 delete+insert를 택했고, 반대로 mart는 규모에서도 0.31s라 증분화하지 않았습니다. 초 단위인 곳을 최적화하면 복잡도만 늘어납니다. 문제 없는 곳은 손대지 않는 것도 판단입니다.

규모의 축은 시간 하나가 아닙니다. 몇백 대를 관제하는 쪽에서 늘어나는 건 dt가 아니라 인스턴스 수(N)라, 총량을 고정하고 축만 돌려 다시 쟀습니다. 인스턴스축 300대(52.2M행)에서도 증분 fct는 8.03초로 건재했고, 급소는 소파일이 아니라 full-refresh(769초)라는 게 드러났습니다. 로드맵에 외삽해 뒀던 “소파일이 먼저 무너진다”가 틀렸음을, 축을 돌린 실측이 바로잡았습니다.

규모 실측을 시간축(dt)과 인스턴스축(N)으로 나눠, 총량을 고정하고 축만 돌린 설계를 그린 그림

규모 가정을 한 줄로 세우면 이렇습니다. 인스턴스 수백 대, 수년치(low-TB) 이력을 단일 노드 DuckDB로 감당하는 것이 설계 목표입니다. 근거는 두 축 실측입니다. 시간축 1년치(54.5M행)에서 증분 fct가 4초, 인스턴스축 300대(52.2M행)에서도 증분이 8.03초로 버텼습니다. 서빙 동시성은 발행(쓰기) 도중 0.3초 간격 41회 연속 읽기가 전부 온전한 결과를 본 것으로 확인했습니다(DuckLake 스냅샷 격리). 이 규모에서 병목은 처리량이 아니라 full-refresh 하나였고, 그건 증분으로 우회했습니다. 측정 스펙은 macOS + Docker 로컬 스택이고, 이 선을 실측으로 넘기 전까지 분산은 이야기하지 않습니다.

5. 창고만 할 수 있는 여섯 가지 판정 (DBA·DBRE 본진)

여기서 프로젝트의 성격이 한 번 바뀝니다. 창고가 데이터를 내리기만 하던 데서, 판정을 내리기 시작했습니다. 재료는 이미 다 있는데 판정 컬럼까지 이어 붙이지 않았을 뿐이었습니다. 라이브 7일 창으로는 구조적으로 못 하고 장기 창고라야 가능한 판정 여섯을 판정 편에서 채웠습니다. 여섯 판정은 전부 DBA·DBRE가 손으로 하던 일이고, 창고가 있으면 컬럼으로 자동화됩니다.

한 가지 원칙을 판정층 전체에 걸었습니다. 창고는 판정 컬럼까지만 계산하고 발화(알림)는 하지 않습니다. 알림은 Metabase나 DBTower의 몫으로 두어, 두 번째 알림 시스템을 만들지 않습니다. 판정은 조언 어휘로만 싣고, 상관을 인과로 단정하지 않습니다.

플랜 회귀 (execution plan regression)

같은 쿼리가 어느 날 갑자기 느려지는 흔한 원인은 옵티마이저가 실행 플랜을 바꿔 잡는 것입니다(통계 갱신·데이터 분포 변화로 인덱스 스캔이 풀 스캔으로 뒤집히는 식). 라이브 관제는 “지금 느리다”까지는 보지만 “언제 플랜이 뒤집혔다”는 7일 밖이라 못 짚습니다. 판정 마트는 일 단위 대표 플랜을 뽑아 뒤집힌 날을 찾고 전후 N일 지연을 비교합니다. 지어내지 않는 규율을 컬럼에 박았습니다. 관측이 덜 차면 PENDING, 비교창이 오염되면 AMBIGUOUS로 남깁니다.

플랜 회귀 판정 개념도. 대표 플랜이 뒤집힌 시점을 기준으로 전후 지연을 비교하는 구조

용량 D-day (capacity planning)

크기 시계열의 선형 추세로 임계까지 남은 잔여일을 냅니다. DBA가 “이 인스턴스 언제 증설해야 하나”를 감이 아니라 추세로 답하게 하는 판정입니다. 라이브 7일로는 추세선을 그릴 표본이 없어 구조적으로 불가능하고, 장기 창고라야 기울기가 의미를 갖습니다.

백업 공백 (backup RPO)

복구 지점 목표(RPO)를 지키려면 “며칠째 백업이 없는가”를 인스턴스별로 알아야 합니다. 함정은 유니버스입니다. 백업 테이블만 보면 백업을 한 번도 안 돌린 인스턴스는 행 자체가 없어 목록에서 조용히 빠집니다. 그래서 유니버스를 query 팩트(살아 있는 모든 인스턴스)에서 잡아 기록 없는 인스턴스도 행으로 드러냈습니다. 기준일은 벽시계가 아니라 창고 최신 dt로 잡아, 파이프라인이 멈춘 것과 백업이 멈춘 것을 섞지 않습니다.

미사용 인덱스 (index hygiene)

안 쓰는 인덱스는 쓰기마다 갱신 비용만 물리고 저장만 차지합니다(write amplification). 하지만 “지금 안 쓴다”고 지우면 월말 배치나 분기 리포트가 쓰던 인덱스를 날릴 수 있어, 짧은 창의 판단은 위험합니다. 90일 창의 실사용으로 “지워도 되나”를 판정하되, 삭제 지시가 아니라 후보까지만 냅니다. 최종 삭제는 사람의 몫으로 둡니다.

설정 변경 상관 (config drift correlation)

“어떤 파라미터를 바꾼 뒤 성능이 나빠졌다”는 DBA의 단골 추적입니다. 설정 스냅샷을 장기로 쌓아 파라미터가 바뀐 시점과 성능이 뒤따라 나빠진 시점을 겹쳐 원인 후보를 지목합니다. 라이브에서 인스턴스 2와 4가 work_mem을 4096과 8192 사이로 오간 변경이 2건씩 잡혔고, 나머지 다섯은 변경 0건인데도 수집은 정상(cycles_collected 23)이라 “무변경”과 “미수집”이 한 표에서 갈립니다. 상관이지 인과가 아니라 조언 어휘로만 싣습니다.

설정 변경 상관 개념도. 파라미터 변경 타임라인과 성능 회귀를 겹쳐 원인 후보를 지목하는 구조

Metabase 설정 드리프트 대시보드. 변경 타임라인과 영향 상관 표, 일별 수집·변경 표를 담은 실화면

상관은 플랜 뒤집힘 한 축에서 지연·볼륨 축까지 넓혔습니다. 원인 후보가 성능을 어느 방향(지연 증가·호출량 급증)으로 밀었는지를 함께 봐야 오판(회귀를 개선으로 착각하는 함정)을 피합니다.

변경 영향을 지연·볼륨 축으로 넓혀 회귀와 개선을 가르는 개념도

변경 영향 판정 대시보드 실화면

가용성 SLO와 에러버짓 (availability SLO)

1분 핑을 하루로 접어 최근 30일 uptime을 목표와 견주고 SRE 에러버짓을 냅니다. 목표 99.9%면 한 달에 허용되는 다운타임 예산이 정해지고, 남은 예산이 음수면 이미 목표를 넘긴 것입니다. 판정은 목표 미달이면 breach, 버짓을 거의 소진했으면 at_risk, 그 외는 meets입니다. 여기서도 발화는 안 합니다. SLO 판정까지만 계산하고 다운 알림은 관제의 몫입니다.

가용성 판정이 특히 곧바로 켜졌습니다. 상관 마트들은 후행 관측이 쌓여야 의미가 차는데, 가용성은 연속 측정이라 실데이터가 바로 말을 합니다. 라이브에서 MSSQL 두 인스턴스가 최근 창 63.45%·64.28% 가용성에 평균 ping 2132ms·6897ms로 목표 99.9에 한참 못 미쳤고, PostgreSQL·Mongo·Oracle은 99.9%로 목표를 지켰습니다. 어느 기종이 지금 문제인지가 숫자로 바로 보입니다.

가용성 SLO 개념도. 1분 핑을 하루 uptime으로 접고 최근 30일을 목표와 견줘 에러버짓을 내는 구조

Metabase 가용성 SLO 대시보드 실화면. instance_name과 engine이 붙어 local-mssql은 최근 창 63.45퍼센트에 평균 ping 2132밀리초, mssql-pitr는 64.28퍼센트에 6897밀리초로 목표 99.9에 못 미치고, PostgreSQL과 Mongo와 Oracle은 99.9퍼센트대로 목표를 지킨다

기종 축과 주간 보고

이 판정들이 한 화면에서 읽히도록 인스턴스 차원(dim_instance)을 붙여, 그동안 여러 마트가 “기종은 저쪽에서 보세요”라고 달던 각주를 회수했습니다. instance_id 1local-mysql (MYSQL)로 읽히고, 대기 이벤트가 기종과 나란히 섭니다. 이 판정들은 주간 운영 보고 한 장으로도 접혀, 사람이 매주 손으로 뽑던 리포트를 마트가 대신 굽습니다.

기종(engine) 축을 붙여 인스턴스별 판정을 기종과 나란히 세운 개념도

기종 축이 붙은 주간 운영 보고 대시보드 실화면

플랜 회귀·백업 공백·용량을 한 장으로 접은 주간 운영 보고 대시보드

저빈도 감사 데이터(스키마 변경 이력 등)를 재료로 하는 change_review 판정은 자리만 열어 두고 내리지 않았습니다. 관측이 너무 드물어 판정으로 세우면 대부분 PENDING만 나오기 때문입니다. 안 내리는 것도 판정입니다. 그리고 가용성을 가져오기 전에 DBTower 원천 테이블 29개를 전부 대조해, 이 창고가 가져올 것과 관제·인프라의 몫으로 남길 것을 갈랐습니다.

6. 남이 그대로 띄우는 셀프호스트와 두 저장소의 되쓰기

여기까지는 “로컬에서 재현된다”고 써 왔는데, 남이 clone하면 아무것도 안 떴습니다. 원천이 없어 인스턴스 0개로 조용히 빈 결과가 나기 때문입니다. 그래서 데모 위성을 떼어내 셀프호스트 어플라이언스로 만들었습니다. docker-compose.standalone.yml--profile demo로 DBTower 없이도 offload부터 게이트(4축), dbt run, 발행까지 e2e가 돌고, 격리 실측에서 외부 의존은 0이었습니다. 범용 도구로 벌리지는 않았습니다. DBTower를 셀프호스트하는 사람이 자기 관제 옆에 이 창고를 같이 띄우는 것까지가 목표라, 초점을 좁힌 결정입니다.

데모 위성을 떼어내 DBTower 없이도 단독으로 도는 셀프호스트 어플라이언스 구성도

standalone 프로필로 DBTower 없이 부팅한 Airflow와 Metabase 첫 화면

그다음 두 저장소가 손을 잡았습니다. 원천을 query_snapshot 하나만 내리던 걸 테이블 스펙 레지스트리로 일반화해 백업·플랜·대기 이력까지 편입하고, 방향이 반대인 일도 하나 더 했습니다. 장기 dow×hour 베이스라인을 계산해 DBTower의 이상 감지 쪽으로 되쓰는(writeback) 경로입니다. 원천 readonly 봉인을 깨지 않으려 별도 역할에 해당 테이블만 권한을 주고 단일 트랜잭션으로 32,498행 왕복을 실측했습니다. 그 역할로 query_snapshot을 읽으면 permission denied가 나는 것까지 확인해, 분석계가 운영계를 오염시키지 않는다는 안전 논거를 지키면서 두 계층을 이었습니다.

7. 커밋마다 검증되고 침묵해도 잡히는 신뢰

8단계에서 pytest 35개로 로직을 고정했지만 그건 로컬 자산입니다. 내 노트북에서만 돕니다. 세 구멍을 닫았습니다(2편).

CI 3관문으로 GitHub Actions가 커밋마다 ruff·pytest·dbt(deps/parse/build)를 강제합니다. 이 스택의 강점이 CI에서 드러납니다. 쿼리 엔진 DuckDB가 임베디드라 MinIO·PG 없는 러너에서 tiny 픽스처 parquet 몇 장으로 staging→fct→mart를 실제로 짓고 데이터 테스트·계약·unit test까지 한 번에 돕니다(dbt build PASS=25→26). dbt unit test는 이 프로젝트의 심장(누적→일간 델타)을 정적 입력→기대 출력으로 고정했습니다. first-vs-last 차분과 순리셋 클램프, 하루 1스냅샷이면 델타 0이 되는 경계, 지문 충돌 SUM, 롤링 윈도우. 이렇게 5건입니다.

deadman heartbeat입니다. 기존 알림은 “실패하면 운다”뿐이라 태스크가 시작조차 못 하면(스케줄러 death·DAG pause·원천 침묵) 아무도 안 웁니다. 성공 시 heartbeat를 카탈로그 PG에 남기고 “기한 내 갱신 없으면 경보”하는 역방향 감시를 넣었습니다. 30시간 침묵을 실제 경보 발화로, 한 번도 성공 기록이 없는 DAG도 경보로 잡았습니다. 한계가 하나 있습니다. Airflow 내 감시 DAG는 자기 스케줄러의 total death는 못 잡아 외부 cron 경로를 함께 뒀고, 그 외부 러너의 생존은 결국 조직 밖 상시 모니터(PagerDuty류)의 몫으로 남깁니다.

dbt contracts로 fct·mart에 contract: enforced: true + 컬럼 타입·CHECK 제약을 선언했습니다. dbt-duckdb가 DB 레벨로 실제 enforce하므로, 마트 컬럼 하나의 산출 타입을 바꿔 다운스트림 파괴를 시뮬레이션하면 CREATE TABLE 시점에 빌드가 막힙니다(data type mismatch, PASS=0 ERROR=1). 대시보드가 기대는 컬럼 타입이 조용히 바뀌는 경로를 발행 전에 끊습니다.

8. 정직성과 트레이드오프

이 프로젝트에서 기능만큼 공들인 게 “무엇을 수치로 쓸 수 있고 없는지”의 규율입니다. 관제·분석 도구의 출력은 운영 결정의 입력이 되므로, 과장된 수치는 버그보다 나쁘다고 생각합니다.

  • 닫힌 창만 수치로 쓴다. 원천(DBTower)이 실측 시점에 라이브로 수집 중이라 dt=07-07 같은 열린 창은 값이 계속 자랍니다(268,952 → 269,354 → 279,002). 재적재 직후 그 순간엔 PG=parquet로 맞지만 다음 순간 또 벌어질 수 있습니다. 그래서 안정 통과의 근거는 값이 불변인 닫힌 창(07-05=149,259 · 07-06=79,894)에만 뒀습니다.
  • DuckLake vs Iceberg. 표준은 Iceberg입니다. 하지만 DuckDB에서 Iceberg 쓰기는 REST 카탈로그 서버가 필수라(path 기반은 읽기 전용) 로컬 단일노드에 서비스가 하나 더 늡니다. DuckLake는 카탈로그를 이미 쓰던 PG에 SQL로 두고 데이터는 parquet라 서비스 추가가 0입니다. 타임트래블·스키마 진화 개념은 동일하므로 멀티엔진 연합이 필요해지는 시점의 Iceberg 전환은 어댑터 문제로 남깁니다. 이 규모엔 Iceberg가 과할 뿐, 지원되지 않아서 못 쓰는 게 아닙니다.
  • 비용도 트레이드오프의 한 축입니다. 이 스택은 클라우드 관리형 서비스를 하나도 쓰지 않습니다. 카탈로그는 이미 도는 PG를 재사용하고, 스토리지는 MinIO(로컬 S3 호환), 쿼리 엔진 DuckDB는 임베디드라 별도 서버가 없습니다. 같은 구성을 클라우드로 옮기면 관리형 카탈로그(Glue류)·객체 스토리지·서버리스 쿼리(Athena·BigQuery)가 각각 과금됩니다. 구체 단가는 계정·리전마다 달라 지어내지 않겠습니다. 대신 구조로 말하면, BigQuery는 스캔량 과금이라 fct가 매일 전체 이력을 다시 읽던 구조(스캔 54.5M행)를 그대로 옮겼다면 쿼리 비용이 이력에 비례해 늘었을 자리입니다. 증분 전환으로 읽는 파일을 2,190개에서 여섯 개 남짓으로 줄인 건 성능만이 아니라 스캔 과금까지 같은 배수로 줄이는 결정이었습니다. 로컬·단일 노드를 택한 이유가 재현성만은 아닙니다. 이 규모(low-TB·일 배치·소비자 하나)에서는 관리형의 운영비가 정당화되지 않기 때문이고, 비용이 성능을 정당화하는 시점이 오면 dbt 어댑터 교체로 클라우드로 옮겨집니다.
  • 자체 게이트 vs elementary. dbt 관측성 도구 elementary는 자체 4축 게이트 + webhook과 역할이 겹칩니다. 도구를 늘리기보다 게이트를 키웠습니다. dbt source freshness도 게이트 freshness 검문과 중복이라, 같은 판정을 두 군데서 내려 기준이 갈라지지 않게 게이트를 단일 진실로 뒀습니다.
  • 지문 충돌 SUM은 근사입니다. 서로 다른 물리 쿼리를 하나로 합치기 때문입니다. id로 계열을 온전히 분리하진 못합니다(id는 스냅샷마다 새로 발번). 지문 단위 ‘총 활동’까지가 정직한 한계이고, 그렇게 적었습니다.

Kafka 판정과 안 하기로 한 것

“왜 안 넣었는지 안다”가 “많이 넣었다”보다 강점이라고 생각합니다. 실무 실태를 웹 리서치로 조사해 근거를 박았습니다.

Kafka를 넣지 않는 것이 실무적 정답입니다. 조직 단위 채택률과 파이프라인 단위 실사용은 완전히 다른 그림입니다. 벤더 서베이는 높게 나오지만(Confluent 2025: “86%가 스트리밍 투자 우선순위”) 이는 조직 어딘가에서 쓴다는 뜻이지 파이프라인 다수가 스트리밍이란 뜻이 아닙니다. 시니어 패널은 “‘실시간’ 요구의 90%를 물리쳤다”고 하고, BigQuery 실사용 분석은 쿼리의 90%가 100MB 미만을 처리한다고 합니다. 채택 조건은 세 가지(①초~분 단위 신선도가 돈이 되고 ②동일 이벤트 소비자가 여럿이고 ③분산시스템 운영 여력이 있을 때)인데, 이 파이프라인은 셋 다 아닙니다. 스냅샷 원천이라 붙일 이벤트 스트림 자체가 없고, 일 단위 SLA에, 소비자는 하나입니다. 준실시간 신선도가 필요해지면 그때도 풀 Kafka 대신 Debezium Server(단독) 같은 경량 CDC가 2025~2026 실무 흐름이고, 그마저도 이 저장소 밖, 별도 수집 계층의 일입니다.

같은 논리로 안 하기로 한 것들과 이유입니다.

  • Spark는 단일 노드 DuckDB로 수년치 처리가 가능해 필요 없습니다(컬럼형 + 파티션 프루닝). 메모리·로컬 디스크 한계를 실측으로 넘으면 그때 이야기입니다.
  • Iceberg/Delta는 멀티엔진(Spark·Trino·Flink)이 한 테이블을 공유하는 조직 표준입니다. 단일 엔진(DuckDB) 규모엔 DuckLake가 맞고 전환은 어댑터 문제입니다.
  • OpenLineage/Marquez는 아직 필요 없습니다. 계보 소비자가 하나라 dbt docs의 문서 계보로 충분하기 때문입니다.
  • Cosmos(dbt→Airflow 태스크 분해)는 모델 3개짜리에서 태스크 그래프 분해라 오버헤드만 더합니다.
  • 클라우드 DW(BigQuery) 대신 로컬 재현성을 택했습니다. dbt 어댑터 교체로 이전할 수 있습니다.

그리고 이 로드맵이 실제로 향한 방향(CI·deadman·규모 실측·롤링 윈도우·contracts)은 서베이가 집계한 실무 고통 순위와 그대로 겹칩니다.

실무 고통 (근거 수치)이 프로젝트의 대응
1. 조용한 실패(스테이크홀더가 먼저 발견 74%, 해결 평균 15시간)4축 fail-closed 게이트 + webhook + deadman heartbeat로 ‘미실행’까지
2. 스키마 변경이 다운스트림 파괴드리프트 게이트 + dbt contracts로 발행 전 빌드 차단
3. 비용 통제스캔량·저장량 대응물로 CHECKPOINT가 파일·바이트 절감 실측
4. 백필/멱등파티션 덮어쓰기 멱등 + backfill 실증 + 자기파괴 가드
5. 작은 파일/파티션 폭증 (최대 4배 저하)DuckLake CHECKPOINT 컴팩션 + 규모 실측(177KB 계측)

9. 커버리지와 남은 한계

한계를 먼저 적는 쪽이 신뢰를 만든다고 생각해, 못 한 것과 안 한 것을 명시합니다.

  • 원천 부분 유실입니다. 자기파괴 가드는 “원천 0행 vs 파티션 존재”까지만 봅니다. 0은 아니지만 급감하는 부분 유실은 여전히 덮어씁니다(게이트 reconciliation의 사후 탐지 영역). 쓰기 전 행수 급감 거부는 정당한 감소와의 오탐 트레이드오프라 미결로 뒀습니다.
  • 통계적 이상 감지도 남습니다. 품질 게이트는 규칙 기반(정합·완결성·freshness·드리프트)까지입니다. 실패 통보는 webhook으로 닫았지만 통계적 이상 자동 감지는 범위 밖입니다.
  • 인스턴스별 freshness도 과제입니다. 지금은 dt 파티션 전체의 최신 captured_at으로 판정해서, 일부 인스턴스만 일찍 끊겨도 다른 인스턴스가 경계까지 수집했으면 dt-level로는 OK가 될 수 있습니다.
  • 롤링 윈도우는 이력을 요구합니다. 최신 dt 기준 최근 7일 vs 직전 30일 창이라, 실데이터 닫힌 dt가 3개뿐이라 실운영 마트는 0행입니다(규모 합성에서만 랭킹이 나옴). 이력이 쌓이면 실데이터에서도 채워집니다. 구조는 검증됐고 이력이 없으면 마트를 그대로 비워 두는 게 규율입니다.
  • 과거 dt 정정입니다. fct는 이제 증분이라 과거 dt(<max) 정정은 --full-refresh가 필요합니다(backfill 레시피는 RUNBOOK).
  • 합성 규모의 재현 범위도 한계입니다. 파일 수·파티션 규모는 정확히 재현하지만 고유 쿼리 카디널리티 폭증은 하루치의 반복이라 미재현입니다(원천 다양성의 문제).
  • 계약·계보도 마찬가지입니다. 컬럼 레벨 계보·PII 태깅은 dbt Enterprise 영역이라 문서 계보까지만 했습니다.

돌아보면 이 프로젝트를 관통한 건 네 문장입니다.

  1. 버려지는 것에 두 번째 삶을 준다. 관제가 7일 만에 버리는 데이터를 분석계가 받아 장기 이력으로 잇습니다.
  2. 주장은 실측으로 편다. 멱등은 닫힌 창 재실행으로, 품질은 장애 주입으로 확인했습니다. 규모는 1년치를 실제로 만들어 쟀고, 최적화는 병목 수치가 정당화한 곳만 건드렸습니다.
  3. 안 하는 것에도 이유를 붙인다. Kafka·Spark·Iceberg를 안 넣은 근거가 넣은 것만큼 이 파이프라인의 성격을 보여줍니다. 저빈도 감사 데이터라 change_review를 안 내린 것도, 가용성을 가져오기 전에 원천 29개를 전부 대조한 것도 같은 규율입니다.
  4. 데이터를 내리는 데서 판정을 내리는 데까지. 재료를 장기 보관하는 창고가, 라이브 7일 창이 구조적으로 못 하는 여섯 가지 판정을 내리는 창고로 자랐습니다. 발화는 여전히 남에게 맡기고, 판정 컬럼까지만 정직하게 계산합니다.

전 과정의 상세는 시리즈 1편(구축)부터 5편(판정)까지에, 재현 가능한 기록은 GitHub에 있습니다.

Author
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.