파트너 포털 결제 대시보드에 신규 회원·판매 7일 추이 통계 추가
목차
파트너 포털 결제 대시보드에 신규 회원/판매 7일 추이 통계를 추가했다. 변경 파일은 내부 클래스 1개, SQL 매퍼 1개, 뷰/스타일 1개. 파일 숫자만 보면 작아 보이지만 실제 작업 범위는 쿼리 설계부터 권한 체크까지 전 레이어에 걸쳐 있었다.
왜 쿼리부터 다시 설계했나
기존 화면/API가 제공하지 않던 데이터가 필요해졌을 때 빠른 방법은 프론트에서 기존 엔드포인트를 조합해 클라이언트 측에서 계산하는 것이다. 일시적으로 동작은 하지만 결제/정산 데이터에서 이 접근은 나중에 반드시 문제를 일으킨다. 집계 기준(날짜 범위 경계, 타임존, NULL 처리)이 호출 측마다 달라지면서 화면마다 숫자가 조금씩 달라지는 상황이 생기기 때문이다.
그래서 단순 UI 추가가 아니라 쿼리 레벨부터 설계해서 정합성을 맞췄다. 신규 회원 집계와 판매 집계 각각의 기준일을 어떻게 정의할 것인지, 7일 추이를 뽑을 때 데이터가 없는 날(거래가 없는 날짜)을 어떻게 채울 것인지, 이 두 가지를 SQL 매퍼에서 명시적으로 처리했다.
빈 날짜 처리는 흔히 놓치는 부분이다. GROUP BY DATE(created_at)만 쓰면 데이터가 없는 날은 결과 행 자체가 없어서, 프론트에서 7개 포인트를 고정으로 그리려면 빈 날을 어딘가에서 채워 넣어야 한다. 이걸 서버에서 할지, 매퍼에서 할지, 프론트에서 할지 선택해야 하는데 이번엔 매퍼 레벨에서 날짜 시퀀스를 생성해 LEFT JOIN 하는 방식을 택했다.
-- 날짜 시퀀스를 생성한 뒤 LEFT JOIN으로 빈 날을 0으로 채움 (개념 예시)
WITH RECURSIVE date_seq AS (
SELECT CURDATE() - INTERVAL 6 DAY AS d
UNION ALL
SELECT d + INTERVAL 1 DAY FROM date_seq WHERE d < CURDATE()
)
SELECT
ds.d AS stat_date,
COALESCE(COUNT(m.id), 0) AS new_member_count
FROM date_seq ds
LEFT JOIN members m
ON DATE(m.created_at) = ds.d
AND m.status = 'ACTIVE'
GROUP BY ds.d
ORDER BY ds.d;
이렇게 하면 집계 기준이 DB 레벨에서 단일하게 고정되고, 프론트는 항상 7개 row가 내려온다고 가정할 수 있다. 결과를 매퍼 내부 클래스(VO)로 타입-세이프하게 받아서 컨트롤러까지 올린 뒤 JSP + AJAX로 차트에 바인딩하는 구조다.
구현 순서와 권한 체크 타이밍
실제 작업 순서는 다음과 같았다.
- 포탈에 신규 메뉴/기능 추가
- 백엔드 API 엔드포인트 구현 (컨트롤러 - 서비스 - 매퍼)
- 프론트 화면 연동 (JSP + AJAX)
- 권한 인터셉터 통과 확인
- 사이드바 메뉴 활성화 처리
권한 인터셉터를 마지막에 확인하면 빠뜨리기 쉽다. 이번엔 의도적으로 중간 단계에서 확인했다. 파트너 포털 특성상 파트너 계정 종류별로 메뉴 노출 범위가 다르고, 인터셉터가 통과 기준을 어떻게 판단하는지 코드를 먼저 읽고 시작했다. 나중에 다른 파트너 계정으로 접근했을 때 404인지 403인지 빈 화면인지 결과가 달라서 디버깅이 복잡해지는 걸 피하려는 이유다.
사이드바 활성화 처리도 작지만 무시하면 안 된다. 단순히 active 클래스 하나지만, 여러 하위 경로에서 동일하게 활성화 상태가 유지되는지 확인해야 한다. 빠뜨리면 사용자가 현재 어느 메뉴에 있는지 UI가 아무런 피드백을 주지 못한다.
설계 시 고민한 트레이드오프
폴링 vs 페이지 로드 1회 집계
7일 추이 데이터를 실시간으로 갱신해야 하는지 여부를 먼저 결정했다. 결론은 "폴링 불필요, 페이지 로드 시 1회 집계"였다. 대시보드 통계는 실무에서 실시간보다 "어제까지 누적 + 오늘 현재 시점" 수준이면 충분한 경우가 많고, 빈번한 폴링은 집계 쿼리 특성상 DB 부하를 불필요하게 높인다. 실시간이 진짜 필요한 상황이라면 캐시 레이어를 끼우거나 배치로 집계 테이블을 갱신하는 방식으로 가야 하는데, 이번엔 거기까지 갈 이유가 없었다.
NULL 처리와 예외 방어
| 방어 위치 | 처리 내용 |
|---|---|
| SQL 매퍼 | COALESCE(COUNT(...), 0) - 빈 날 0 보장 |
| 컨트롤러 | 빈 리스트 반환 시 기본 구조 유지 |
| 뷰(JSP) | 차트 라이브러리가 빈 배열 받았을 때 렌더링 확인 |
데이터가 전혀 없는 파트너 계정으로 대시보드를 열었을 때 차트 라이브러리가 어떻게 동작하는지 실제로 확인했다. "없으면 없는 대로 나오겠지"는 답이 아니다.
쿼리 실행 계획
EXPLAIN으로 실행 계획을 먼저 봤다. 7일 범위라 인덱스만 제대로 타면 풀스캔은 피할 수 있는 구조였고, created_at 컬럼에 인덱스가 이미 있어서 별도 DDL 변경 없이 진행할 수 있었다.
구현 후 직접 화면에서 동작을 확인하고, 기존 데이터가 깨지지 않았는지 관련 화면 숫자를 cross-check했다. 금융/결제 도메인에서 숫자 하나가 틀리면 신뢰가 무너질 수 있어서, "대충 맞는 것 같다"는 처음부터 선택지에서 제외했다.
이번 작업도 체감한 건 마찬가지였다. 기능 하나가 SQL 집계, 상태 처리, 예외 방어, 화면 렌더링, 권한 체크를 모두 물고 있어서 어느 하나만 빠뜨려도 숫자가 맞지 않거나 특정 계정에서 이상한 화면이 나온다. 그래서 변경 전 수치나 동작을 메모해두고 수정 후 같은 케이스로 재확인하는 루틴을 유지하고 있다.
커밋은 매퍼 변경, 컨트롤러 변경, 뷰 변경을 분리해서 논리적으로 독립된 단위로 쪼갰다. 나중에 어느 변경에서 숫자가 달라졌는지 추적할 때 훨씬 빠르다. 커밋 메시지에는 "무엇을 바꿨다"보다 "왜 바꿨는지"를 담으려고 했는데, 이게 생각보다 코드 리뷰 때 맥락 전달에 효과적이다. 작업 의도가 diff만으로 안 보일 때 메시지가 그 역할을 해준다.
댓글 0
첫 댓글 달아줘.