대시보드에 쿠폰 마진 집계 지표 추가
목차
admin/dashboard/total-summary 영역에 쿠폰 마진 집계 지표를 새로 올렸다. 비외부 채널 쿠폰에 한정한 마진 산식과 세부 항목까지 포함해서, 변경 파일은 SQL 매퍼 1개와 뷰/스타일 1개로 소박한 편이었지만 생각보다 꼼꼼하게 따져야 할 게 많았다.
왜 지금 이 지표가 필요했나
기존 대시보드는 주문/결제 중심 KPI만 보여줬다. 쿠폰 관련 지표는 별도 리포트나 개별 화면에서 따로 확인해야 했고, 그마저도 채널 구분 없이 전체 합산으로만 나왔다. 비외부 채널(자사 발행 쿠폰)과 외부 채널을 같은 버킷에 넣으면 마진 계산이 왜곡된다. 외부 채널 쿠폰은 제휴사로부터 별도 보전이 들어오기 때문에 마진 산식에 함께 포함시키면 이중 계산이 된다. 그래서 비외부 채널 쿠폰만 필터링해서 집계하는 로직이 필요했다.
단순히 "화면에 숫자 하나 더 띄우는 것"처럼 보여도, 산식 자체가 틀리면 이 숫자를 보고 내리는 의사결정이 통째로 빗나갈 수 있다. 결제/금융 도메인에서 지표 하나가 잘못 정의되면 신뢰 문제로 이어지고, 그 신뢰를 다시 쌓는 데 드는 비용이 처음 제대로 만드는 것보다 훨씬 크다. 그래서 쿼리 설계부터 UI 포맷까지 전 레이어를 직접 검토하는 걸 기본으로 잡고 시작했다.
구현 흐름
SQL 집계 구조
매퍼 파일에 일별/월별/누적 집계를 각각 작성했다. 핵심은 채널 필터링과 NULL 방어 두 가지다. 집계 쿼리에서 COALESCE를 빠뜨리면 쿠폰이 하나도 없는 날 리포트가 null을 내보내고, 프런트에서 포맷 함수에 null이 들어오는 순간 NaN이나 깨진 문자열이 나타난다. 간헐적으로만 발생하는 종류의 버그라 잡기도 애매하다.
SELECT
DATE_FORMAT(o.created_at, '%Y-%m-%d') AS stat_date,
COALESCE(SUM(
CASE WHEN c.channel_type != 'EXTERNAL'
THEN c.discount_amount ELSE 0 END
), 0) AS coupon_margin_total,
COALESCE(SUM(
CASE WHEN c.channel_type != 'EXTERNAL' AND c.coupon_type = 'RATE'
THEN c.discount_amount ELSE 0 END
), 0) AS coupon_margin_rate,
COALESCE(SUM(
CASE WHEN c.channel_type != 'EXTERNAL' AND c.coupon_type = 'FIXED'
THEN c.discount_amount ELSE 0 END
), 0) AS coupon_margin_fixed
FROM orders o
LEFT JOIN order_coupons c ON c.order_id = o.id
WHERE o.status IN ('COMPLETED', 'PARTIAL_REFUND')
AND o.created_at BETWEEN #{startDate} AND #{endDate}
GROUP BY DATE_FORMAT(o.created_at, '%Y-%m-%d')
ORDER BY stat_date
LEFT JOIN을 선택한 것도 의도적이다. INNER JOIN으로 쓰면 쿠폰을 사용하지 않은 주문이 결과셋에서 빠져버려서 주문 총액 집계와 숫자가 맞지 않는다. 마진 집계 숫자만 놓고 보면 맞는 것 같은데 다른 지표와 엮어보면 이상해지는 유형의 정합성 오류가 생긴다. 조인 타입 선택 하나가 뒤에서 설명할 cross-check 단계를 통과하느냐 마느냐를 가른다.
실시간 갱신 - 폴링으로 결정한 이유
5초 폴링으로 갱신했다. 웹소켓이나 SSE를 검토했지만, 이 대시보드의 사용 패턴은 "항상 열어두고 모니터링"이 아니라 "필요할 때 탭을 열어서 확인"에 가깝다. 초 단위 정확도보다 "열면 최신값이 보인다"가 더 중요한 상황이라 구현 비용 대비 효과를 봤을 때 폴링이 적합했다.
단, 폴링은 탭이 백그라운드 상태일 때도 요청을 계속 보낸다. document.visibilityState로 탭 활성 여부를 체크해서 비활성 탭에서는 폴링을 멈추도록 처리했다.
let pollingTimer = null;
function startPolling() {
if (pollingTimer) return;
pollingTimer = setInterval(fetchCouponSummary, 5000);
}
function stopPolling() {
clearInterval(pollingTimer);
pollingTimer = null;
}
document.addEventListener('visibilitychange', () => {
document.visibilityState === 'visible' ? startPolling() : stopPolling();
});
작은 디테일이지만 서버 쪽 불필요한 부하를 줄이고, 배터리 낭비도 막는다. 폴링 방식에서 자주 빠뜨리는 부분이라 챙겨두면 나쁘지 않다.
숫자 포맷 통일
천단위 콤마와 원 단위 표기는 이미 다른 화면에서 쓰는 공통 포맷 함수를 그대로 재사용했다. 새로 만들면 소수점 처리 방식이나 단위 표기가 화면마다 미묘하게 달라지는 문제가 반드시 생긴다. "99,000원"과 "99000 원"이 같은 서비스에 공존하면 사용자 신뢰가 떨어진다. 공통 함수를 재사용하는 게 정합성을 지키는 가장 확실한 방법이다.
검증과 되짚어보기
구현 후 직접 화면에서 숫자를 확인하고, 관련 화면(쿠폰 리포트, 주문 상세)과 cross-check했다. 내가 쓰는 루틴은 대략 이렇다.
- 변경 전 수치를 메모하거나 스크린샷으로 남긴다
- 변경 후 같은 조건으로 비교한다
- 관련 화면에서 동일 지표를 찾아 숫자가 일치하는지 확인한다
- 빈 데이터(쿠폰 없는 날짜 범위) 케이스를 별도로 테스트한다
금액이 일치하면 통과, 차이가 나면 산식 재확인하는 루틴이다. 이번에는 LEFT JOIN 여부를 바꿔가며 두 번 확인했다.
이번 작업에서 다시 체감한 건, 기능 하나가 SQL 집계, 뷰 로직, 상태 갱신, 예외 처리, 포맷 통일까지 레이어 전체를 건드린다는 것이다. 어느 한 레이어만 빠뜨려도 특정 조건에서 숫자가 깨진다. 금융/결제 도메인은 이런 간헐적 오류가 제일 무섭다. 항상 재현되는 버그는 빠르게 찾는데, 데이터가 없는 날에만 이상하게 보이는 케이스는 발견하는 데 오래 걸린다.
커밋은 SQL 매퍼 변경과 뷰 변경을 별도 커밋으로 분리했다. 나중에 숫자 이슈가 생겼을 때 "쿼리가 문제인지 렌더링이 문제인지"를 git log만 보고 좁힐 수 있어야 한다. 커밋 메시지도 "쿠폰 마진 집계 쿼리 추가"보다는 "비외부 채널 쿠폰 이중 계산 방지를 위해 channel_type 필터 적용"처럼 이유를 담으려 했다. 몇 달 뒤에 왜 이 코드가 있는지 알 수 있어야 한다.
사내 서비스를 계속 만들다 보면, 기능 하나가 화면에 요소 하나 추가하는 것으로 끝나는 경우가 거의 없다. SQL 집계, 상태 머신, 예외 처리, 화면 렌더링, 권한 체크가 다 엮여 있어서, 하나만 빠뜨려도 특정 사용자에게 이상한 화면이 나타나거나 숫자가 맞지 않는다. "대충 맞는 것 같다"로 넘어가면 나중에 반드시 다시 돌아온다. 그게 금융 도메인이다.
댓글 0
첫 댓글 달아줘.