늘 1.37% 부족했습니다 — 그게 오차가 아니라 원천에서 빠진 매출이었습니다
POS에서 매일 세 종류의 데이터를 받아옵니다. 일별 매출 속보, 상품별 매출, 영수증 상세입니다. 같은 하루의 매출을 세 경로로 중복 수집하는 구조인데, 정작 이 셋의 합계가 서로 맞는지 확인하는 화면은 없었습니다.
그래서 수집 결손이 생기면 며칠 뒤 엉뚱한 화면에서 드러났습니다. 정산 리포트 숫자가 이상하다는 문의가 먼저 들어오고, 거슬러 올라가 보면 특정 날짜의 수집이 비어 있었습니다. 검증 지점이 사고 지점에서 한참 떨어져 있던 구조였습니다.
세 수집물을 같은 기준으로 나란히 놓았습니다
먼저 대조 화면을 만들었습니다. 세 수집물의 합계를 총매출과 취소 반영 순액이라는 같은 기준으로 환산해 날짜별로 나란히 놓고, 기준선은 속보로 잡았습니다. 영수증은 반품이 양수로 적재되는 특성이 있어 부호를 뒤집어 차감했습니다.
이 작업의 절반은 숫자를 비교하는 코드가 아니라 비교 기준을 맞추는 일이었습니다. 총계가 어긋날 때 계산식이 틀린 것인지 세는 대상이 다른 것인지부터 갈라야 하는데, 그 구분을 건너뛰면 멀쩡한 로직을 며칠 들여다보게 됩니다.

15초를 3.5초로 줄인 건 새 인덱스가 아니었습니다
화면을 띄우니 30일 조회가 15초였습니다. 영수증 테이블에 날짜가 선두인 인덱스가 없어, 브랜드와 기간 조건만으로는 탈 수 있는 인덱스가 없는 상태였습니다.
운영 DDL을 건드리는 대신 매장 코드 목록을 리터럴로 넘겨 기존 (매장 코드, 날짜) 인덱스를 타게 했습니다. 3.5초가 됐습니다. 데이터도 결과도 그대로고 조건 형태만 바꾼 결과입니다.
늘 1.37% 부족했고, 그래서 아무도 그 화면을 보지 않았습니다
화면이 돌기 시작하자 격차가 보였습니다. 상품별 매출 합계가 속보보다 항상 적었습니다. 최근 30일 기준 1.37%였습니다.
문제는 크기가 아니라 상시성이었습니다. 어제도 어긋났고 그제도 어긋났으니 오늘 어긋난 것은 새로운 것이 아닙니다. 사람은 늘 빨간불인 화면을 몇 주 만에 보지 않게 되고, 그러면 진짜 수집 사고가 난 날의 빨간불도 같은 빨간불에 섞여 사라집니다.
처음엔 이 화면의 완성 조건을 '차이를 정확히 계산해 보여주는 것'으로 뒀습니다. 운영해보니 그 정의로는 화면이 오래 가지 못합니다. 정확한 화면과 실제로 보게 되는 화면은 다른 물건이었습니다.
대조 화면의 목표는 차이를 보여주는 것이 아니라, 정상 상태의 차이를 0으로 만드는 것입니다.
설명 가능한 격차는 주석으로 달지 말고 계산에 포함시켜 없애고, 설명하지 못하는 차이만 화면에 남깁니다. 그러면 빨간불이 뜨는 날이 곧 사고가 난 날이 됩니다. 격차를 설명하는 것과 격차를 0으로 만드는 것은 숫자의 정확도는 같아도 운영 효과가 전혀 다릅니다.

원인은 크롤러가 아니라 원천 데이터이었습니다
처음엔 수집기 버그로 봤습니다. 며칠 파도 나오지 않았습니다. 방향을 돌려 격차 금액 자체를 쪼개보니 정체가 드러났습니다. 격차는 매장이 POS에 직접 등록한 상품, 즉 특정 코드 대역의 매출 전량이었습니다.
30일 전 일자에서 이 대역을 더하니 잔차가 0이 됐습니다. 리포트 API가 본사 상품 마스터에 등록된 코드만 집계하기 때문에, 매장이 자체로 만든 상품은 원천에서 제외된 채 넘어오고 있었습니다. 영향 매장은 수십 곳이었습니다.
선택지 | 결과 |
|---|---|
방치 — 1.37%는 오차로 보고 넘어간다 | 화면이 늘 빨간불이라 진짜 사고를 못 잡는다 |
크롤러를 계속 파고든다 | 원인이 외부 API 사양이라 우리 코드에서는 나오지 않는다 |
대조 화면으로 가시화 → 원인 부류 확정 → 설명 가능한 격차를 계산에 포함 | 정상 차이 0, 남은 차이만 사고로 취급 |
추론을 문서에 적기 전에 원천 API를 직접 호출했습니다
여기까지는 아직 추론이었습니다. '본사 API가 그 대역을 안 준다'를 설계 문서에 적어버리면 그 문장이 다음 작업의 전제가 됩니다. 전제가 틀렸을 때 드는 비용은 확인 비용보다 훨씬 큽니다. 그래서 크롤러 클라이언트로 그 리포트 API를 직접 호출했습니다.
기존과 동일한 파라미터로 호출 — 자체등록 코드 0건
항목 유형을 전체로 바꿔 호출 — 동일하게 0건
매장 지정을 비우고 브랜드 전체로 호출 — 900행 중 0건
파라미터를 바꿔가며 우회 가능성을 하나씩 닫고 나서야 '영수증 라인으로 전환하는 것이 유일한 경로'가 추측에서 사실이 됐습니다. 추론이 결과적으로 맞았더라도, 실증 전에는 다른 엔드포인트나 옵션이 남아 있을 가능성을 닫지 못합니다. 그 가능성을 닫아야 다음 작업의 방향이 확정됩니다.
직접 호출해보고 나서 발견한 극단 사례도 있었습니다. 어떤 매장은 매출의 100%가 자체등록 대역이라 리포트 API가 0행을 반환했습니다. 정상 영업 중인 매장이 본사 리포트에서는 '매출 없는 매장'으로 보이고 있었습니다.
확인 화면이 확인 대상을 구조적으로 빠뜨리고 있었습니다
이 시스템에는 '상품 미확인 매핑' 화면이 있습니다. 본사 상품과 연결되지 않은 상품을 모아 담당자가 매핑해주는 화면입니다. 자체등록 상품이야말로 여기 떠야 할 대상입니다. 그런데 한 건도 없었습니다.
이 목록의 원천이 상품별 매출 크롤이 채우는 테이블이었기 때문입니다. 자체등록 상품은 그 크롤에 아예 들어오지 않으니 미확인 목록에도 뜨지 않았습니다. 확인해야 할 대상이 확인 화면에서 구조적으로 빠져 있던 셈입니다.
이 대목이 이 작업에서 가장 오래 남았습니다. 미확인 목록이 비어 있으면 담당자는 '처리할 게 없다'로 읽습니다. 목록이 빈 이유가 '없어서'인지 '못 들어와서'인지는 화면에 표시되지 않습니다. 감시 화면을 만들 때 목록의 원천이 무엇인지 한 번은 물어봐야 하는 이유입니다.

정리하고 남은 것
상품별 매출 표시에 자체등록 대역을 합산하고 포함 금액을 따로 병기했습니다. 상시 -1.37%가 사라져 정상 상태의 차이가 0이 됐고, 남은 불일치는 실제 사고 4일뿐이었습니다. 각각 4천 원에서 1만 원 규모, 옵션 라인이 원천에서 누락된 부류였습니다.
다만 이 처방이 항상 옳지는 않습니다. 격차를 계산에 포함해 0으로 만드는 방식은 그 격차의 원인이 안정적일 때만 성립합니다. 원인이 변동하면 0으로 맞춘 화면이 오히려 변화를 숨깁니다. 포함 금액을 따로 병기한 이유가 그것입니다.
리터럴 목록으로 인덱스를 태우는 기법도 목록이 길어지면 무너집니다. 실제로 다른 쿼리에서는 같은 기법이 오히려 느려진 적이 있습니다. 그리고 자체등록 상품은 본사 상품 체계 밖이라, 금액은 살렸지만 무엇이 팔렸는지는 여전히 분석하지 못합니다.
다른 시스템에도 그대로 옮겨가는 부분
상시 격차는 감시 기능을 무력화한다. 늘 켜져 있는 경고등은 꺼진 것과 같다.
설명 가능한 차이는 계산에 반영해 없애고, 설명 못 하는 차이만 남긴다. 주석으로 설명하는 것과는 운영 효과가 다르다.
'아마 이래서일 것'을 문서에 적기 전에 원천을 직접 호출한다. 실증 전에는 파라미터·다른 엔드포인트로 우회할 가능성을 닫지 못한다.
확인 화면이 확인 대상을 구조적으로 빠뜨릴 수 있다. 목록의 원천이 무엇인지 물어본다.
인덱스를 추가하기 전에 조건 형태를 먼저 바꿔본다. 15초에서 3.5초로 줄인 것은 새 인덱스가 아니라 기존 인덱스를 타게 만든 조건이었다.
원천 시스템의 사양은 결국 우리 집계의 상한이 된다. 저쪽의 설계 결정이 우리 숫자의 한계선을 정한다.
대조·감시 화면을 만들 때 점검할 것
정상 상태에서 이 화면의 차이가 0인가. 상시 격차가 남아 있으면 아직 완성이 아니다.
화면에 남은 격차 중 원인을 문장으로 설명할 수 있는 것이 몇 개인가. 설명되는 것은 계산에 넣을 후보다.
격차를 0으로 맞출 때 포함 금액을 따로 보여주고 있는가. 원인이 변하면 화면이 그것을 숨긴다.
'미확인'·'미매핑' 목록의 원천 테이블이 무엇을 채우고 무엇을 못 채우는가.
원천 사양을 근거로 삼은 결론이 문서에 있다면, 그 사양을 직접 호출로 확인한 기록이 함께 있는가.
빨간불이 늘 켜져 있는 화면은 꺼진 화면과 같습니다. 이 작업에서 실제로 바뀐 것은 빠져 있던 1.37%를 찾은 것보다, 어긋난 4일이 눈에 보이게 된 쪽입니다. 대조 화면을 운영 중이라면 지난 30일 중 며칠이 초록이었는지 한 번 세어보는 것으로 충분히 시작됩니다.