실전 데이터 품질 체크 — 행 단위 검증(볼륨·유일성·완전성·참조무결성·유효성)
The 7 Data Quality Checks Every Pipeline Needs (With Real SQL) — Darshil Parmar Check 1-5 (pp.5-11)
이 모듈을 다 읽으면
- 볼륨, 유일성, 완전성, 참조 무결성, 유효성 체크를 각각 SQL로 작성할 수 있다
- 완전성 체크가 컬럼마다 다른 허용 기준(budget)을 가져야 하는 이유를 설명할 수 있다
- 웨어하우스가 FK를 강제하지 않기 때문에 참조 무결성을 직접 쿼리로 검증해야 하는 이유를 설명할 수 있다
실제로 실행 가능한 SQL로 5가지 행 단위 데이터 품질 체크(볼륨, 유일성, 완전성, 참조 무결성, 유효성)를 구현하는 방법을 다룬다. 원문은 DuckDB로 의도적으로 오염된 주문 피드를 로드해 각 체크가 서로 다른 실제 버그를 잡아내는 과정을 그대로 재현한다.
왜 '체크 목록'이 아니라 '실행 가능한 쿼리'가 필요한가
데이터 품질 체크 목록은 대부분 freshness, volume, nulls처럼 벤더 슬라이드에서 베낀 용어만 나열하고 실제로 실행할 수 있는 SQL 한 줄도 제공하지 않는다. 월요일 아침 매출 대시보드가 갑자기 100배로 뛰었을 때 필요한 것은 목록이 아니라 지금 당장 실행할 수 있는 쿼리다.
이어지는 다섯 개의 행 단위(row-level) 체크는 행을 하나씩 판단한다 — 키가 유일한지, 필수 필드가 채워져 있는지, 외래 키가 실제로 해석되는지, 값이 유효한지를 본다. 16번 모듈에서 다룰 두 개의 집계(aggregate) 체크는 데이터셋 전체의 '모양(shape)'을 판단한다는 점에서 근본적으로 다르다.
핵심 포인트
- 실행 가능한 SQL 없이 개념만 나열한 체크 목록은 실제 인시던트 대응에 쓸모가 없다.
- 행 단위 체크는 각 행을 개별적으로 판단하고, 집계 체크는 데이터셋 전체의 모양을 판단한다.
체크 1: 볼륨 — 누락은 아무 에러도 내지 않는다
가장 저렴한 체크는 단순히 행 수를 세는 것이다. 각 날짜의 행 수를 자신의 트레일링 평균과 비교해, 평소의 절반 미만으로 떨어진 날을 찾아낸다.
WITH daily AS (
SELECT order_ts::DATE AS day, COUNT(*) AS n
FROM orders GROUP BY day
)
SELECT day, n,
ROUND(AVG(n) OVER (ORDER BY day ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING), 0)
AS prior_7d_avg
FROM daily
QUALIFY n < 0.5 * prior_7d_avg
ORDER BY day;
실제 사례에서 특정 날짜는 평소 평균 46건 대비 13건(정상의 28%)만 들어왔다. 이 날의 적재 사고가 매출 급락의 진짜 원인이었다. 핵심은, 행이 누락된 날에는 어디에서도 에러가 나지 않는다는 점이다 — 모든 다운스트림 잡이 초록불로 성공하고 모든 대시보드가 정상 렌더링되지만, 숫자는 조용히 실제보다 훨씬 낮게 나온다. 볼륨 체크가 존재하는 이유는 '부재(absence)는 침묵한다'는 이 사실 때문이다.
핵심 포인트
- 볼륨 체크는 오늘의 행 수를 최근 N일 트레일링 평균과 비교해 급격한 감소를 잡아낸다.
- 행 누락은 어떤 에러도 발생시키지 않는다 — 파이프라인은 성공으로 끝나고 대시보드도 정상 렌더링되지만 숫자는 조용히 왜곡된다.
체크 2, 3: 유일성과 완전성
유일성 체크는 키별 사본 수를 센다: `SELECT order_id, COUNT(*) AS copies FROM orders GROUP BY order_id HAVING COUNT(*) > 1`. 같은 타임스탬프를 가진 중복 쌍이 여럿 발견됐다면 이는 '적어도 한 번(at-least-once) 전달' 방식 재시도가 두 번 다 성공했다는 신호다 — 사람이 같은 초에 주문을 두 번 입력할 리는 없기 때문이다. 중복은 가만히 있지 않고 JOIN을 타고 증식한다 — 중복된 주문 하나가 모든 다운스트림 SUM에서 매출을 두 배로 만들고, 다른 테이블이 거기에 다시 JOIN되면 팬아웃이 누적된다. 그래서 리포트마다 매번 중복 제거를 하는 대신, 게이트에서 한 번만 중복을 제거하는 것이 옳다.
완전성은 법이 아니라 예산(budget)이다. 어떤 컬럼은 NULL을 어느 정도 허용해도 되고, 어떤 컬럼은 NULL이면 그 행 자체가 쓸모없어진다. 주문 금액(amount)이 없으면 매출로 잡을 수 없으므로 NULL 예산은 0이어야 하고 위반 시 피드 전체를 실패시켜야 한다. 반면 게스트 체크아웃 경로에서 나온 customer_id 누락은 일별 매출 합계에는 영향을 주지 않으므로 2% 정도의 예산과 알림 정도로 충분하다. 컬럼마다 다른 기준 없이 '어디에도 NULL이 있으면 안 된다'는 전역 규칙 하나만 두면, 팀은 결국 그 알림을 무시하도록 학습될 뿐이다.
핵심 포인트
- 유일성 체크는 GROUP BY 키 + HAVING COUNT(*) > 1로 중복을 찾으며, 동일 타임스탬프를 가진 중복 쌍은 재시도(at-least-once delivery)의 흔적이다.
- 중복은 JOIN을 통해 증식하므로 매 리포트가 아니라 게이트에서 한 번만 제거해야 한다.
- 완전성은 전역 규칙이 아니라 컬럼별 예산이다 — 매출 금액처럼 필수적인 컬럼은 NULL 예산 0, 부가적인 컬럼은 소량의 예산을 허용한다.
체크 4, 5: 참조 무결성과 유효성
웨어하우스와 레이크하우스는 원칙적으로 외래 키를 강제하지 않는다(07번 모듈 참고). 그래서 존재하지 않는 고객을 가리키는 주문이 생기는 것을 막을 방법이 없고, 안티 조인(anti-join)으로 직접 확인해야 한다:
SELECT o.order_id, o.customer_id, o.order_ts, o.amount
FROM orders o
LEFT JOIN customers c USING (customer_id)
WHERE o.customer_id IS NOT NULL AND c.customer_id IS NULL
ORDER BY o.customer_id;
이렇게 발견된 '고아(orphan)' 레코드는 삭제된 계정, 테스트 사용자, 또는 동기화가 어긋난 고객 내보내기(export) 등에서 나온다. 피해는 나중에 JOIN 시점에 드러난다 — 이너 조인은 고아 레코드를 조용히 버리고, 레프트 조인은 NULL 속성을 남긴다. 어느 쪽이든 '고객 도시별 매출' 합이 전체 매출과 맞지 않게 되어 누군가 오후 내내 이유를 찾아 헤매게 된다.
유효성 체크는 두 질문이다: 값이 허용된 집합 안에 있는가, 그리고 수치가 허용된 범위 안에 있는가. 열거형(enum) 값 체크로 대소문자가 섞인 상태값('DELIVERED' vs 'delivered') 같은 드리프트를 잡고, 범위 체크로 음수 금액이나 미래 타임스탬프 같은 이상값을 잡는다. 모든 열거형은 언젠가 드리프트하고, '불가능한' 값도 언젠가는 실제로 들어온다 — 유효성 체크는 그것을 고객이 발견하기 전에 쿼리로 먼저 발견하는 수단이다.
핵심 포인트
- 웨어하우스는 FK를 강제하지 않으므로, LEFT JOIN + WHERE 참조키 IS NULL 형태의 안티 조인으로 고아 레코드를 직접 찾아야 한다.
- 고아 레코드를 방치하면 이너 조인은 행을 조용히 버리고 레프트 조인은 NULL을 남겨, 서로 다른 대시보드의 매출 합이 어긋나는 원인이 된다.
- 유효성 체크는 열거형 드리프트(대소문자 변경 등)와 범위 위반(음수 금액, 미래 타임스탬프)을 잡아낸다.