매장을 여러 곳 운영하는 고객사의 주간 운영 리포트를 자동화하고 있다. POS API에서 주차 데이터를 긁어 JSON으로 굳히고, 그걸로 임원용 리포트를 렌더한다. 45주치를 백필해 두고 매주 한 주씩 붙이는 구조다.
이 파이프라인에는 검증기가 있다. 수집이 예외를 안 던지고 조용히 틀리는 사고를 이미 세 번 겪었기 때문이다. 매장 ID를 빼고 조회하면 API가 200 OK를 주면서 기본 매장 것만 돌려주고, 커서를 끝까지 안 따라가면 절반만 읽히고, 정산 상태 필터가 좁으면 며칠 뒤 확정된 건이 통째로 빠진다. 셋 다 에러가 없다. 그래서 “값이 서로 맞는가”를 따로 검사하는 스크립트를 8종 붙여 뒀다.
이번 주에 지표 정의 오류를 두 개 찾았다. 검증기 8종은 둘 다 통과시켰다. 왜 못 잡았는지가 이 글의 본론이다.
오류 ①: 매출에 팁과 세금이 들어 있었다
수집 코드가 주문의 합계 금액 필드를 그대로 매출로 쓰고 있었다.
gross = int((o.get("total_money") or {}).get("amount", 0))
POS API의 total_money에는 팁과 세금이 포함된다. 팁은 직원에게 가는 돈이지 회사 매출이 아니다. 45주 실측에서 팁은 매출의 7.5% 수준이었다.
문제는 크기가 아니라 분산이었다. 팁 비율이 매장마다 4.1%에서 8.5%까지 달랐다. 즉 전사 합계만 부푸는 게 아니라 매장 간 비교 자체가 왜곡된다. 팁이 후한 매장이 매출로도 앞서 보이고, 인건비율은 낮게 나온다. 파생 지표에서 인건비율이 전사 기준 2.1%p, 매장에 따라 1.4~3.1%p 낮게 계산되고 있었다.
고친 건 두 줄이다.
# total_money 에는 팁과 세금이 포함된다. 팁은 직원에게 가는 돈이지 회사 매출이 아니다.
# 매장별 팁 비율이 4.1~8.5% 로 달라 그대로 두면 매장 간 비교까지 왜곡된다.
tip = int((o.get("total_tip_money") or {}).get("amount", 0))
tax = int((o.get("total_tax_money") or {}).get("amount", 0))
gross = int((o.get("total_money") or {}).get("amount", 0)) - tip - tax
품목별 매출도 같은 함정이 있어 라인아이템 세금까지 같이 뺐다. 그리고 45주 전량을 재수집했다. 24분, 44/44 성공, 검증 실패 0.
가장 뼈아픈 건 따로 있었다. 월간 표 스크립트의 각주에 “팁은 제외됩니다”라고 이미 적혀 있었다. 사실이 아니었다. 주석은 검증되지 않는다.
오류 ②: 회원 거래를 customer_id로 세고 있었다
두 번째는 더 컸다. 주문에 customer_id가 있으면 회원 거래로 세고 있었는데, POS가 결제 시 전화번호로 로열티를 붙일 때는 주문에 customer_id를 남기지 않는다. 적립은 되는데 주문에는 흔적이 없다.
customer_id로 세면 실제의 40% 수준만 잡힌다. 45주 내내 “회원 식별률 15.3%”로 보고하고 있었는데 실제는 37.7%였다. 재수집 후 회원 거래 비중은 14.7% → 37.2%(2.54배)로 바뀌었다.
이건 숫자 몇 개가 틀린 문제가 아니다. “우리 고객의 85%는 비회원”이라는 전제로 프로모션 제안서를 쓰고 있었다. 전제가 반대에 가까웠으므로 제안서도 다시 썼다.
올바른 판정 기준은 주문이 아니라 적립 이벤트다. 그런데 이벤트 검색 API는 limit이 30이 상한이라 한 주에 130여 페이지가 든다. 45주 백필이면 6,000페이지다. 그래서 이벤트를 주차 루프 밖으로 빼서 기간을 통째로 한 번 받아 캐시하고, 수집기는 거기서 읽기만 하게 했다.
# 캐시 구조 — order_id 를 키로 둔다. 수집기가 주문을 훑으며 조회하는 축이 그것이다.
# {order_id: [loyalty_account_id, 기본별, 프로모션별, location_id, created_at]}
if o["id"] in MEMBER_ORDERS:
S[L]["member_orders"] += 1
S[L]["member_gross"] += gross
MACC[L].add(MEMBER_ORDERS[o["id"]]) # 고유 회원 계정
if o.get("customer_id"):
S[L]["cid_orders"] += 1 # 참고용 — 구 지표와의 대조에만 쓴다
두 가지 판단이 들어 있다.
- 구 지표를 지우지 않고
cid_orders로 남겼다. 새 숫자가 2.5배로 튀었을 때 “새 코드가 과대집계하는 것 아닌가”를 확인할 대조군이 필요하다. 정의를 바꾸는 커밋에서 옛 정의를 같이 굴리는 비용은 카운터 하나다. - 캐시가 없으면 회원 지표만 비우고 나머지는 정상 수집한다. 보조 데이터가 없다고 파이프라인 전체를 멈추면, 급할 때 리포트를 못 뽑는다.
46주를 다시 수집했다. 주차 JSON 46개가 18,404줄 추가 / 16,128줄 삭제로 갈렸다.
왜 검증기 8종이 둘 다 놓쳤나
기존 검사는 전부 이런 모양이었다.
# ① 정산 대사 — 항목의 합에서 수수료를 빼면 입금액과 1센트도 어긋나면 안 된다
check(comp - p.get("fees", 0) == p["deposited"], w, "정산 대사 불일치")
# ② 부분집합 관계 — 모집단을 넘을 수 없는 값들
check(s.get("member_orders", 0) <= s["orders"], w, f"{n}: 회원주문 > 전체주문")
check(s.get("member_gross", 0) <= s["gross"], w, f"{n}: 회원매출 > 전체매출")
“부분의 합 = 전체”와 “부분집합 ≤ 전체”는 정의가 틀려도 참이다. 매출에 팁이 섞여 있어도 부분의 합은 여전히 전체와 같다. 회원 주문을 실제의 40%만 세도 전체 주문보다는 여전히 작다. 항등식은 수집 누락을 잡는 도구지 정의 오류를 잡는 도구가 아니다.
정의 오류의 특징은 데이터가 자기 안에서 완벽하게 일관된다는 것이다. 그래서 바깥에 기준이 있어야 잡힌다.
두 오류를 실제로 잡은 것도 내부 검사가 아니었다.
| 오류 | 잡은 방법 |
|---|---|
| 매출에 팁 포함 | 회사가 별도 기준으로 마감·공표한 연간 매출과 대조 → 팁 포함 시 11% 벌어짐 |
| 회원 거래 과소집계 | 적립 이벤트 총건수와 customer_id 기준 건수를 직접 세어 비교 |
첫 번째는 특히 명확했다. 팁을 뺀 우리 집계는 외부 수치와 1.2% 차이로 맞았고, 팁을 포함하면 11%가 벌어져 설명이 안 됐다. 독립된 두 출처가 일치한다는 것이 유일하게 신뢰할 만한 신호였다.
그래서 규칙을 두 개 만들었다.
- 매출 정의를 건드리는 변경은 외부 대조를 다시 통과해야 한다. 검증기 통과는 조건이 아니다.
- 검증기에 범위 검사를 추가한다. 항등식과 달리 범위는 정의가 바뀌면 깨진다. 객단가 범위 검사를 넣었다 — 팁이 다시 섞이면 객단가가 부푼다.
재수집이 싸야 정의를 고칠 수 있다
두 번의 오류에서 매번 45주치를 통째로 다시 수집했다. 그게 가능했던 이유는 하나뿐이다. 주차 JSON을 불변으로 두고, 수집을 언제든 반복 가능하게 짜 뒀기 때문이다. 전체 재수집이 24분이면 정의를 고치는 결정은 쉽다. 하루가 걸렸다면 “다음 주부터 적용하자”로 타협했을 것이고, 과거 45주는 영원히 틀린 채로 남았을 것이다.
재수집 비용은 데이터 파이프라인의 품질 상한을 정한다. 이건 사후에 알게 된 게 아니라, 이번에 두 번 연달아 확인한 것에 가깝다.
한계 · 다음 계획
- 범위 검사는 임계값이 임의적이다. 객단가 상·하한을 실측으로 잡아 뒀지만, 계절성이나 메뉴 개편으로 정상적으로 벗어날 수 있다. 지금은 경고로만 두고 사람이 본다.
- 외부 대조는 연 1회 수준으로만 가능하다. 대조할 공표 자료가 그 주기로 나온다. 주간 단위로 정의 오류를 잡을 방법은 아직 없다.
- 적립 이벤트 캐시는 시작일 이전 구간을 못 채운다. 캐시 커버리지보다 앞선 주차를 수집하면 회원 지표가 빈 채로 나온다. 지금은 경고를 찍게 해 뒀다.
- 남은 지표 중 정의를 의심해 볼 후보를 한 번 훑을 생각이다. 할인·환불처럼 “총액에 어떻게 반영되는지”가 애매한 것들이 우선이다.
FAQ
Q. 검증기를 몇 개 더 붙이면 정의 오류도 잡히지 않나?
같은 종류를 늘리면 안 잡힌다. 항등식·부분집합 검사는 데이터 내부의 일관성을 보는데, 정의 오류는 내부적으로는 완벽하게 일관되기 때문이다. 늘려야 하는 건 개수가 아니라 종류다. 외부 기준 대조, 범위 검사, 두 정의를 동시에 굴려 비교하는 대조군 — 이 셋은 정의가 틀리면 깨진다.
Q. POS API에서 매출을 뽑을 때 뭘 먼저 확인해야 하나?
합계 금액 필드에 무엇이 들어 있는지부터 본다. 대부분의 결제·POS API에서 주문 총액에는 팁과 세금이 포함되어 있고, 벤더 대시보드는 그것들을 분리해 보여준다. 화면 값과 API 값이 다르다는 걸 전제로 시작하는 편이 안전하다. 그리고 팁 비율은 매장·시간대별로 달라서, 전사 평균으로 보정하면 비교가 또 틀어진다. 필드에서 빼는 게 맞다.
Q. 지표 정의를 바꿀 때 과거 데이터는 어떻게 하나?
전량 재수집이 가능하면 그게 가장 깔끔하다. 정의가 섞인 시계열은 나중에 반드시 사고를 낸다. 재수집이 비싸다면 순서를 뒤집어서, 먼저 재수집을 싸게 만드는 작업(원본 캐시, 멱등한 수집기, 불변 스냅샷)을 하고 정의를 고치는 게 낫다. 그리고 바꾸는 커밋에서는 옛 지표를 지우지 말고 같이 남겨 대조군으로 쓴다.
Q. 코드 주석에 정의를 적어 두는 건 도움이 되나?
도움은 되지만 보증은 안 된다. 이번에 월간 표 각주에 “팁은 제외됩니다”라고 적혀 있었는데 사실이 아니었다. 주석은 실행되지 않으므로 틀려도 아무도 모른다. 정의는 주석이 아니라 검사로 적는다.