결제 대시보드 수익 카드 분리
목차
dashboard-revenue 영역에 카드 두 개를 분리하는 작업을 했음. 겉으로 보면 레이아웃 변경이지만, 실제로는 SQL 집계 쿼리를 새로 짜고 외부 채널 sale_price의 정본 출처를 명확히 잡는 것부터 시작해야 했다.
변경 파일은 뷰/스타일 2개, SQL 매퍼 1개. 파일 수만 보면 작은 작업처럼 보이는데 과정은 그렇지 않았음.
왜 카드를 분리해야 했나
기존에는 "판매 수익"이라는 하나의 합산 카드로 표시되고 있었다. 문제는 쿠폰 판매 수익과 수수료 수익이 성격이 완전히 다른 항목인데 한 덩어리로 묶여 있다 보니, 지표를 볼 때마다 수작업으로 쪼개야 했음. 대시보드의 KPI 카드가 P&L 분석에 바로 쓰이지 못하고 중간 계산을 거쳐야 하는 상황이 반복되고 있었다. 대시보드라는 이름이 무색한 상태였음.
외부 채널 쪽도 issue가 있었다. sale_price가 여러 테이블에 흩어져 있어서 어느 값을 "정본"으로 봐야 하는지 불명확한 상태였음. 같은 지표를 화면마다 다른 쿼리로 가져오면 숫자가 조금씩 달라지고, 그게 쌓이면 어느 화면 숫자를 믿어야 하는지 모르게 된다. 결제/금융 도메인에서 이건 단순한 UX 문제가 아님. 데이터 신뢰가 무너지면 그 대시보드는 장식이 돼버리고, 담당자들은 결국 엑셀로 돌아간다.
그래서 이번 작업의 목표는 두 가지였음.
- 쿠폰 판매 수익 / 수수료 수익을 각각 독립 카드로 분리
- 외부 채널 sale_price 정본을 SQL 레벨에서 단일 소스로 고정
KPI 카드 정합 통일은 부수효과가 아니라 핵심 목적이었다. 화면에 카드가 하나 더 생기는 게 아니라, "이 숫자가 저 숫자와 왜 다르지?"라는 질문이 다시는 나오지 않도록 만드는 게 진짜 목적이었음.
구현하면서 고민했던 부분들
SQL 집계 설계
집계 쿼리는 일별/월별/누적 세 단위로 나눠 작성했다. 단위가 다르면 WHERE 조건이나 GROUP BY가 달라지는데, 이걸 하나의 범용 쿼리로 전부 커버하려다가 조건 분기가 복잡해지는 경우를 꽤 많이 봐왔음. 이번에는 단위별로 매퍼를 분리해서 각각 관리하기로 했다. 유지보수 측면에서 이 편이 낫다. 범용 쿼리 하나보다 단순한 쿼리 셋이 나중에 읽기도 쉽고 튜닝 포인트도 명확하다.
-- 월별 수수료 수익 집계 예시
SELECT
DATE_FORMAT(created_at, '%Y-%m') AS month,
IFNULL(SUM(fee_amount), 0) AS fee_revenue
FROM orders
WHERE channel = 'external'
AND status = 'COMPLETED'
AND created_at BETWEEN #{startDate} AND #{endDate}
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ORDER BY month DESC
NULL 처리는 항상 신경 써야 한다. fee_amount가 NULL인 행이 섞이면 SUM 결과가 기대와 달라질 수 있어서 IFNULL로 방어 로직을 넣었음. 빈 결과셋도 마찬가지인데, 프론트에서 undefined를 받으면 NaN으로 렌더링되는 케이스가 생각보다 자주 나온다. 프론트에서 null guard를 쓰는 것보다 쿼리 출처에서 IFNULL(SUM(...), 0)으로 막아두는 게 훨씬 안전하고, 어디서 처리하는지 헷갈리지 않음. 쿼리 실행 계획도 미리 확인했다. 집계 범위가 넓어지면 풀 스캔이 나올 수 있어서 created_at, channel, status가 인덱스에 포함되어 있는지 점검하고 넘어갔음.
폴링 vs 이벤트 방식 선택
실시간 갱신은 5초 폴링으로 구현했다. 이벤트 기반이 더 효율적이긴 하지만, 이 화면은 내부 관리용이고 동시 접속자가 많지 않아서 폴링으로 충분하다고 판단했음. 방식 선택은 요건 강도와 인프라 복잡도 사이의 트레이드오프다.
| 방식 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|
| 폴링 | 구현 단순, 안정적 | 빈 요청 지속 발생 | 접속자 적고 실시간성 요건이 느슨할 때 |
| SSE | 서버 푸시, 연결 효율 | 단방향, 연결 관리 필요 | 서버에서 클라이언트로 단방향 스트림 |
| 웹소켓 | 양방향, 저지연 | 인프라 복잡도 높음 | 채팅, 실시간 협업 등 강한 실시간 요건 |
폴링의 단점인 빈 요청 문제는 응답을 캐싱하거나 ETag 기반 조건부 요청을 활용하면 어느 정도 해소됨. 지금 단계에서는 그 정도 최적화가 필요하지 않아서 단순하게 갔다. 복잡도는 실제로 문제가 생겼을 때 올리는 게 낫다. 미리 과설계하면 코드는 복잡해지고 정작 문제가 다른 곳에서 나오는 경우가 많음.
숫자 포맷 통일
천단위 콤마와 원 단위 표기를 통일하는 건 생각보다 놓치기 쉽다. 컴포넌트마다 각자 포맷 로직을 쓰다 보면 "1,234,000원"과 "1234000원"이 같은 화면에 공존하는 일이 생김. 작은 것 같아도 이게 대시보드 신뢰도를 떨어뜨린다. 숫자 표기가 일관되지 않으면 사람들은 데이터를 의심하기 전에 시스템을 의심하게 된다. 이번에 공통 포맷 유틸로 정리해서 뷰 두 곳 모두 같은 함수를 물리도록 했음.
function formatKRW(value) {
if (value == null || isNaN(value)) return '0원';
return Number(value).toLocaleString('ko-KR') + '원';
}
단순한 함수지만 null 방어와 NaN 처리를 함께 묶어두면 나중에 다른 화면에서 재사용할 때도 일관된 동작을 보장함. 포맷 로직이 여기저기 흩어져 있으면 하나를 고쳐도 다른 곳이 남아있어서 결국 같은 문제가 반복된다.
검증과 커밋 습관
구현 후 직접 화면에서 동작을 확인했다. 기존에 표시되던 합산 카드 숫자와 분리된 두 카드의 합이 일치하는지부터 봤고, 관련 화면에서 보이는 동일 지표와도 숫자가 맞는지 cross-check했음. 일별/월별/누적 각각 케이스를 바꿔가며 확인했다. 특히 경계값(월초, 월말, 데이터가 없는 날짜)에서 이상한 숫자가 나오는 경우가 종종 있어서 그쪽도 직접 짚어봤음.
이 확인 과정을 번거롭게 느끼는 경우가 있는데, 금융 도메인에서는 그냥 기본값으로 해야 하는 일이다. "대략 맞는 것 같다"로 넘어가면 결국 다시 돌아오게 돼 있음. 그걸 몇 번 경험하면 꼼꼼하게 확인하는 게 오히려 더 빠른 길이라는 걸 알게 된다. 나중에 "어느 배포 이후로 숫자가 달라졌는데요"라는 말을 듣고 뒤져보는 게 훨씬 비용이 크다.
커밋은 논리적으로 독립된 단위로 쪼개는 걸 유지하고 있다. SQL 매퍼 변경, 뷰 변경, 스타일 변경을 하나의 커밋에 몰아넣으면 나중에 어디서 숫자가 깨졌는지 추적하기가 어려워짐. 작게 쪼개두면 문제가 생겼을 때 특정 커밋까지 빠르게 좁힐 수 있다. 커밋 메시지도 "무엇을"보다 "왜"를 담으려는 편이다. 3개월 뒤에 git log를 봤을 때 "왜 이걸 바꿨지?"라는 질문에 바로 답이 나와야 하기 때문임. "fee_amount NULL 처리 추가"보다는 "외부 채널 수수료 집계 시 NULL 행 포함으로 인한 집계 오류 방지"처럼 맥락이 있는 쪽이 훨씬 낫다.
사내 서비스 특성상 기능 하나가 화면 버튼 하나에서 끝나는 경우가 거의 없음. SQL 집계, 포맷 유틸, 뷰 렌더링, 정합 검증이 전부 연결되어 있어서 어느 하나를 빠뜨리면 엉뚱한 숫자가 나오거나 특정 조건에서만 깨지는 버그가 생긴다. 이번 작업도 그 흐름 그대로였고, 작업 단위를 잘 나누는 게 결국 가장 중요한 실행 습관이라는 걸 다시 한번 확인했음.
댓글 0
첫 댓글 달아줘.