회계 통합 대시보드 개발 기록 (2) 번들 상품과 판매량 804,333 맞추기

합계가 맞지 않는 판매량 회계 통합 대시보드에 데이터를 모으고 나서 가장 먼저 받은 질문은 "왜 이 화면과 저 화면의 판매량이 다르냐"였다. 수요 예측 쪽에서 쓰는 API 중에 당월 누적 판매량(sold)을 주는 것과, 같은 달 판매량을 채널별로 나눠 주는 것이 있었다. 채널별 숫자를 모두 더하면 당월 누적 판매량과 같아야 하는데, 특정 품목에서 채널 합계가 눈에 띄게 적었다. 적게 잡히는 품목들에는 공통점이 있었다. 단품으로도 팔리지만 세트(번들) 상품의 구성품으로도 나가는 품목이었다. 이 글은 번들 상품 때문에 생긴 집계 차이를 두 번 만나고 고친 기록이다. 원인: 완제품 기준 테이블과 BOM 전개 테이블 매출 데이터를 PostgreSQL에 적재하는 배치는 두 종류가 있었다. 테이블 기준 번들 처리 tb_items_total 판매된 상품 코드 그대로(완제품) 번들은 번들 코드 한 줄로 남음 tb_items_total_receiving WMS의 BOM 전개 뷰 기준 번들을 구성품 코드와 수량으로 펼침 당월 누적 판매량은 BOM 전개 테이블에서 계산하고 있었는데, 채널별 판매 API는 완제품 테이블을 보고 있었다. A 세트에 B 품목이 2개 들어 있다면, BOM 기준에서는 세트 하나가 팔릴 때 B가 2개 팔린 것으로 잡히지만 완제품 기준에서는 "A 세트 1개"로만 남는다. 채널별로 B를 찾으면 세트로 나간 수량이 통째로 빠진다. 구성품이 과소 집계되는 이유가 이것이었다. 채널별 API가 처음부터 완제품 테이블을 쓴 데는 이유가 있었다. 채널을 나누려면 거래처명이 필요했는데, 완제품 테이블에는 거래처명( custknm )이 있었고 BOM 전개 테이블에는 없었다. 거래처명 → 채널 매핑 테이블과 조인할 수 있는 쪽을 고른 것이다. 시행착오 1: 채널별 판매를 BOM 기준으로 옮기기 처음 시도와 증상 처음 구현은 완제품 테이블의 국내 매출과 해외 매출 테이블을 각각 조회해 합치는 방식이었다. 국내는 거래처 카테고리로 채널을 나누고, 해외는 통째로 "해외" 채널로 붙였다. 품목 코드는 대문자로 바꾸고 해외 출고 코드에 붙는 -OS 접미사를 떼서 맞췄다. 채널 목록은 그럴듯하게 나왔지만, 당월 누적 판매량과 나란히 놓고 보니 번들 구성품으로 나가는 품목에서 채널 합계가 모자랐다. 이 API를 만든 다음 날 원천을 바꿨다. 바꾼 방법 먼저 BOM 전개 배치의 Oracle 조회에 거래처명을 추가했다. SELECT와 GROUP BY에 거래처명 한 컬럼을 더한 것이 전부지만, GROUP BY 키가 늘어나니 행 단위가 더 잘게 쪼개진다. 이 테이블을 쓰는 기존 화면이 행 단위에 의존하고 있으면 숫자가 바뀔 수 있어서, 소비하는 쿼리를 모두 찾아봤다. 전부 SUM(...) GROUP BY skuno 형태라 합계는 변하지 않는다는 것을 확인하고 넣었다. 그 다음 채널별 판매 API의 원천을 BOM 전개 테이블로 바꿨다. 당월 누적 판매량과 같은 원천, 같은 품목 코드 정규화를 쓰는 것이 핵심이었다. -- 판매보고서(BOM 전개) 기준. 당월 누적판매량(sold)과 같은 원천·같은 SKU 정규화를 쓴다. SELECT REGEXP_REPLACE(TRIM(t.skuno_bom), '-OS$', '') AS skuno, COALESCE(NULLIF(TRIM(cat.customer_category), ''), '미분류') AS channel, SUM(COALESCE(t.reqcnt_bom_sum, 0)) AS qty FROM tb_items_total_receiving t LEFT JOIN app_customer_categories cat ON cat.customer_name = t.custknm WHERE SUBSTRING(t.salesindate_start, 1, 6) = %s AND COALESCE(TRIM(t.skuno_bom), '') <> '' GROUP BY REGEXP_REPLACE(TRIM(t.skuno_bom), '-OS$', ''), COALESCE(NULLIF(TRIM(cat.customer_category), ''), '미분류') 해외 매출 테이블을 따로 더하던 부분은 지웠다. BOM 전개 테이블에 이미 해외 매출이 포함되어 있어서, 그대로 두면 이번에는 해외가 두 번 잡힌다. 매핑이 없는 거래처는 버리지 않고 "미분류" 채널로 모았다. 버리면 다시 합계가 어긋나기 때문이다. 결과 바꾼 뒤 2026년 7월 기준으로 전체 판매량(sold) 804,333과 채널별 합계 804,333이 일치해 차이가 0이 됐다. 이 검증을 커밋 메시지에 숫자로 남겨 두었는데, 나중에 비슷한 문의가 왔을 때 "어느 달 기준으로 맞춰 봤는가"를 바로 답할 수 있어서 유용했다. 시행착오 2: 협력사 재고현황에서 다시 만난 번들 증상 8월 중순에 협력사(제조사)별 재고현황 화면을 만들었다. 제조사별로 재고 금액, 출고 금액, 회전율(출고 금액 / 재고 금액)을 보고, 회전율이 기준(기본 1.5) 아래인 곳을 따로 모아 보여 주는 화면이다. 품목의 제조사는 SKU 정보의 제조사 항목에서 가져왔다. 그런데 재고와 출고 데이터는 번들 코드 기준으로 남아 있어서, 세트가 출고되면 그 수량과 금액이 번들 코드 한 줄에 붙은 제조사로만 잡혔다. 세트 안의 단품을 실제로 공급한 협력사 쪽에는 그 출고가 반영되지 않으니 회전율이 실제보다 낮게 나올 수 있었다. 판매량 문제와 원리는 같았다. 번들을 펼치지 않으면 구성품 쪽 숫자가 빠진다. 바꾼 방법 이번에는 BOM 전개 테이블을 쓸 수 없었다. 재고와 출고는 각각 다른 원천이었고, 둘 다 번들 코드 기준으로 남아 있었다. 그래서 WMS의 상품 구성 정보(부모 상품 코드, 구성품 코드, 구성 수량)를 매일 출고 배치에서 함께 읽어 tb_wms_sku_pack 테이블에 동기화하도록 했다. 부모-구성품 쌍을 기본키로 두고, 구성 수량이 0 이하인 줄은 버린다. 보고서 쿼리에서는 재고·출고 행을 이 테이블과 LEFT JOIN해서, 구성 정보가 있는 번들은 구성품 행으로 펼친다. 수량은 번들 수량 × 구성 수량 , 금액은 번들 수량 × 구성 수량 × 구성품 단가 로 다시 계산하고, 제조사와 품목상세도 구성품 쪽 값을 쓴다. CASE WHEN %s AND p.child_skuno IS NOT NULL THEN COALESCE(NULLIF(TRIM(p.child_manufacturer), ''), NULLIF(TRIM(child.skudesc5), ''), b.raw_partner) ELSE b.raw_partner END AS partner, CASE WHEN %s AND p.child_skuno IS NOT NULL THEN COALESCE(b.qty, 0) * COALESCE(p.packunits, 1) ELSE b.qty END AS qty, CASE WHEN %s AND p.child_skuno IS NOT NULL THEN COALESCE(b.qty, 0) * COALESCE(p.packunits, 1) * COALESCE(NULLIF(p.child_price1, 0), child.price1, 0) ELSE b.amount END AS amount ... LEFT JOIN tb_wms_sku_pack p ON %s AND p.parent_skuno = b.norm_skuno 조건마다 붙은 %s 는 "번들 단품전개" 켜기/끄기 값이다. 기본은 켜짐이지만, 번들 단위로 보고 싶다는 요청도 있어서 화면에서 끌 수 있게 했다. 이 값을 SQL 파라미터로 넘기면 쿼리를 두 벌 관리하지 않아도 된다. 대신 같은 불리언을 일곱 번 넘겨야 해서 파라미터 순서를 맞추는 데 신경을 써야 했다. 결과와 남은 일 단품전개를 켜면 세트로 나간 출고가 실제 단품 협력사에 나뉘어 잡힌다. 금액을 구성품 단가로 다시 계산하기 때문에 번들의 판매가와 구성품 단가 합계가 다르면 금액 합계는 원래와 달라진다. 이 화면의 목적이 "협력사별 재고 대비 출고 흐름"이었기 때문에 단가 기준 재계산을 택했다. 매출 금액을 맞춰 보는 용도라면 단품전개를 끄고 보는 편이 맞다. 덤: 무거워진 화면을 나누기 협력사 재고현황은 요약표 아래에 협력사별, 품목상세별 상품 리스트를 아코디언으로 펼쳐 보는 구조였다. 처음에는 페이지를 열 때 모든 협력사의 상품별 재고·출고를 한꺼번에 조회했다. 사용자는 보통 한두 협력사만 펼쳐 보는데, 매번 전체 상품 리스트를 계산하고 있었다. 상품 리스트 조회를 별도 JSON 엔드포인트로 떼어 내고, 아코디언을 펼칠 때 그 협력사(또는 품목상세)만 조회하게 바꿨다. 보고서 빌더에는 include_products 플래그를 두어, 화면에서는 요약만 계산하고 엑셀 내보내기에서만 상품 단위까지 계산한다. 이렇게 경로가 둘로 갈라지자 실수가 하나 나왔다. 제조사별 운영상태(수요 예측 시스템의 운영 여부) 컬럼을 화면에 추가하면서 엑셀 쪽 빌더의 품목 행에는 그 값을 채우지 않았고, 엑셀을 만들 때 KeyError 가 났다. 운영상태 조회 결과를 엑셀 경로에서도 함께 채우도록 고쳤다. 같은 데이터를 두 경로로 만들면 컬럼을 추가할 때 두 곳을 모두 고쳐야 한다는 걸 이때 다시 배웠다. 회고 "합계가 같아야 하는 두 숫자"를 먼저 정했어야 했다. 당월 누적 판매량과 채널별 합계는 정의상 같아야 하는데, 서로 다른 테이블에서 계산하고 있었다. 지금은 같은 원천을 쓰게 맞췄지만, 처음부터 "이 두 API의 합계는 같아야 한다"는 검증을 배치 후 자동으로 돌렸다면 더 빨리 잡았을 것이다. 번들은 원천 단계에서 펼치는 게 낫다. 판매는 WMS의 BOM 전개 뷰 덕분에 원천에서 해결됐지만, 재고와 출고는 보고서 쿼리 안에서 펼쳤다. 다른 화면이 같은 요구를 하면 같은 CASE 문을 또 써야 한다. 다시 한다면 구성 정보로 펼친 재고·출고 테이블을 배치 단계에서 따로 만들겠다. GROUP BY 키를 바꿀 때는 소비자를 먼저 찾는다. 거래처명 컬럼 하나를 추가하는 일이 기존 화면의 숫자를 바꿀 수도 있었다. 이번에는 모든 소비 쿼리가 품목 단위 합계라 문제가 없었지만, 확인한 내용을 커밋 메시지에 남겨 둔 덕분에 나중에 다시 의심하지 않아도 됐다. 3편에서는 오류 없이 조용히 틀린 값이 들어가던 두 사례, 쿠팡의 "0 응답"과 마케팅 재무 자료 덮어쓰기를 다룬다. 기간: 2026.08 ~ 2026.09 기술: Python, Flask, PostgreSQL, Oracle(WMS), openpyxl 관련 프로젝트: 회계 통합 대시보드