7일이면 버려지는 스냅샷을 장기 이력으로 살려낸 dbtower-lakehouse 실측 총정리
AirflowdbtDuckDBMinIOData EngineeringELT
목차
DBTower-lakehouse: 버려지는 관측 데이터의 장기 분석·판정 파이프라인
DBTower가 7일 뒤 삭제하는 관측 데이터를 만료 전에 내려(ELT) 장기 질문에 답합니다. 운영계와 분석계를 분리하고, 조용히 틀린 데이터는 fail-closed(이상하면 통과 대신 정지)로 차단하며, 그 위에서 DBA가 손으로 하던 판정 여섯을 컬럼으로 자동화합니다.
- 기간: 2026.04 ~ 진행 중 · 개인(100%) · GitHub · 블로그 시리즈 0~5편
- 스택: Airflow · dbt · DuckDB/DuckLake · MinIO(S3) · PostgreSQL · Metabase · pytest · GitHub Actions
- 역할 전부: 멱등 추출(EL) / dbt 변환 / 품질 게이트 / DuckLake 테이블 포맷 / 운영(알림·deadman·CI) / 대시보드 / 규모 검증(2축·합성) / 판정층 6종 / 셀프호스트 어플라이언스 / 원천 되쓰기
- 규모 가정: 관제 대상은 5기종 인스턴스이고, 설계 목표는 수백 대·수년치(low-TB) 이력을 일 단위 SLA의 배치로 단일 노드 DuckDB가 감당하는 것입니다. 관심 지표는 초당 처리량이 아니라 “이력이 몇 배로 늘어도 매일 배치가 초 단위로 끝나는가”입니다. 시간축 1년치 54.5M행·인스턴스축 300대 52.2M행 두 축으로 합성 검증했습니다
왜 만들었는가
- DBTower의 7일 삭제는 의도된 설계입니다.
- 관측 데이터를 무한히 쌓으면 메타 DB가 관리 대상보다 먼저 포화됩니다. AWS RDS Performance Insights 무료 티어의 보존 기간이 정확히 7일인 것과 같은 선례입니다.
- 그런데 DBTower를 만들면서 답할 수 없는 질문이 쌓였습니다.
- “지난 분기 대비 가장 악화된 쿼리 TOP 10은?”
- “이 인스턴스의 3개월 성장 추세로 볼 때 용량 증설 시점은?”
- “기종별 장기 레이턴시 분포는?”
- 그래서 운영계(OLTP)와 분석계를 분리하는 실무 원칙을 그대로 따랐습니다.
- 관제는 짧은 최신 구간을 자주 조회하고, 분석은 긴 전체 이력을 스캔합니다. 보존 정책도 정반대라, 한 저장소에 억지로 두면 관제가 느려지거나 메타 DB가 포화됩니다.
- 관제는 지금에 최적화된 채로 두고, 버려지기 직전의 스냅샷만 ELT로 컬럼형 저장소에 내렸습니다.
누구에게 도움이 되는가
- SQL 없이 대시보드로 답을 얻는 DB 운영·성능 담당자입니다.
- “지난 구간보다 느려진 쿼리 있어?”에 “instance 8의 Oracle 쿼리가 이틀 새 평균 25.9ms에서 64.5ms로 149% 느려졌다”로 답합니다. raw 스냅샷만으로는 낼 수 없던 답입니다.
- DBA의 반복 판정을 자동화합니다.
- 용량 증설 시점, 플랜 뒤집힘, 백업 공백, 인덱스 정리 후보, 설정 변경의 성능 영향, 가용성 목표. 사람이 매번 뒤지던 여섯 판정을 매일 자동으로 계산해 둡니다.
- 대시보드를 이원화했습니다.
- 분석 대시보드(데이터를 신뢰한 뒤의 질문)와 운영 대시보드(파이프라인 자체가 건강한가)를 분리했고, 실제 수집 정지일이 completeness·freshness FAIL로 표시되는 것까지 확인했습니다.
- 자기 부하도 발견합니다.
- 악화 랭킹에 오른 인스턴스 하나는 DBTower가 자기 메타 PG에 던지는 스냅샷 쿼리였습니다. 파이프라인이 준 부하를 파이프라인이 관측합니다.

아키텍처
Airflow DAG 한 줄이 offload → quality_gate → transform → publish → heartbeat를 순서대로 지납니다.
- 게이트가 FAIL이면 뒤 단계가 전부 멈춥니다(fail-closed). 실패는 webhook 경보로 알리고, “성공의 부재”는 deadman이 역방향으로 잡습니다.
- 각 단에는 그 단만의 안전장치가 붙습니다. 쓰기 경로에는 아카이브 자기파괴 가드, 읽기 경로에는 4축 게이트, 성공 신호에는 heartbeat와 deadman입니다.
- 컨테이너 경계와 포트도 분리했습니다(원천 PG 15432 · MinIO 19000 · Airflow 8080 · Metabase 13001). 오케스트레이터는 CeleryExecutor 대신 LocalExecutor를 골라 Redis 브로커와 별도 워커 컨테이너를 없앴고, Airflow 메타 DB만 전용 PostgreSQL로 격리했습니다. 하루 수만 행을 단일 노드로 처리하는 규모라 분산 워커가 줄 이득이 없다고 판단했습니다.
- 데이터는 원천 2테이블 → raw parquet → staging → marts 순으로 흐릅니다.
- marts는 contract로 컬럼 타입을 강제해, 타입이 조용히 바뀌면 빌드 자체가 막힙니다.
- 이 계보의 심장은 누적 카운터를 일간 델타로 바꾸는 변환 지점입니다.
- 원천의 호출 수는 서버 기동 이후 계속 쌓이는 누적값이라, 그대로 더하면 의미가 없다고 판단했습니다. 하루 양 끝의 차분이 그날의 실제 발생량입니다.
기능 지도
| 영역 | 기능 |
|---|---|
| 파이프라인 | Airflow DAG · 멱등 추출 · 테이블 스펙 레지스트리 · 아카이브 자기파괴 가드 |
| 품질 | 4축 fail-closed 게이트 · dbt test/unit test · contracts 타입 강제 · CI 3관문 |
| 변환 | staging → fct → mart 증분 변환 · 롤링 윈도우 악화 랭킹 |
| 판정층 | 용량 D-day · 플랜 회귀 · 백업 RPO · 미사용 인덱스 · 설정 변경 상관 · 가용성 SLO · 기종 축 · 주간 운영 보고, 발행 21테이블 |
| 서빙 | DuckLake 단일 트랜잭션 발행·타임트래블 · Metabase 대시보드 이원화 |
| 감시·연계 | webhook 실패 경보 · deadman · 원천 되쓰기 · AI 에이전트 서빙 · 셀프호스트 어플라이언스 · RUNBOOK·VERIFICATION 문서 |
설계 1. 원천에 부하를 주지 않는 멱등 EL
추출이 관제 DB를 느리게 하면, 관제를 관제하다 관제를 망가뜨리는 자기모순입니다. 기본기부터 원리로 눌렀습니다.
- 추출은 인덱스 선두를 탑니다.
- 원천 인덱스는 복합
(instance_id, captured_at)인데, 선두를 고정하지 않은captured_at단독 범위 조건은 정렬 트리를 좁히지 못해 사실상 전 구간을 훑습니다. 50만 행 관제 DB에서 이건 그대로 부하가 됩니다. - 원천 인덱스를 바꾸는 대신(관제탑은 최후까지 안 건드립니다) 인스턴스별 등치 루프로 매 쿼리가 선두를 타게 했습니다. 세션은 readonly로 열어 쓰기를 트랜잭션 레벨에서 차단하고, 서버사이드 커서로 결과 전체를 메모리에 올리지 않아 인스턴스 수와 무관하게 메모리가 일정합니다.
- 원천 인덱스는 복합
- DAG보다 계약(CONTRACT.md)을 먼저 굳혔습니다.
- 파이프라인 버그는 대개 코드가 아니라 계약 불명확에서 옵니다. dt는 UTC 자정 경계의 반열림 구간(겹침·누락 0)이고, 원천이 UTC로 저장하니 경계도 UTC로 맞춰 KST/DST 흔들림을 없앴습니다.
- parquet는 스키마를 명시 선언해 타입 추론이 int64를 double로 조용히 바꾸는 사고를 막았고, hive 파티션 규약(
dt=.../instance_id=.../)이라 특정 dt만 읽을 때 파일을 열지 않고 경로만으로 걸러냅니다.
- 멱등 덮어쓰기라 부분 실패로 중복될 일이 원리상 없습니다.
- 파티션 프리픽스를 통째로 지우고 다시 씁니다. 닫힌 dt를 2회 실행해도 79,894행·오브젝트 6개 불변을 실측했고, backfill 날짜 산수까지 실측으로 확정해 RUNBOOK에 적었습니다.
- 3자 일치 verify를 상시 장치로 뒀습니다.
- 원천 PG 행수 = parquet 행수 = DuckDB 조회를 dt별로 대조합니다(149,259 / 79,894 ALL MATCH). “유실도 중복도 없다”의 정의를 검증 가능한 형태로 박았습니다.
- 원천이 라이브라 값이 계속 자라는 열린 창(오늘)으로는 검증하지 않고, 값이 불변인 닫힌 UTC 구간으로만 대조합니다.
설계 2. 조용한 오답을 막는 신뢰 계층
신뢰는 하나의 기능으로 서지 않습니다. 네 장치가 각자 다른 실패 모드를 막아 층으로 쌓이고, 전부 장애 주입으로 실증했습니다.
- 게이트는 dbt 앞에서 raw 파티션 자체를 검문합니다.
- 정합(원천=parquet 행수)·완결성(기대 인스턴스 전부 존재)·신선도·스키마 드리프트 4축을 dt마다 돌리고 가장 나쁜 값으로 접습니다. dbt test는 이미 로드된 데이터만 봐서 없는 행을 못 잡기 때문입니다.
- 파티션 20,158행을 지워 주입하자 정합·완결성이 동시에 FAIL이 나고 dbt가 실행되지 않았습니다(exit 2). 반쪽 파티션 위의 “조용히 틀린 랭킹”을 이렇게 막습니다.
- 게이트 자신도 검문했습니다. 게이트가 원천을 Seq Scan(332ms)으로 훑던 안티패턴을 인덱스 등치 루프로 바꿔 332ms → 20ms. “관제가 부하가 되면 안 된다”를 게이트 자신에게도 적용했습니다.
- deadman은 감시의 방향을 뒤집습니다.
- 기존 알림은 태스크가 돌다 실패해야 웁니다. 수집기가 21시간 조용히 멈췄는데 알림이 0통이었던 경험에서, heartbeat가 기한 내 갱신되지 않으면 경보하는 역방향 감시를 넣었습니다. 30h 침묵(기한 26h)에서 경보 실수신을 확인했고, 미실행·pause DAG도 검출합니다.
- 감시자는 감시 대상 밖에 있어야 하므로, 스케줄러 자체가 죽는 경우까지 잡는 외부 cron 경로를 함께 뒀습니다.
- CI와 contracts가 회귀를 막습니다.
- CI 3관문은 커밋마다 픽스처만으로 dbt build를 e2e로 돌려(PASS=25), 테스트가 “로컬 자산”이 되는 회귀를 막습니다.
- dbt contracts는 마트 컬럼 타입이 조용히 바뀌는 것을 막습니다. VARCHAR 타입을 주입하자 CREATE TABLE 시점에 빌드가 차단됐습니다.
- 몸으로 배운 방어도 있습니다.
- 원천 스택이 통째로 내려간 날, psycopg2 기본값엔 접속 타임아웃이 없어 연결 시도가 무한 대기로 멈췄습니다. 걸려 멈춘 태스크는 실패도 아니라 재시도도 알림도 못 탑니다. 모든 DSN에
connect_timeout=5를 박았습니다. 빨리 죽어야 재시도가 삽니다. - 재시도도 갈랐습니다. 일시 장애는 지수 백오프 3회(2→4→8분), 품질 FAIL은 재시도해도 결과가 같은 결정적 실패라 게이트만 retries=0으로 즉시 알립니다.
- 원천 스택이 통째로 내려간 날, psycopg2 기본값엔 접속 타임아웃이 없어 연결 시도가 무한 대기로 멈췄습니다. 걸려 멈춘 태스크는 실패도 아니라 재시도도 알림도 못 탑니다. 모든 DSN에

설계 3. 파일 직결을 실측으로 실격 판정하고 DuckLake로 서빙합니다
- 가장 쉬운 길(DuckDB 파일 직결)은 실측 2건으로 실격입니다.
- DuckDB 파일은 단일 쓰기 모델이라, 같은 호스트에서는 읽기 커넥션의 잠금에 dbt 쓰기가 죽습니다(매일 새벽 transform이 Conflicting lock으로 죽습니다).
- 컨테이너 경계에서는 잠금이 전파되지 않아 열린 리더 밑에서 파일이 소리 없이 재작성됩니다. 시끄럽게 죽는 쪽보다 나쁜 실패입니다.
- 마트를 DuckLake로 발행하고 Metabase는 read-only로만 읽습니다.
- Metabase가 DuckDB를 읽으려면 이미지부터 손봐야 했습니다. 공식 Metabase는 Alpine(musl) 기반이라 DuckDB JDBC 네이티브 라이브러리(glibc 링크)가
UnsatisfiedLinkError: libduckdb_java...so로 안 뜹니다. 드라이버 저장소(MotherDuck)도 README에서 “기본 Alpine 컨테이너에선 안 된다”며 Debian 기반을 권장하기에,eclipse-temurin:21-jre-jammy위에 metabase.jar와 드라이버 jar를 얹은 20줄짜리 이미지를 직접 구웠습니다. 드라이버는 Metabase 메이저·DuckDB 버전에 짝 고정했습니다(1.5.3.0 = Metabase 59 + DuckDB 1.5.3, dbt의 1.5.4와 같은 계열로 포맷 호환). - 동시성 중재를 파일 잠금에서 PG 트랜잭션(스냅샷 격리)으로 넘겼습니다. 카탈로그는 이미 있는 PostgreSQL을 재사용해 서비스 추가 0, 데이터는 MinIO(S3)입니다.
- 발행(쓰기) 도중 0.3초 간격 연속 질의 41회 전부 온전했고, 수치는 3경로(파일 직독·API·화면) 대조로 전부 일치했습니다. 타임트래블도
AT (VERSION => n)으로 과거 상태를 재현해 실증했습니다.
- Metabase가 DuckDB를 읽으려면 이미지부터 손봐야 했습니다. 공식 Metabase는 Alpine(musl) 기반이라 DuckDB JDBC 네이티브 라이브러리(glibc 링크)가
- 화면이 깨운 동시성 버그와 발행 원자성까지 잡았습니다.
- 대시보드가 카드 3장을 동시에 쏘자 2장이 500으로 터졌습니다. 커넥션 풀마다 실행되는
CREATE OR REPLACE SECRET이 공유 카탈로그에서 충돌한 것으로, 세션 로컬 SET으로 공유 상태 자체를 없앴습니다. - 두 테이블 발행 사이에 장애를 주입해 “새 fct + 이전 mart” 혼합 버전 결함을 재현하고, 발행을 단일 트랜잭션으로 묶어 원자성을 증명했습니다.
- 대시보드가 카드 3장을 동시에 쏘자 2장이 500으로 터졌습니다. 커넥션 풀마다 실행되는
기능 1. 창고만 할 수 있는 판정 여섯: DBA의 반복 판정을 컬럼으로
데이터를 내리기만 하던 창고가 판정을 내립니다. 전부 DBA·DBRE가 손으로 하던 일이고, 라이브 7일 창으로는 구조적으로 불가능합니다.
| 판정 | 답하는 질문 | 설계 핵심 |
|---|---|---|
| 용량 D-day | 언제 증설해야 하나 | 크기 시계열 선형 추세로 임계까지 잔여일. 7일로는 추세선 표본이 없다 |
| 플랜 회귀 | 언제 플랜이 뒤집혀 느려졌나 | 하루 대표 플랜의 뒤집힘을 전후 N일 지연과 겹침. 관측 부족은 PENDING, 창 오염은 AMBIGUOUS로 지어내지 않음 |
| 백업 RPO | 백업이 며칠째 없나 | 기록 없는 인스턴스도 행으로 드러냄. 기준일은 벽시계가 아닌 창고 최신 날짜 |
| 미사용 인덱스 | 지워도 되나 | 90일 창 실사용 판정. 후보까지만 내고 삭제는 지시하지 않음(최종 판단은 사람) |
| 설정 변경 상관 | 어떤 변경 뒤 나빠졌나 | 변경 시점과 성능 회귀를 겹침. 상관이지 인과가 아니라 조언 어휘로만 |
| 가용성 SLO | 목표만큼 떠 있었나 | 1분 핑을 하루로 접어 최근 30일 uptime과 SRE 에러버짓 산출 |
- 여섯 판정의 계열은 셋으로 접힙니다.
- 미래 외삽(용량 D-day), 시점 전후 상관(플랜 회귀·설정 상관), 부재 감지(백업 공백)입니다. 셋 다 전후 며칠을 비교해야 판정이 서므로, 7일 창으로는 구조적으로 못 하고 장기 창고라야 컬럼이 됩니다.
- 판정 컬럼까지만 내고, 발화는 하지 않습니다.
- 알림은 Metabase·DBTower의 몫으로 두어 두 번째 알림 시스템을 만들지 않았습니다.
- 라이브 실측에서 곧바로 답이 나왔습니다.
- 가용성: MSSQL 2대가 uptime 63~64%로 breach, MySQL은 98.86%로 근소 미달, PG·Mongo·Oracle은 99.9% meets였습니다. down 샘플의 ping은 타임아웃 값이라 up 샘플로만 평균해 지연 통계 오염을 막았습니다.
- 설정 상관: work_mem 변경 2건이 타임라인에 잡혔고, 무변경 다섯 인스턴스는 수집 정상(23사이클)이라 “무변경”과 “미수집”이 한 표에서 갈립니다.
- 안 내리는 것도 판정입니다.
- 저빈도 감사 데이터 기반 change_review는 관측이 성겨 세우면 대부분 PENDING만 나와 자리만 열어 뒀습니다.
- 가용성을 가져오기 전에 원천 테이블 29개를 전부 대조해 창고의 몫과 관제·인프라(Prometheus)의 몫을 갈랐습니다.

기능 2. 남이 그대로 띄우는 어플라이언스, 그리고 원천으로의 되쓰기
- “재현 가능”의 숨은 전제를 부수고 셀프호스트 어플라이언스로 만들었습니다.
- 남이 clone하면 원천이 없어 인스턴스 0개로 조용히 빈 결과가 났습니다. 내 재현은 데모 스택이 옆에 이미 떠 있을 때의 재현이었던 겁니다.
- 카탈로그 설정에 폴백 하나를 더해 기존 경로를 안 깨고 분리했고,
--profile demo로 DBTower 없이 e2e가 도는 상자로 만들었습니다. 사용자는 DuckDB라는 단어를 볼 필요 없이.env와 Metabase 대시보드만 만납니다.
- 범용화는 거부했습니다.
- 범용 쿼리 분석 도구는 이미 레드오션이고, 범용화하는 순간 원천(DBTower)까지 직접 만든 이 구조의 유일 자산이 죽습니다. Prometheus와 Thanos의 관계처럼, DBTower 옆에 창고를 같이 띄우는 것까지가 목표입니다.
- 원천 5종 일반화에서 “테이블만 늘리면 된다”는 전제가 셋 깨졌습니다.
- 대기 이벤트는 영속 테이블이 없어 시끄럽게 거부하고, 플랜은 개수 기반 보존이라 계약 문서에 명기하고, 백업은 사후 UPDATE되는 변이 테이블이라 불변 워터마크를 따로 잡았습니다.
- 테이블마다 다른 건 이름이 아니라 성질(워터마크·불변성·게이트 프로필)이라, 성질을 스펙으로 만드니 추출·게이트·DAG가 스펙을 읽는 일반 장치가 됐습니다.
- 반대 방향의 일(되쓰기)도 합니다.
- DBTower 이상 감지의 오탐을 지우는 장기 dow×hour 베이스라인을 계산해 원천 쪽으로 되씁니다. 평균·표준편차만 넘기면 원시 관측을 더한 것과 수학적으로 동일하게 병합되므로, 원시 행 왕복이 없습니다.
- readonly 봉인을 깨지 않으려 별도 역할에 해당 테이블만 권한을 주고 단일 트랜잭션으로 32,498행 왕복을 실측했습니다. 그 역할로 원천을 읽으면 permission denied가 납니다. 봉인이 실제로 잠겨 있다는 뜻입니다.
트러블슈팅 1. 아카이브가 자기 자신을 지우는 경로를 재현하고 차단했습니다
- 문제: 원천은 7일 보존이라 그보다 지난 날짜를 backfill하면 MinIO의 parquet가 세상에 남은 유일본이 됩니다. 그런데 멱등 재적재가 “파티션 통째 삭제 후 재작성(delete-first)“이라, 원천이 0행이면 유일본을 지우고 아무것도 안 쓴 채 exit 0 “성공”으로 끝납니다
- 원인: delete-first는 “지운 만큼 다시 쓸 수 있다”를 암묵 전제하는데, 보존 창 밖에서는 delete만 남고 write가 사라집니다. fail-closed를 읽기 경로에는 적용했으면서 쓰기 경로에는 빠뜨린 뒷면이었습니다
- 재현·해결: 가짜 파티션을 심고 재실행해 “삭제 → 데이터 없음 → 총 0행 완료” 로그로 소멸을 직접 확인했습니다. “지울 자격은 다시 쓸 수 있는 자에게만”이라는 원칙으로, 원천 0행 + 파티션 존재면 삭제를 거부하고 시끄럽게 실패하게 했습니다(exit 1 + webhook 경보). 자동 우회 플래그는 일부러 만들지 않았습니다
- 결과: 같은 입력에서 유일본을 보존하고, 정상 경로는 멱등 그대로입니다(verify ALL MATCH, 149,259행 불변)
트러블슈팅 2. 누적 카운터를 일간 델타로 접다가 만난 지문 충돌 12,743키
- 문제: raw의 calls는 서버 기동 이후 누적 카운터라 SUM하면 무의미합니다. 하루 양 끝 차분으로 델타를 내려는데, 시간순 정렬에서 302→55→302로 지그재그하는 가짜 리셋이 나왔습니다
- 원인: 같은 쿼리 지문 아래 두 누적 계열이 얽힌 지문 충돌 12,743키였습니다. 이대로 인접 차분하면 302→55가 -247로 잡혀 델타가 통째로 오염됩니다
- 해결: staging에서 시점별 SUM으로 단조성을 복원하고(“단조 비감소 계열의 합도 단조”), 하루 양 끝 차분 + 리셋 클램프로 일간 델타를 냈습니다. 순리셋 219그레인이 0으로 눌렸고 음수 델타 0건입니다
- 대안 기각: Prometheus rate 방식(인접 양의 델타 합산)도 후보였지만, 쿼리가 잠깐 사라졌다 재등장할 때 유령 증가분을 과대계상해 총합 7배 부풀림(22.2M vs 3.1M)을 실측하고 기각했습니다. 심장 로직은 dbt unit test 4건으로 고정했습니다
트러블슈팅 3. 두 축으로 재보고, 수치가 요구한 곳만 최적화했습니다
- 문제: 실측이 전부 3일치(수십만 행)에서 초 단위로 끝나, “규모에서도 버틴다”는 아직 증명이 아니었습니다. fct 마트는 매일 전체 이력을 다시 계산하는데 이력이 3일이라 안 아팠을 뿐입니다. 증분부터 도입하는 감 최적화를 거부하고 “먼저 재보고, 수치가 요구할 때만” 순서를 택했습니다
- 합성 실측: 닫힌 파티션을 날짜 시프트 복제해 365일 × 6인스턴스 = 2,190파일(54.5M행)을 실데이터와 격리한 경로에 합성했습니다(원천 PG 무접촉, 서버 부하 0). 병목은 fct 전체 재빌드 407.62s 하나였고, 상위 마트는 0.31s라 증분화하지 않았습니다. 초 단위인 곳을 최적화하면 복잡도만 늡니다
- 함정: 증분 전환 후에도 2분+ 타임아웃이 났습니다. 워터마크를 서브쿼리(
select max(dt))로 주면 값이 실행 시점에야 정해져 플래너가 hive 경로 프루닝을 못 하고 2,190파일을 전부 읽습니다. 컴파일 타임에 max dt를 리터럴로 구워 넣자 최신 dt만 읽었습니다 - 결과: 407.62s → 4s(약 100배), 읽는 파일 2,190 → 6~12개, 같은 날짜 재실행이 멱등입니다
- 축 회전: 총량을 고정하고 인스턴스축 300대(52.2M행)로 돌려 재보니 증분은 8.03s로 건재했고, 급소는 외삽해 뒀던 소파일이 아니라 full-refresh(769s)로 드러났습니다. 재보지 않은 예측 두 개가 뒤집혔고, 운영 가이드 1순위가 “full-refresh는 dt 청크로 쪼개라”로 바뀌었습니다. 769s의 원인은 EXPLAIN으로 확정하지 않아 가설로만 표기했습니다
- 소파일: 파일 평균 177KB는 실무 타깃 128MB의 1/741로, 실무 1순위 고통을 그대로 재현했습니다. 그 고통은 글롭 리스팅(2.2초)과 커밋 누적으로 나타나고, 후자는 주간 CHECKPOINT 컴팩션이 값싸게 흡수합니다(366파일 → 1파일, 0.47s)
그 밖의 판단과 트레이드오프
- Kafka는 쓰지 않았습니다.
- 스냅샷 원천이라 붙일 이벤트 스트림 자체가 없고, SLA는 일 단위, 소비자는 하나입니다. 스트리밍 채택 3조건(초~분 신선도가 가치 · 다중 소비자 · 분산시스템 운영 여력)에 전부 미해당이라 배치를 골랐습니다.
- 준실시간이 필요해져도 풀 Kafka가 아니라 경량 CDC + 별도 수집 계층이 맞다고 근거까지 문서화했습니다.
- microbatch를 기각하고 delete+insert를 골랐습니다.
- dbt 공식 증분 전략 중 microbatch는 event_time이 필수고 unique_key 기반 파티션 교체가 안 됩니다. 기각 사유를 문서로 남겼습니다.
- 도구를 늘리는 대신 게이트를 키웠습니다.
- dbt 관측성 도구 elementary는 4축 게이트+webhook과 역할이 겹치고, dbt source freshness도 게이트와 중복입니다. 같은 판정을 두 군데서 내려 기준이 갈라지지 않게 게이트를 단일 진실로 뒀습니다.
- 롤링 윈도우를 재설계하고, 오차 원인까지 정직하게 적었습니다.
- 전체 이력 비교는 오래된 개선을 영원히 기억해 최근 7일 vs 직전 30일로 재설계했습니다. 검증 주입 +50/+150이 경계 겹침 때문에 +47.5/+138.1로 나오는 것까지 원인 규명해 기록했습니다.
- 실데이터 3일치에서는 이 마트를 0행으로 정직하게 비워 둡니다.
- 지문 충돌 SUM은 근사임을 명시했습니다.
- id로 계열을 온전히 분리하지 못하는 원천 성질이라, “지문 단위 총 활동”까지가 정직한 그레인의 상한입니다.
- 클라우드 관리형을 하나도 안 씁니다.
- 카탈로그는 기존 PG 재사용, 스토리지는 MinIO, 엔진은 임베디드 DuckDB입니다. 스캔량 과금 구조였다면 매일 전체 이력(54.5M행)을 읽던 설계는 이력에 비례해 과금됐을 자리라, 증분 전환은 성능뿐 아니라 과금도 같은 배수로 줄이는 결정이었습니다.
- 이 규모(low-TB·일 배치·소비자 하나)에서는 관리형 운영비가 정당화되지 않고, 시점이 오면 dbt 어댑터 교체로 이전합니다.
대표 수치 (전부 직접 실측, 재현 기록 저장소 보존)
| 항목 | 실측 |
|---|---|
| 정합 검증 | 원천 PG = parquet = DuckDB 3자 일치(149,259 / 79,894행) · 멱등 2회 재실행 불변 |
| 변환 | 지문 충돌 12,743키 SUM 복원 · 순리셋 219그레인 클램프(음수 0건) · 인접 합산 대비 7배 부풀림 실측 기각 |
| 품질 게이트 | 4축 fail-closed · 장애 주입 시 dbt 미실행(exit 2) · 게이트 자체 부하 332→20ms |
| 아카이브 가드 | 자기파괴 경로 전/후 재현: 수정 전 유일본 소멸, 수정 후 삭제 거부·보존 |
| 규모 검증 | 시간축 365일·54.5M행: fct 407.62s → 4s(약 100배) · 인스턴스축 300대·52.2M행: 증분 8.03s, 급소는 full-refresh(769s) |
| 판정층 | 판정 6종 · 발행 21테이블 · MSSQL 2대 uptime 63~64% breach 실검출 |
| 서빙 | 발행 중 연속 질의 41회 무중단(스냅샷 격리) · 파일 직독=API=화면 3자 정합 |
| DuckLake 운영 | 타임트래블·ROLLBACK 원자성 실증 · CHECKPOINT 스냅샷 정리에도 행수 불변 |
| 되쓰기 | dow×hour 베이스라인 32,498행 단일 트랜잭션 왕복 · 원천 readonly 봉인 유지 |
| 감시 | deadman 30h 침묵 경보 실수신 · contracts 위반 주입 시 빌드 차단 |
| 테스트 | pytest 57 passed · dbt build PASS=129 · CI 3관문 |
배운 것
- 조용히 틀린 데이터는 없는 것보다 나쁩니다. 게이트·CI·contracts·deadman이 각자 다른 실패 모드를 막아야 신뢰가 계층으로 섭니다.
- 최적화는 규모가 요구할 때만 합니다. 두 축을 합성해 재보기 전까지는 증분 전환도 하지 않았고, 실측이 병목 하나만 지목했기에 거기만 고쳤습니다. 외삽은 축을 돌린 실측이 바로잡았습니다.
- 멱등은 공짜가 아닙니다. delete-first 재적재의 경계 조건을 감사하지 않았다면, 편의 장치가 데이터를 지우는 장치가 됐습니다.
- 판정은 계산까지, 발화는 남에게 맡깁니다. 창고가 PENDING·AMBIGUOUS를 지어내지 않고 남기는 것, 삭제 후보까지만 내고 멈추는 것이 자동화의 신뢰를 만듭니다.
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.