포캐스트 시스템 개발 기록 (2) 채널 합계가 누적판매량과 맞지 않던 이유

채널 합계와 누적판매량이 왜 다를까 포케스트 시스템의 재고 운영 관리표(jego12)는 품목 하나를 한 줄에 놓고, 채널별(네이버·쿠팡로켓·B2C·B2B·자사몰·해외) 계획 수량과 판매 수량, 달성률을 옆으로 쭉 펼쳐 보여주는 화면이다. 맨 오른쪽에는 전체 블록이 있어서 당월 누적판매량, 적중률, 예상 월말재고, 예상 품절일이 나온다. 이 화면을 만든 직후 붙잡고 있었던 문제가 하나 있다. 채널별 판매 수량을 다 더한 값이 전체 블록의 누적판매량과 맞지 않았다. 조금 다른 게 아니라 크게 달랐고, 어떤 채널은 달성률이 297%로 튀었다. 이 글은 그 숫자를 맞추기까지의 과정과, 같은 시기에 채널이 32종에서 47종으로 늘면서 생긴 합계 누락 문제를 다룬다. 배경: 실적이 월 마감 후에만 들어온다 처음 관리표는 채널별 판매 수량을 월 실적 테이블에서 읽었다. ERP에서 월 마감 후 적재되는 테이블이라 BOM 전개(번들 상품을 구성품으로 풀어낸) 기준이고 숫자도 정확했다. 문제는 진행 중인 달이었다. 당월을 열면 실적이 아직 없으니 달성률이 전부 0%로 나왔다. 처음에는 기본 조회월을 실적이 있는 마지막 월로 열고, 미마감 월을 고르면 안내 배너를 띄우는 것으로 넘어갔다. 잔여 계획이 아주 작을 때 재고 확보율이 수만 %로 튀는 것도 이때 발견해 999%+로 줄여 표기했다. 하지만 담당자들이 원한 건 "이번 달 지금까지 얼마나 팔렸나"였다. 그래서 회계서버의 월중 누계를 붙이기로 했다. 전체 누적판매량은 회계 forecast-metrics API의 sold (판매보고서 기준 월중 누계)로 바꿨고, 진행 중인 달에도 달성률이 나오기 시작했다. 채널별 수량은 회계서버에 forecast-channel-sales API를 따로 만들어, 거래처분류를 6개 채널로 매핑해 받았다. 스토어팜은 네이버, 쿠팡은 쿠팡로켓, MK몰은 자사몰, 대형마트·특판은 B2B, 해외는 해외로 보내는 식이다. 한 품목을 골라 7월 수치를 월 실적 테이블과 대조했을 때 채널별로 일치하는 것까지 확인했다. 시행착오 번들이 해체되지 않은 채널 매출 품목 하나로는 맞았는데, 번들에 많이 들어가는 품목에서 숫자가 크게 어긋났다. 구강티슈 30매 품목을 예로 들면 2026년 7월 쿠팡 채널 판매가 회계 채널 매출로는 11,240개였는데, 실제(BOM 전개 기준)로는 19,856개였다. 채널 합계가 누적판매량보다 한참 작았다. 원인을 따라가 보니 회계 채널 매출 API가 완제품 매출 테이블을 기준으로 집계하고 있었다. 완제품 매출에는 "구강티슈 3팩 세트"가 세트 한 개로만 찍히고, 그 안의 구강티슈 3개는 구성품 판매로 잡히지 않는다. 반면 누적판매량( sold )은 판매보고서 기준이라 번들이 이미 풀려 있었다. 같은 화면에 서로 다른 기준의 숫자가 나란히 서 있었던 것이다. 첫 대응은 마감 여부로 나누는 것이었다. 마감된 월: BOM 전개 기준인 월 실적 테이블을 쓴다. 같은 품목의 7월 채널 합계가 15,485에서 23,989로 올라가 누적판매 24,239와 거의 맞았다. 진행 중인 월: 마감 실적이 없으니 회계 채널 매출을 쓰되, "번들 구성품은 실제보다 적게 잡히고 월 마감 후 교체된다"는 경고를 빨간 글씨로 띄운다. 임시방편이라는 건 알고 있었다. 진행 중인 달이야말로 담당자가 가장 많이 보는 달인데, 그 달의 채널 숫자에 "틀릴 수 있음"을 붙여 놓은 셈이었다. 그래서 회계서버 쪽 API를 고쳤다. 채널 매출을 완제품 매출이 아니라 판매보고서(BOM 전개) × 거래처분류로 집계하도록 바꿨더니, 7월 그 품목의 채널 합계가 누적판매 24,239와 정확히 같아졌다(쿠팡 19,856). 마감월·진행월 분기를 걷어내고 항상 회계 채널값을 쓰게 했고, 월 실적 테이블은 회계 응답이 없을 때의 폴백으로만 남겼다. 빨간 경고문도 "같은 판매보고서 기준이라 일치합니다"라는 한 줄로 바뀌었다. B2B 달성률 297%: 키워드 매칭 순서 숫자를 맞추고 나서 보니 2026년 7월 B2B 달성률이 297.3%, 해외는 0.1%였다. 해외 판매가 갑자기 사라지고 그만큼 B2B가 부푼 모양새였다. 원인은 거래처분류 매핑 순서였다. 거래처분류 이름에 채널 키워드가 들어 있는지를 stripos 로 보고 처음 걸리는 채널로 보냈는데, 순회 순서가 화면에 채널을 보여주는 순서와 같았고, 해외는 뒤쪽에 있었다. 그런데 거래처분류 중에 '해외(B2B)'가 있었다. 이 이름은 해외를 검사하기 전에 B2B 키워드에 먼저 걸렸다. 표시 순서와 판정 순서를 같은 배열 하나로 쓰고 있었던 게 문제였다. 판정 순서를 표시 순서에서 떼어 내 해외를 가장 먼저 검사하게 했다. B2B는 297.3%에서 91.9%로, 해외는 0.1%에서 정상 범위로 돌아왔다. /** * 회계서버 거래처분류 → 채널 그룹 키. * '해외(B2B)' 처럼 다른 채널 키워드를 품은 분류명이 있어 해외를 가장 먼저 판정한다. * (표시 순서인 fo_channels() 배열 순서와 분리) */ function fo_acc_channel_key_of(string $accChannel): string { $name = trim($accChannel); if ($name === '') { return 'etc'; } $channels = fo_channels(); foreach (['overseas', 'naver', 'coupang', 'mall', 'b2c', 'b2b'] as $key) { foreach (($channels[$key]['acc'] ?? []) as $needle) { if (stripos($name, $needle) !== false) { return $key; } } } return 'etc'; } 바뀐 건 순회 대상뿐이다. 배열 순서에 의미를 두 가지(보여주는 순서와 판정 우선순위) 실어 두면 한쪽을 바꿀 때 다른 쪽이 조용히 깨진다는 걸 이번에 확인했다. 채널이 32종에서 47종으로: 사라진 해외 합계와 NULL 전파 8월 말에 ERP 실적 적재 배치의 채널이 32종에서 47종으로 늘었다. 3.오프라인대리점, 7.면세점, 11.기타, 직원구매, 국가 미지정 해외, 그리고 일본·사우디·카자흐스탄 등 해외 10개국이 새 컬럼으로 추가됐다. 실적 테이블은 채널마다 RussiaCount , JapanCount 같은 컬럼을 따로 두는 구조라, 합계를 내려면 컬럼을 하나하나 더해야 했다. 그 덧셈식이 14개 PHP 파일에 손으로 적혀 있었다. 새 채널이 합계에 하나도 반영되지 않았고, 해외 합계에서 신규 국가가 통째로 빠졌다. 더 고약한 건 포케스트 요약 화면(forecast11)이었다. 4개월 평균을 구하는 식이 IFNULL 없이 A + B + C ... 로 컬럼을 그냥 더하고 있었다. 기존 컬럼만 있을 때는 값이 늘 채워져 있어 문제가 없었지만, 신규 컬럼은 과거 행에 전부 NULL이었다. SQL에서 NULL이 하나라도 섞이면 덧셈 결과가 NULL이 되니, 4개월 평균과 전월 판매가 통째로 사라졌다. 14개 파일을 다시 하나씩 고치는 대신 채널 정의를 한 파일로 모았다. forecast_channels.php 에 B2C·B2B·기타·단독·해외 그룹별 채널 목록을 배열로 두고, 합계식은 이 배열에서 생성하게 했다. /** 채널 목록 → SQL 합계식 문자열 (모든 항에 COALESCE를 씌운다) */ function fc_sum( array $channels, string $tpl = 'COALESCE(%s, 0)', string $prefix = '', string $suffix = 'Count', string $sep = "\n + " ): string { $terms = []; foreach ($channels as $ch) { $terms[] = sprintf($tpl, $prefix . $ch . $suffix); } return implode($sep, $terms); } // 사용하는 쪽: 32줄짜리 덧셈식이 한 줄로 바뀐다 $sql = "SELECT ItemCode, (" . fc_total_sum("IFNULL(%s, 0)") . ") AS prevSales FROM forecast_monthly_data WHERE Date = '$prevDate'"; 템플릿 기본값을 COALESCE(%s, 0) 로 둔 게 핵심이다. 호출하는 쪽이 NULL 처리를 깜빡해도 합계가 사라지지 않는다. 수량( Count )과 금액( Price )은 접미어로만 구분해 같은 정의를 공유했다. 정리하는 김에 B2B에 3.오프라인대리점과 면세점을 넣고, 성격이 모호한 11.기타와 직원구매는 별도 그룹으로 빼서 총계에만 포함했다. 재고 화면 한 곳의 판매실적 합계에서 빠져 있던 온라인매입도 이때 발견해 넣었다. 결과 관리표의 채널 합계와 누적판매량이 진행 중인 달에도 같은 기준(판매보고서 BOM 전개)으로 일치하게 됐다. 화면 상단에는 두 숫자를 나란히 적어 두어, 혹시 다시 어긋나면 바로 눈에 띄게 했다. 채널이 또 늘어나면 forecast_channels.php 의 배열 하나만 고치면 되고, 이 정의는 이후 팀별 적중률과 매출 추이 화면에서도 그대로 쓰고 있다. 회고 세 문제 모두 "같은 화면의 숫자가 서로 다른 기준을 쓰고 있었다"로 요약된다. 번들 문제는 완제품 기준과 BOM 전개 기준, 매칭 문제는 표시 순서와 판정 순서, NULL 문제는 과거 행과 신규 컬럼의 차이였다. 처음 화면을 만들 때 숫자마다 출처를 화면에 적어 둔 덕분에, 어긋남을 발견하고 원인을 좁히는 속도가 빨랐다. 다시 한다면 마감월·진행월 분기 같은 화면 쪽 우회를 만들기 전에 원천 API를 먼저 고쳤을 것이다. 분기와 경고문은 결국 하루 만에 걷어냈다. 그리고 채널처럼 늘어날 수밖에 없는 목록은 처음부터 한 곳에서 정의했어야 했다. 32개 컬럼을 손으로 더하는 식이 14군데 있다는 걸 알게 된 건 채널이 늘어 숫자가 틀린 다음이었다. 기간: 2026.08 ~ 2026.08 기술: PHP, MySQL, 회계서버 JSON API, SQL 관련 프로젝트: 포캐스트 시스템