실전 데이터 품질 체크 — 집계 단위 검증과 파이프라인 게이팅
The 7 Data Quality Checks Every Pipeline Needs (With Real SQL) — Darshil Parmar Check 6-7 + Wiring all seven checks (pp.11-18)
이 모듈을 다 읽으면
- 신선도(freshness) 체크가 다른 체크의 오염된 데이터에 속아 넘어갈 수 있는 이유를 설명할 수 있다
- 행 단위 체크로는 잡을 수 없고 분포(distribution) 체크만 잡을 수 있는 버그 유형을 설명할 수 있다
- 일곱 개 체크를 하나의 결과 테이블로 묶어 파이프라인을 게이팅하는 패턴을 설명할 수 있다
신선도 체크가 오염된 타임스탬프에 속는 함정, 그리고 100배 매출 급증의 진짜 원인(단위 변환 버그)을 잡아낸 유일한 체크인 분포 검증을 다룬다. 마지막으로 일곱 체크를 하나의 dq_results 테이블로 묶어 파이프라인을 게이팅하는 프로덕션 패턴과 dbt 테스트와의 대응 관계를 정리한다.
체크 6: 신선도 — 다른 체크의 오염이 이 체크를 속인다
신선도는 일곱 체크 중 가장 쉬워 보인다 — 가장 최신 행이 얼마나 오래됐는지만 보면 된다. 하지만 순진한 버전의 쿼리(`SELECT MAX(order_ts), 지금과의 시간차`)를 그대로 실행하면, 15번 모듈의 유효성 체크에서 발견된 '2099-12-31' 같은 오염된 미래 타임스탬프 행 때문에 '음수 64만 시간 지연'처럼 말이 안 되는 값이 나오고, `hours_stale < 24` 같은 단순 임계값은 이걸 '신선함'으로 잘못 판정해 버린다.
미래 타임스탬프를 걸러내야만 진짜 값이 드러난다 — 실제로 가장 최신 정상 주문은 이 체크를 실행하기 43.5시간 전의 것이었고, 피드는 거의 이틀 동안 멈춰 있었다. 이 사례는 체크들이 서로 의존한다는 것을 보여준다 — 타임스탬프에 기반한 체크를 신뢰하려면 먼저 타임스탬프 자체의 유효성을 검증해야 한다. 유효성 체크가 신선도 체크보다 먼저 실행돼야 하는 이유가 여기에 있다.
핵심 포인트
- 순진한 신선도 체크(MAX(timestamp) 기준 경과 시간)는 미래로 오염된 타임스탬프에 속아 멈춘 피드를 '신선함'으로 오판할 수 있다.
- 타임스탬프 기반 체크를 실행하기 전에 먼저 유효성 체크로 미래/이상 타임스탬프를 걸러내야 한다 — 체크는 서로 의존한다.
체크 7: 분포 — 100배 급증을 잡아낸 유일한 체크
매출이 하루아침에 100배로 뛴 미스터리로 돌아가 보자. 그 날의 주문들은 지금까지의 모든 체크를 통과한다 — ID는 유일하고, NULL도 없고, 고객도 실재하고, 상태값도 유효하고, 금액도 양수이고, 타임스탬프도 정상이다. 행 단위로는 흠잡을 데가 없다.
이제 그 날을 행들의 집합이 아니라 '하나의 형태(shape)'로 봐야 한다:
WITH daily AS (
SELECT order_ts::DATE AS day, AVG(amount) AS avg_order_value
FROM orders WHERE order_ts <= NOW() GROUP BY day
)
SELECT day, avg_order_value,
(SELECT MEDIAN(avg_order_value) FROM daily) AS median_all_days
FROM daily
WHERE avg_order_value > 3 * (SELECT MEDIAN(avg_order_value) FROM daily);
결과는 단 하루: 평균 주문 금액이 78,937.04로, 전체 중앙값 767.39의 102.9배였다. 100배는 결코 유기적인 성장이 아니라 단위 변환 문제다 — 실제로는 소스 시스템이 하루 동안 루피 대신 파이스(1/100 단위) 단위로 금액을 보냈다. ₹589.76이 58976으로 들어온 것이고, 이 값은 완벽하게 유효한 양수이므로 모든 행 단위 체크를 그대로 통과했다.
행 단위 체크는 값(value)을 검증하고, 분포 체크는 의미(meaning)를 검증한다. 통화 단위 전환, 이벤트가 중복 발행되는 버그, 봇 공격으로 인한 가입 수 급증 — 이들 중 어느 것도 단 하나의 무효한 행을 만들지 않으면서, 모두 그 날의 형태를 역사적 범위 밖으로 크게 휘게 만든다. 분포 체크가 없다면 이런 버그들은 다음 분기 재작성(restatement)으로 이어질 때까지 발견되지 않는다.
핵심 포인트
- 그날의 평균 주문 금액이 전체 중앙값의 3배 이상이면 플래그하는 분포 체크가, 행 단위로는 완벽히 정상인 데이터에서 100배 매출 급증(단위 변환 버그)의 유일한 탐지 수단이었다.
- 행 단위 체크는 값의 유효성을, 분포 체크는 데이터셋 전체의 의미(정상 범위)를 검증한다 — 통화 단위 전환, 중복 이벤트 발행, 봇 공격은 무효 행을 만들지 않으면서 분포만 왜곡시킨다.
일곱 체크를 하나로 묶어 파이프라인 게이팅하기
필요할 때만 손으로 실행하는 체크는 체크가 아니라 사후 고고학(archaeology)이다. 프로덕션 패턴은 피드를 raw 테이블에 적재하고, 일곱 쿼리를 모두 실행해 체크당 한 행씩 결과 테이블(`dq_results`)에 쓴 뒤, 모든 행이 통과할 때만 다음 로드를 진행시키는 것이다. 이 패턴은 UNION ALL로 일곱 개의 서브쿼리를 하나의 CREATE TABLE ... AS 문으로 묶어 구현할 수 있다.
이미 dbt를 쓰고 있다면 이 중 네 개는 내장 제네릭 테스트로 대체된다: `unique`, `not_null`, `accepted_values`, `relationships`가 각각 유일성·완전성 일부·유효성·참조 무결성에 대응하고, 소스 신선도(source freshness) 기능이 체크 6을 커버한다. 볼륨과 분포는 여전히 직접 SQL을 작성해야 하는 영역이다.
요약하면: 파이프라인이 멈추면 조사에 한 시간이 들지만, 거짓말하는 대시보드는 누군가 알아챌 때까지 그 위에서 내려진 모든 의사결정의 비용을 치르게 만든다. 그래서 크게, 그리고 로드 이전에 실패해야 한다.
핵심 포인트
- 일곱 체크를 UNION ALL로 묶어 하나의 dq_results 테이블에 쓰고, 모든 행이 통과할 때만 적재를 진행시키는 것이 프로덕션 게이팅 패턴이다.
- dbt를 쓴다면 unique/not_null/accepted_values/relationships 제네릭 테스트와 소스 신선도 기능이 체크 1~6 중 4~5개를 대체하지만, 볼륨과 분포 체크는 직접 SQL로 작성해야 한다.
- 파이프라인을 멈추는 비용은 조사 한 시간이지만, 거짓 대시보드를 방치하는 비용은 그 위의 모든 의사결정에 누적된다.