정산 출금 대시보드에 실시간 KPI 집계 카드 추가
목차
정산·출금 도메인은 화면 하나 바꿀 때도 건드리는 레이어가 꽤 많다. 이번 작업도 마찬가지였다. admin/dashboard 영역에 실시간 KPI 집계 카드를 추가하는 일인데, 뷰·스타일 파일 3개, 내부 클래스 2개, SQL 매퍼 1개가 동시에 바뀌었다. total-summary 카드를 통합하고 정산 출금 partial을 다른 뷰와 공유하는 구조로 만들었다.
작업 자체는 "대시보드에 카드 하나 추가"처럼 보이지만 실제로는 집계 쿼리 설계부터 시작해야 했다. 기존 화면이나 API에서 내려주지 않던 수치라서 SQL 매퍼부터 손댔다.
집계 쿼리를 먼저 설계한 이유
대시보드 KPI는 보통 두 가지 접근이 있다. 하나는 애플리케이션 레이어에서 기존 API 응답을 조합해서 계산하는 방식, 다른 하나는 DB에서 집계 쿼리를 직접 때리는 방식. 이번엔 후자를 택했다.
이유는 단순하다. 일별/월별/누적 집계는 트랜잭션 단건 조회와 쿼리 형태가 근본적으로 다르다. 기존 API를 조합해 "화면에서 계산"하면 페이지네이션이나 필터 상태에 따라 숫자가 달라질 수 있고, 정합성 보장이 어렵다. 특히 정산 도메인은 같은 기간의 합산이 어느 화면에서 봐도 동일해야 한다는 전제가 있어서, 집계 출처를 하나로 고정해 놓는 게 맞다고 판단했다.
SQL 매퍼에 일별/월별/누적 세 가지 집계를 분리해서 넣었다. 아래 패턴으로 작성했음.
-- 누적 집계 예시 (실제 컬럼명은 도메인에 맞게)
SELECT
COUNT(*) AS total_count,
SUM(amount) AS total_amount,
SUM(CASE WHEN status = 'COMPLETED' THEN amount ELSE 0 END) AS settled_amount,
SUM(CASE WHEN status = 'PENDING' THEN amount ELSE 0 END) AS pending_amount
FROM settlement_withdraw
WHERE created_at >= :fromDate
AND created_at < :toDate
월별은 DATE_FORMAT(created_at, '%Y-%m')으로 GROUP BY해서 최근 N개월 슬라이스를 뽑는 구조. 쿼리 실행 계획은 작성하고 나서 바로 확인했다. created_at 인덱스가 있어서 풀스캔은 없었고, 예상 rows 수치를 보고 "이 정도면 5초 폴링을 달아도 무리 없겠다"고 판단하고 진행했다.
실시간 갱신 방식 선택
5초 폴링을 택했다. 웹소켓이나 SSE 같은 이벤트 푸시 방식 대신 폴링을 선택한 근거는 이렇다.
| 방식 | 장점 | 단점 |
|---|---|---|
| 폴링 | 구현 단순, 서버 상태 무관하게 동작 | 불필요한 요청 발생 |
| 웹소켓 | 즉각 반영, 서버 부하 낮음 | 연결 관리, 재연결 로직 필요 |
| SSE | 단방향 푸시, 상대적으로 단순 | 브라우저 연결 수 제한 |
admin 대시보드는 동시 접속자가 많지 않고, 집계 데이터 특성상 "1-2초 딜레이"가 실무에서 문제가 될 상황이 없다. 폴링 주기를 5초로 잡으면 사용자 입장에선 충분히 실시간처럼 느껴지고, 구현 복잡도는 훨씬 낮다. 나중에 트래픽이 늘거나 정말 즉각 갱신이 필요한 상황이 생기면 그때 바꾸면 된다. 섣불리 웹소켓을 달았다가 연결 관리 코드가 비즈니스 로직보다 더 많아지는 경우를 몇 번 봤기 때문에, 지금 단계에선 단순한 게 맞다.
폴링 구현은 뷰 컴포넌트 마운트 시점에 인터벌을 등록하고 언마운트 때 해제하는 패턴으로 처리했다. 탭 전환이나 모달 열림 상태에서도 폴링이 계속 돌면 불필요하므로 document.visibilitychange 이벤트로 탭 비활성 시 정지, 활성 복귀 시 즉시 한 번 호출 후 재시작하도록 방어했다.
숫자 정합성과 예외 처리
숫자 포맷은 전역 필터/유틸로 빼서 통일했다. 천단위 콤마, 원 단위 suffix. 이게 사소해 보이지만 화면마다 제각각이면 "이 화면은 1,234원인데 저 화면은 1234원"이 되고, 사용자는 헷갈려서 오히려 믿지 않게 된다.
방어해야 할 케이스를 미리 정리하고 들어갔다.
- NULL 집계값: SUM이 NULL을 반환할 수 있으므로
COALESCE(SUM(amount), 0)처리 - 빈 기간: 조회 결과가 0건일 때 "-" 또는 "0원"을 명확히 구분해서 표시
- 권한 없는 접근: 카드 자체를 숨기거나 403 핸들링을 뷰 레벨에서 처리
- 부분 로딩 상태: 폴링 중에 카드가 깜빡이지 않도록 스켈레톤 처리
구현 후에는 기존 화면에서 같은 기간을 조회했을 때 숫자가 일치하는지 cross-check했다. 정산 출금 목록 화면의 합산과 대시보드 KPI 카드 수치가 맞아야 한다. 이걸 확인하지 않으면 "대시보드에서는 100만원인데 상세에서는 98만원"이 나오는 상황이 생기고, 그 순간 신뢰가 무너진다. 금융 도메인에서 이런 불일치는 기능 버그보다 훨씬 치명적이다.
이번 작업을 하면서 다시 느낀 건, 기능 추가의 실제 복잡도는 화면이 아니라 데이터 흐름에 있다는 거다. 뷰 파일만 보면 카드 몇 개 추가한 것처럼 보이지만, 그 뒤에는 쿼리 설계, 정합성 검증, 예외 처리, 갱신 방식 결정이 따라온다. 이 레이어들 중 하나라도 소홀히 하면 숫자가 틀리거나 특정 상황에서만 터지는 버그가 된다.
작업 전 현재 수치를 메모해두고, 수정 후 같은 조건으로 확인하는 습관이 이런 도메인에선 특히 유효하다. 커밋도 집계 쿼리 추가, 뷰 구성, 폴링 로직, 포맷 통일을 분리해서 쪼갰다. 문제가 생겼을 때 "어느 변경에서 깨졌나"를 추적하는 비용이 확연히 달라진다.
댓글 0
첫 댓글 달아줘.