데이터 사고는 대부분 조용히 일어난다. 원천 팀이 컬럼 하나를 바꾸거나 배치가 절반만 적재돼도 파이프라인은 성공으로 끝나고, 며칠 뒤 보고서 숫자가 틀린 것을 누군가 발견한다. 이 글은 생산자와 소비자 사이의 약속을 Data Contract로 문서화하고, 그 약속을 CI와 파이프라인에서 자동으로 검사해 깨진 데이터가 서비스 테이블에 닿기 전에 막는 방법을 정리한다.

한눈에 보기
| 단계 | 누가 | 무엇을 검사하나 | 실패하면 |
|---|---|---|---|
| 계약 작성 | 생산자(원천 팀) | 스키마, 의미, 품질 규칙, SLA | — |
| 변경 PR | 생산자 CI | 새 스키마가 이전 버전과 호환되는가 | 병합 차단 |
| 적재 직후 | 소비자 파이프라인 | 건수·합계 대사, 유일성, 참조 무결성 | 격리 또는 경고 |
| 주기 점검 | 소비자 파이프라인 | 신선도, 분포 변화 | 경고, 담당자 호출 |

핵심은 같은 계약 파일 하나를 생산자와 소비자가 함께 쓴다는 점이다. 계약이 위키 문서로만 있으면 검사 코드와 금방 어긋난다.
정합성 검증의 일곱 가지 종류
검사는 많이 만드는 것보다 종류를 빠뜨리지 않는 것이 중요하다. 아래 일곱 가지가 대부분의 사고를 덮는다.
| 종류 | 질문 | 예시 규칙 |
|---|---|---|
| 스키마 | 컬럼 이름·타입·필수 여부가 약속대로인가 | amount는 decimal(18,2), 필수 |
| 완전성 | 보낸 만큼 왔는가 | 원천과 대상의 일자별 건수가 같다 |
| 정확성 | 값이 변하지 않았는가 | 일자별 amount 합계가 같다 |
| 유일성 | 중복이 없는가 | order_id가 대상에서 유일하다 |
| 참조 무결성 | 가리키는 대상이 있는가 | 모든 customer_id가 고객 테이블에 있다 |
| 신선도 | 제때 왔는가 | 최신 updated_at이 1시간 이내 (예시) |
| 분포 변화 | 평소와 모양이 같은가 | 일 건수가 7일 평균의 ±30% 안 (예시) |
앞의 다섯 가지는 맞고 틀림이 분명해서 차단 기준으로 쓴다. 신선도와 분포 변화는 정상인데도 흔들릴 수 있어 경고로 시작한다. 연휴에 주문이 반으로 줄어도 데이터는 맞기 때문이다.
시스템 사이에 메시지를 주고받으며 생기는 정합성(중복 전송, 유실)은 Outbox·Inbox 패턴 글에서 다뤘다. 이 글은 이미 쌓인 데이터의 품질과 그 약속을 다룬다.
Data Contract에 들어가야 할 것
Data Contract는 "이 데이터는 이런 모양이고, 이런 뜻이고, 이 정도 품질을 이 시각까지 보장한다"는 생산자의 약속이다. 여섯 칸이 있으면 쓸 만하다.
- 소유자: 데이터를 바꿀 권한과 책임이 있는 팀. 문제가 생기면 호출할 대상이다. 개인 이름보다 팀 이름이 낫다. 사람은 떠난다.
- 스키마: 컬럼 이름, 타입, 필수 여부, 기본 키.
- 의미: 컬럼이 무엇을 뜻하는지.
amount가 부가세 포함인지, 취소 건이 음수로 오는지 같은 것이다. 타입이 맞아도 뜻이 다르면 숫자는 틀린다. - 품질 규칙: 앞 절의 일곱 가지 중 이 데이터에 적용할 것과 기준값.
- SLA: 언제까지 도착하는지, 얼마나 오래 보관하는지.
- 버전·호환성 정책: 버전 번호를 어떻게 올리는지, 어떤 변경을 허용하는지, 깨지는 변경은 몇 주 전에 알리는지.
계약 형식을 직접 만들 필요는 없다. Open Data Contract Standard(ODCS)나 datacontract.com의 Data Contract Specification처럼 공개된 사양이 있고, 검사 도구도 함께 나와 있다. 다만 사양을 고르는 것보다 여섯 칸을 실제로 채우는 것이 먼저다.
계약 파일 예시
아래는 이 글의 예시 형식이다. 공개 사양과 필드 이름이 다르니 실제로 쓸 때는 고른 사양에 맞춘다.
# contracts/orders.yaml — 이 글의 예시 형식
contract: orders
version: 2.1.0 # 깨지는 변경이면 앞자리를 올린다
owner: 주문팀
compatibility: BACKWARD # Schema Registry 호환성 모드와 맞춘다
schema:
- name: order_id
type: bigint
required: true
unique: true
meaning: 주문 번호. 취소돼도 재사용하지 않는다
- name: customer_id
type: bigint
required: true
references: customers.customer_id
- name: status
type: string
required: true
allowed: [PAID, CANCELLED, REFUNDED]
- name: amount
type: decimal(18,2)
required: true
meaning: 부가세 포함 결제 금액(원). 환불은 별도 행으로 온다
quality:
reconcile: [count, sum(amount)] # 일자별로 원천과 대조
volume_change_pct: 30 # 7일 평균 대비, 경고만
sla:
freshness: 1h
retention: 5y
notice_for_breaking_change: 4w
meaning을 비워 두지 않는 것이 중요하다. 스키마는 도구가 읽고, 의미는 사람이 읽는다.
생산자 쪽: CI에서 스키마 호환성 막기
가장 싼 검사는 변경이 병합되기 전에 하는 검사다. 이벤트를 Kafka로 보낸다면 Schema Registry의 호환성 모드가 이 일을 한다.
- BACKWARD(기본값): 새 스키마로 이전 데이터를 읽을 수 있어야 한다. 소비자를 먼저 올린다. 필드 삭제와 기본값 있는 필드 추가가 허용된다.
- FORWARD: 이전 스키마로 새 데이터를 읽을 수 있어야 한다. 생산자를 먼저 올린다.
- FULL: 둘 다. 배포 순서를 신경 쓰기 싫으면 이것을 쓰고 변경 폭을 줄인다.
- 뒤에
_TRANSITIVE가 붙으면 바로 앞 버전이 아니라 모든 이전 버전과 비교한다.
CI에서는 등록 전에 호환성만 물어본다.
# PR 단계: 새 스키마가 최신 등록 버전과 호환되는지 확인
curl -s -X POST \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data "{\"schema\": $(jq -Rs . < schemas/orders.avsc)}" \
"$SCHEMA_REGISTRY_URL/compatibility/subjects/orders-value/versions/latest" \
| jq -e '.is_compatible == true'
jq -e가 거짓이면 0이 아닌 값으로 끝나서 파이프라인이 실패한다. DB 테이블이 원천이면 같은 원리로 마이그레이션 파일을 계약의 schema와 비교하는 검사를 둔다. DB 변경을 이벤트로 내보내는 방법은 CDC와 Debezium 글에 정리했다.
소비자 쪽: 파이프라인 검사와 대사 SQL
적재가 끝나면 소비자는 계약의 품질 규칙을 검사한다. dbt를 쓰면 기본 제공 테스트 네 개로 유일성·필수·허용값·참조 무결성을 바로 덮는다.
# models/orders.yml — dbt 1.8 이상은 data_tests, 이전 버전은 tests 키를 쓴다
models:
- name: orders
columns:
- name: order_id
data_tests: [unique, not_null]
- name: status
data_tests:
- accepted_values:
values: ['PAID', 'CANCELLED', 'REFUNDED']
- name: customer_id
data_tests:
- relationships:
to: ref('customers')
field: customer_id
config:
severity: warn # 고객 적재가 늦는 날이 있어 경고로 시작
dbt 버전에 따라 키 이름(tests/data_tests)과 인자 위치가 다르니 쓰는 버전 문서를 확인한다. Python 파이프라인이면 Great Expectations 같은 도구가 같은 역할을 한다.
건수와 합계 대사는 원천과 대상에서 같은 쿼리를 따로 돌려 결과를 비교한다.
-- 원천과 대상에서 각각 실행 (PostgreSQL 기준)
SELECT count(*) AS cnt,
sum(amount) AS amount_sum,
md5(string_agg(order_id::text || ':' || status || ':' || to_char(amount, 'FM9999999999999990.00'),
',' ORDER BY order_id)) AS digest
FROM orders
WHERE order_date = DATE '2026-11-04';
건수가 같고 합계가 다르면 값이 바뀐 것이고, 둘 다 같은데 해시가 다르면 상태나 순서처럼 합계로 안 보이는 차이가 있는 것이다. 숫자를 문자열로 만들 때는 to_char로 형식을 고정한다. 그러지 않으면 10.0과 10.00이 다른 해시를 만든다. 해시는 하루치처럼 범위를 나눠 계산한다. 테이블 전체를 한 문자열로 이으면 메모리가 버티지 못한다.
실패했을 때: 차단, 경고, 격리, 기록
검사가 실패했을 때 무엇을 할지가 검사 자체보다 중요하다. 정하지 않으면 모든 실패가 경고가 되고, 경고는 아무도 보지 않는다.
- 차단: 스키마, 완전성, 정확성, 유일성. 서비스 테이블 교체를 멈추고 어제 데이터를 그대로 둔다. 틀린 오늘보다 맞는 어제가 낫다.
- 격리: 행 단위로 걸러낼 수 있는 실패. 규칙을 어긴 행만
orders_quarantine에 옮기고 나머지는 통과시킨다. dbt의store_failures설정을 켜면 실패 행이 별도 테이블에 남는다. - 경고: 신선도, 분포 변화. 계약의 소유자에게 알린다. 경고가 한 달에 열 번 넘게 울리면 기준값을 고친다.
검사 결과는 실행 시각, 계약 버전, 규칙, 기대값, 실제값, 판정을 한 행으로 남긴다. 이 표가 있으면 "지난달 15일 데이터는 검증됐나"에 바로 답할 수 있고, 감리나 감사 때 그대로 증적이 된다. 증적을 자동으로 모으는 구조는 감리 대응 증적 자동 수집 글에서 다뤘다. Iceberg 같은 테이블 형식을 쓰면 검사가 끝난 스냅샷만 공개하는 방식도 가능하다(Data Lakehouse와 Apache Iceberg 글).
자주 하는 실수
- 계약을 위키에만 둔다. 검사 코드가 계약을 읽지 않으면 둘은 첫 변경에서 어긋난다. 계약 파일을 저장소에 두고 CI와 파이프라인이 같은 파일을 읽게 한다.
- 모든 검사를 차단으로 건다. 분포 검사로 매일 파이프라인이 멈추면 사람들은 검사를 끈다. 처음에는 확실한 규칙만 차단한다.
- 건수만 맞춘다. 건수가 같아도 금액이 틀릴 수 있다. 건수, 합계, 해시를 함께 본다.
- 소유자를 비워 둔다. 경고가 울려도 받을 사람이 없으면 검사가 없는 것과 같다.
- 깨지는 변경을 버전 번호로만 알린다. 소비자는 버전 번호를 보지 않는다. 계약에 적은 기간 전에 직접 알리고, CI 호환성 검사로 몰래 바뀌는 것을 막는다.
점검표
- 데이터마다 계약 파일이 저장소에 있고 소유 팀이 적혀 있다.
- 금액·상태처럼 오해하기 쉬운 컬럼에
meaning이 적혀 있다. - 스키마 변경 PR에서 호환성 검사가 돌고, 실패하면 병합이 막힌다.
- Schema Registry 호환성 모드와 계약의
compatibility가 같다. - 적재 뒤 일자별 건수·합계·해시를 원천과 대조한다.
- 기본 키 유일성과 외래 키 참조 무결성을 검사한다.
- 규칙마다 차단·격리·경고 중 무엇인지 정해져 있다.
- 실패한 행이 격리 테이블에 남고, 다시 처리하는 절차가 있다.
- 검사 결과가 실행 시각·계약 버전과 함께 표로 남는다.
- 경고 기준값을 한 달에 한 번 실제 경고 횟수를 보고 고친다.
정합성 검증 자동화는 검사 도구를 고르는 일이 아니라, 생산자와 소비자가 같은 계약 파일을 읽고 그 계약이 깨지면 누가 무엇을 멈출지 미리 정해 두는 일이다.
댓글 0개
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.