프로젝트
약 15분 분량 개인 프로젝트/레이크하우스

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행 두 축으로 합성 검증했습니다

왜 만들었는가

  1. DBTower의 7일 삭제는 의도된 설계입니다.
    • 관측 데이터를 무한히 쌓으면 메타 DB가 관리 대상보다 먼저 포화됩니다. AWS RDS Performance Insights 무료 티어의 보존 기간이 정확히 7일인 것과 같은 선례입니다.
  2. 그런데 DBTower를 만들면서 답할 수 없는 질문이 쌓였습니다.
    • “지난 분기 대비 가장 악화된 쿼리 TOP 10은?”
    • “이 인스턴스의 3개월 성장 추세로 볼 때 용량 증설 시점은?”
    • “기종별 장기 레이턴시 분포는?”
  3. 그래서 운영계(OLTP)와 분석계를 분리하는 실무 원칙을 그대로 따랐습니다.
    • 관제는 짧은 최신 구간을 자주 조회하고, 분석은 긴 전체 이력을 스캔합니다. 보존 정책도 정반대라, 한 저장소에 억지로 두면 관제가 느려지거나 메타 DB가 포화됩니다.
    • 관제는 지금에 최적화된 채로 두고, 버려지기 직전의 스냅샷만 ELT로 컬럼형 저장소에 내렸습니다.

누구에게 도움이 되는가

  1. SQL 없이 대시보드로 답을 얻는 DB 운영·성능 담당자입니다.
    • “지난 구간보다 느려진 쿼리 있어?”에 “instance 8의 Oracle 쿼리가 이틀 새 평균 25.9ms에서 64.5ms로 149% 느려졌다”로 답합니다. raw 스냅샷만으로는 낼 수 없던 답입니다.
  2. DBA의 반복 판정을 자동화합니다.
    • 용량 증설 시점, 플랜 뒤집힘, 백업 공백, 인덱스 정리 후보, 설정 변경의 성능 영향, 가용성 목표. 사람이 매번 뒤지던 여섯 판정을 매일 자동으로 계산해 둡니다.
  3. 대시보드를 이원화했습니다.
    • 분석 대시보드(데이터를 신뢰한 뒤의 질문)와 운영 대시보드(파이프라인 자체가 건강한가)를 분리했고, 실제 수집 정지일이 completeness·freshness FAIL로 표시되는 것까지 확인했습니다.
  4. 자기 부하도 발견합니다.
    • 악화 랭킹에 오른 인스턴스 하나는 DBTower가 자기 메타 PG에 던지는 스냅샷 쿼리였습니다. 파이프라인이 준 부하를 파이프라인이 관측합니다.

Metabase 분석 대시보드 실물, 인스턴스별 악화 랭킹과 장기 추이

아키텍처

파이프라인 아키텍처

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(contract 강제)와 운영 테이블까지의 컬럼 계보, 누적 카운터가 일간 델타로 접히는 변환 지점 표기

  1. 데이터는 원천 2테이블 → raw parquet → staging → marts 순으로 흐릅니다.
    • marts는 contract로 컬럼 타입을 강제해, 타입이 조용히 바뀌면 빌드 자체가 막힙니다.
  2. 이 계보의 심장은 누적 카운터를 일간 델타로 바꾸는 변환 지점입니다.
    • 원천의 호출 수는 서버 기동 이후 계속 쌓이는 누적값이라, 그대로 더하면 의미가 없다고 판단했습니다. 하루 양 끝의 차분이 그날의 실제 발생량입니다.

기능 지도

영역기능
파이프라인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를 느리게 하면, 관제를 관제하다 관제를 망가뜨리는 자기모순입니다. 기본기부터 원리로 눌렀습니다.

  1. 추출은 인덱스 선두를 탑니다.
    • 원천 인덱스는 복합 (instance_id, captured_at)인데, 선두를 고정하지 않은 captured_at 단독 범위 조건은 정렬 트리를 좁히지 못해 사실상 전 구간을 훑습니다. 50만 행 관제 DB에서 이건 그대로 부하가 됩니다.
    • 원천 인덱스를 바꾸는 대신(관제탑은 최후까지 안 건드립니다) 인스턴스별 등치 루프로 매 쿼리가 선두를 타게 했습니다. 세션은 readonly로 열어 쓰기를 트랜잭션 레벨에서 차단하고, 서버사이드 커서로 결과 전체를 메모리에 올리지 않아 인스턴스 수와 무관하게 메모리가 일정합니다.
  2. DAG보다 계약(CONTRACT.md)을 먼저 굳혔습니다.
    • 파이프라인 버그는 대개 코드가 아니라 계약 불명확에서 옵니다. dt는 UTC 자정 경계의 반열림 구간(겹침·누락 0)이고, 원천이 UTC로 저장하니 경계도 UTC로 맞춰 KST/DST 흔들림을 없앴습니다.
    • parquet는 스키마를 명시 선언해 타입 추론이 int64를 double로 조용히 바꾸는 사고를 막았고, hive 파티션 규약(dt=.../instance_id=.../)이라 특정 dt만 읽을 때 파일을 열지 않고 경로만으로 걸러냅니다.
  3. 멱등 덮어쓰기라 부분 실패로 중복될 일이 원리상 없습니다.
    • 파티션 프리픽스를 통째로 지우고 다시 씁니다. 닫힌 dt를 2회 실행해도 79,894행·오브젝트 6개 불변을 실측했고, backfill 날짜 산수까지 실측으로 확정해 RUNBOOK에 적었습니다.
  4. 3자 일치 verify를 상시 장치로 뒀습니다.
    • 원천 PG 행수 = parquet 행수 = DuckDB 조회를 dt별로 대조합니다(149,259 / 79,894 ALL MATCH). “유실도 중복도 없다”의 정의를 검증 가능한 형태로 박았습니다.
    • 원천이 라이브라 값이 계속 자라는 열린 창(오늘)으로는 검증하지 않고, 값이 불변인 닫힌 UTC 구간으로만 대조합니다.

설계 2. 조용한 오답을 막는 신뢰 계층

신뢰는 하나의 기능으로 서지 않습니다. 네 장치가 각자 다른 실패 모드를 막아 층으로 쌓이고, 전부 장애 주입으로 실증했습니다.

  1. 게이트는 dbt 앞에서 raw 파티션 자체를 검문합니다.
    • 정합(원천=parquet 행수)·완결성(기대 인스턴스 전부 존재)·신선도·스키마 드리프트 4축을 dt마다 돌리고 가장 나쁜 값으로 접습니다. dbt test는 이미 로드된 데이터만 봐서 없는 행을 못 잡기 때문입니다.
    • 파티션 20,158행을 지워 주입하자 정합·완결성이 동시에 FAIL이 나고 dbt가 실행되지 않았습니다(exit 2). 반쪽 파티션 위의 “조용히 틀린 랭킹”을 이렇게 막습니다.
    • 게이트 자신도 검문했습니다. 게이트가 원천을 Seq Scan(332ms)으로 훑던 안티패턴을 인덱스 등치 루프로 바꿔 332ms → 20ms. “관제가 부하가 되면 안 된다”를 게이트 자신에게도 적용했습니다.
  2. deadman은 감시의 방향을 뒤집습니다.
    • 기존 알림은 태스크가 돌다 실패해야 웁니다. 수집기가 21시간 조용히 멈췄는데 알림이 0통이었던 경험에서, heartbeat가 기한 내 갱신되지 않으면 경보하는 역방향 감시를 넣었습니다. 30h 침묵(기한 26h)에서 경보 실수신을 확인했고, 미실행·pause DAG도 검출합니다.
    • 감시자는 감시 대상 밖에 있어야 하므로, 스케줄러 자체가 죽는 경우까지 잡는 외부 cron 경로를 함께 뒀습니다.
  3. CI와 contracts가 회귀를 막습니다.
    • CI 3관문은 커밋마다 픽스처만으로 dbt build를 e2e로 돌려(PASS=25), 테스트가 “로컬 자산”이 되는 회귀를 막습니다.
    • dbt contracts는 마트 컬럼 타입이 조용히 바뀌는 것을 막습니다. VARCHAR 타입을 주입하자 CREATE TABLE 시점에 빌드가 차단됐습니다.
  4. 몸으로 배운 방어도 있습니다.
    • 원천 스택이 통째로 내려간 날, psycopg2 기본값엔 접속 타임아웃이 없어 연결 시도가 무한 대기로 멈췄습니다. 걸려 멈춘 태스크는 실패도 아니라 재시도도 알림도 못 탑니다. 모든 DSN에 connect_timeout=5를 박았습니다. 빨리 죽어야 재시도가 삽니다.
    • 재시도도 갈랐습니다. 일시 장애는 지수 백오프 3회(2→4→8분), 품질 FAIL은 재시도해도 결과가 같은 결정적 실패라 게이트만 retries=0으로 즉시 알립니다.

품질 게이트 실측, 정상 통과와 장애 주입 시 FAIL로 다운스트림 차단

설계 3. 파일 직결을 실측으로 실격 판정하고 DuckLake로 서빙합니다

  1. 가장 쉬운 길(DuckDB 파일 직결)은 실측 2건으로 실격입니다.
    • DuckDB 파일은 단일 쓰기 모델이라, 같은 호스트에서는 읽기 커넥션의 잠금에 dbt 쓰기가 죽습니다(매일 새벽 transform이 Conflicting lock으로 죽습니다).
    • 컨테이너 경계에서는 잠금이 전파되지 않아 열린 리더 밑에서 파일이 소리 없이 재작성됩니다. 시끄럽게 죽는 쪽보다 나쁜 실패입니다.
  2. 마트를 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)으로 과거 상태를 재현해 실증했습니다.
  3. 화면이 깨운 동시성 버그와 발행 원자성까지 잡았습니다.
    • 대시보드가 카드 3장을 동시에 쏘자 2장이 500으로 터졌습니다. 커넥션 풀마다 실행되는 CREATE OR REPLACE SECRET이 공유 카탈로그에서 충돌한 것으로, 세션 로컬 SET으로 공유 상태 자체를 없앴습니다.
    • 두 테이블 발행 사이에 장애를 주입해 “새 fct + 이전 mart” 혼합 버전 결함을 재현하고, 발행을 단일 트랜잭션으로 묶어 원자성을 증명했습니다.

기능 1. 창고만 할 수 있는 판정 여섯: DBA의 반복 판정을 컬럼으로

데이터를 내리기만 하던 창고가 판정을 내립니다. 전부 DBA·DBRE가 손으로 하던 일이고, 라이브 7일 창으로는 구조적으로 불가능합니다.

판정답하는 질문설계 핵심
용량 D-day언제 증설해야 하나크기 시계열 선형 추세로 임계까지 잔여일. 7일로는 추세선 표본이 없다
플랜 회귀언제 플랜이 뒤집혀 느려졌나하루 대표 플랜의 뒤집힘을 전후 N일 지연과 겹침. 관측 부족은 PENDING, 창 오염은 AMBIGUOUS로 지어내지 않음
백업 RPO백업이 며칠째 없나기록 없는 인스턴스도 행으로 드러냄. 기준일은 벽시계가 아닌 창고 최신 날짜
미사용 인덱스지워도 되나90일 창 실사용 판정. 후보까지만 내고 삭제는 지시하지 않음(최종 판단은 사람)
설정 변경 상관어떤 변경 뒤 나빠졌나변경 시점과 성능 회귀를 겹침. 상관이지 인과가 아니라 조언 어휘로만
가용성 SLO목표만큼 떠 있었나1분 핑을 하루로 접어 최근 30일 uptime과 SRE 에러버짓 산출
  1. 여섯 판정의 계열은 셋으로 접힙니다.
    • 미래 외삽(용량 D-day), 시점 전후 상관(플랜 회귀·설정 상관), 부재 감지(백업 공백)입니다. 셋 다 전후 며칠을 비교해야 판정이 서므로, 7일 창으로는 구조적으로 못 하고 장기 창고라야 컬럼이 됩니다.
  2. 판정 컬럼까지만 내고, 발화는 하지 않습니다.
    • 알림은 Metabase·DBTower의 몫으로 두어 두 번째 알림 시스템을 만들지 않았습니다.
  3. 라이브 실측에서 곧바로 답이 나왔습니다.
    • 가용성: MSSQL 2대가 uptime 63~64%로 breach, MySQL은 98.86%로 근소 미달, PG·Mongo·Oracle은 99.9% meets였습니다. down 샘플의 ping은 타임아웃 값이라 up 샘플로만 평균해 지연 통계 오염을 막았습니다.
    • 설정 상관: work_mem 변경 2건이 타임라인에 잡혔고, 무변경 다섯 인스턴스는 수집 정상(23사이클)이라 “무변경”과 “미수집”이 한 표에서 갈립니다.
  4. 안 내리는 것도 판정입니다.
    • 저빈도 감사 데이터 기반 change_review는 관측이 성겨 세우면 대부분 PENDING만 나와 자리만 열어 뒀습니다.
    • 가용성을 가져오기 전에 원천 테이블 29개를 전부 대조해 창고의 몫과 관제·인프라(Prometheus)의 몫을 갈랐습니다.

가용성 SLO 대시보드 실화면, 기종별 uptime과 에러버짓, MSSQL 2대 breach 검출

기능 2. 남이 그대로 띄우는 어플라이언스, 그리고 원천으로의 되쓰기

  1. “재현 가능”의 숨은 전제를 부수고 셀프호스트 어플라이언스로 만들었습니다.
    • 남이 clone하면 원천이 없어 인스턴스 0개로 조용히 빈 결과가 났습니다. 내 재현은 데모 스택이 옆에 이미 떠 있을 때의 재현이었던 겁니다.
    • 카탈로그 설정에 폴백 하나를 더해 기존 경로를 안 깨고 분리했고, --profile demo로 DBTower 없이 e2e가 도는 상자로 만들었습니다. 사용자는 DuckDB라는 단어를 볼 필요 없이 .env와 Metabase 대시보드만 만납니다.
  2. 범용화는 거부했습니다.
    • 범용 쿼리 분석 도구는 이미 레드오션이고, 범용화하는 순간 원천(DBTower)까지 직접 만든 이 구조의 유일 자산이 죽습니다. Prometheus와 Thanos의 관계처럼, DBTower 옆에 창고를 같이 띄우는 것까지가 목표입니다.
  3. 원천 5종 일반화에서 “테이블만 늘리면 된다”는 전제가 셋 깨졌습니다.
    • 대기 이벤트는 영속 테이블이 없어 시끄럽게 거부하고, 플랜은 개수 기반 보존이라 계약 문서에 명기하고, 백업은 사후 UPDATE되는 변이 테이블이라 불변 워터마크를 따로 잡았습니다.
    • 테이블마다 다른 건 이름이 아니라 성질(워터마크·불변성·게이트 프로필)이라, 성질을 스펙으로 만드니 추출·게이트·DAG가 스펙을 읽는 일반 장치가 됐습니다.
  4. 반대 방향의 일(되쓰기)도 합니다.
    • 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)

두 축 규모 실측, 총량을 고정하고 시간축(dt)에서 인스턴스축(N)으로 축만 돌린 설계

그 밖의 판단과 트레이드오프

  1. Kafka는 쓰지 않았습니다.
    • 스냅샷 원천이라 붙일 이벤트 스트림 자체가 없고, SLA는 일 단위, 소비자는 하나입니다. 스트리밍 채택 3조건(초~분 신선도가 가치 · 다중 소비자 · 분산시스템 운영 여력)에 전부 미해당이라 배치를 골랐습니다.
    • 준실시간이 필요해져도 풀 Kafka가 아니라 경량 CDC + 별도 수집 계층이 맞다고 근거까지 문서화했습니다.
  2. microbatch를 기각하고 delete+insert를 골랐습니다.
    • dbt 공식 증분 전략 중 microbatch는 event_time이 필수고 unique_key 기반 파티션 교체가 안 됩니다. 기각 사유를 문서로 남겼습니다.
  3. 도구를 늘리는 대신 게이트를 키웠습니다.
    • dbt 관측성 도구 elementary는 4축 게이트+webhook과 역할이 겹치고, dbt source freshness도 게이트와 중복입니다. 같은 판정을 두 군데서 내려 기준이 갈라지지 않게 게이트를 단일 진실로 뒀습니다.
  4. 롤링 윈도우를 재설계하고, 오차 원인까지 정직하게 적었습니다.
    • 전체 이력 비교는 오래된 개선을 영원히 기억해 최근 7일 vs 직전 30일로 재설계했습니다. 검증 주입 +50/+150이 경계 겹침 때문에 +47.5/+138.1로 나오는 것까지 원인 규명해 기록했습니다.
    • 실데이터 3일치에서는 이 마트를 0행으로 정직하게 비워 둡니다.
  5. 지문 충돌 SUM은 근사임을 명시했습니다.
    • id로 계열을 온전히 분리하지 못하는 원천 성질이라, “지문 단위 총 활동”까지가 정직한 그레인의 상한입니다.
  6. 클라우드 관리형을 하나도 안 씁니다.
    • 카탈로그는 기존 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에서 가능합니다.