[ 관리자 대시보드 만들기: 캐시 지표 ]
프로젝트를 진행하면서 운영 관점의 지표를
한눈에 확인할 수 있는 관리자 대시보드가 필요했다.
특히 캐시 충전 데이터는
이번 달 상태만 보여주면 끝이 아니라,
지난달 대비 흐름, 결제 수단 구성,
연 단위 추이까지 같이 봐야 의미가 있었다.
이번에 만든 캐시 관리자 대시보드는
1) 월간 충전 KPI(전월 대비)
2) 충전 수단 비율
3) 연 기준 월별 충전금액 그래프
3개 영역으로 구성했다.
겉보기에는 차트를 3개 배치하는 작업 같지만,
실제 구현에서 신경 쓴 부분은 차트 옵션과
표현 규칙과 예외 케이스 처리였다.
전체 구현 흐름
대시보드는 화면부터 만들기 시작하면 중간에 계속 흔들린다.
그래서 작업 순서를 고정했다.
- 데이터 계약(ViewModel) 확정
- KPI 규칙 확정: 전월 대비 계산 + 음수 UX 정책
- 숫자 포맷/단위 정책 확정: KPI와 차트의 언어 통일
- 차트 렌더링: 결제수단 비율, 연간 월별 추이
- 연간 차트 보강: 최고/최저 태그 + 잘림 방지
- 실전 이슈 대응: 경계 튐, 줌/리사이즈, 연도 변경 업데이트
- 감소/0/급증 등 테스트 데이터로 케이스 점검
위 과정을 통해 완성된 화면 미리 보기


1. 데이터 계약을 먼저 고정하기
대시보드는 같은 데이터를 서로 다른 컴포넌트가 공유한다.
KPI, 결제수단 비율, 연간 차트가 각각 다른 방식으로
데이터를 들고 있으면 나중에 기준이 어긋나거나
연도 변경 시 일부만 갱신되는 문제가 생긴다.
그래서 먼저 ‘입력값’만을 중심으로
ViewModel로 더미 데이터 형태를 고정했다.
전월 대비 퍼센트처럼 파생되는 값은 저장하지 않고,
이번 달/지난달 합계로부터 화면에서 계산하도록 두었다.
이렇게 해두면 데이터 소스가 바뀌어도
계산 규칙이 한 군데에 남아서 유지보수가 쉬워진다.
ViewModel(더미 데이터 설정)
// 대시보드가 바라보는 '최소 데이터 계약' 예시
// 포인트: 전월대비 같은 파생 값은 저장하지 않고, thisMonth/lastMonth 입력으로 화면에서 계산한다.
const vm = {
year: 2026,
// KPI 입력(전월대비 계산의 입력값)
thisMonthTotal: 5600000,
lastMonthTotal: 7200000, // 예: 이번달 < 지난달이면 음수 케이스 발생
// (선택) 전월 승인건수까지 있으면 '전월 데이터 없음' 판정이 더 정확해짐
// lastMonthCount: 0,
// 충전 수단 비율(카드2에서 사용)
kakaoPercent: 58,
tossPercent: 42,
// 연간 월별 합계(1~12월)
monthlyTotals: [
1200000, 1800000, 2400000, 2100000, 2700000, 3100000,
2900000, 3300000, 2800000, 3600000, 4100000, 4500000
]
};
2. 전월 대비(MoM)는 계산보다 UX 정책이 핵심
전월 대비는 값이 양수일 때만 가정한 UI에서
음수가 발생하면 사용자에게 ‘오류’처럼 보이거나
어색한 경험을 줄 수 있다.
그래서 음수는 숨기지 않고 그대로 노출하되,
색상과 배지 스타일을 분리해서
감소도 정상적인 상태로 인식되게 처리했다.
정리한 규칙은 다음과 같다.
- 지난달이 0인 경우 퍼센트 계산이
의미 없으므로 ‘N/A’ 같은 표기로 안내 - 증가면 '+'를 붙여서 상승을 명확히 표현
- 감소면 음수 부호를 유지하고,
별도 색/배지로 정상 감소 상태임을 강조 - 0이면 중립 표기
2-1) 전월 대비 계산 함수
/**
* 전월대비(MoM) 계산
* - diff: 이번달 - 지난달 (증감액)
* - percent: 증감률(%) (계산 가능할 때만 숫자)
* - state: UI 표기를 위한 상태값(up/down/flat/na/nodata)
*
* 정책:
* 1) 전월 데이터 자체가 없으면 nodata
* 2) 전월이 0이면 percent는 계산하지 않고 na 처리(무한대 방지)
* 3) 그 외에는 일반 계산
*/
function calcMoM(thisMonthTotal, lastMonthTotal, lastMonthCount) {
const cur = Number(thisMonthTotal ?? 0);
// 전월 데이터 유무 판정(승인건수 기준이 있으면 그걸 우선)
const hasPrevData = (lastMonthCount != null)
? Number(lastMonthCount) > 0
: lastMonthTotal != null;
if (!hasPrevData) {
return { diff: null, percent: null, state: 'nodata' };
}
const prev = Number(lastMonthTotal ?? 0);
const diff = cur - prev;
// 전월이 0이면 퍼센트는 의미가 없거나 무한대가 되기 쉬움 → 안내용 표기(na)로 처리
if (prev === 0) {
if (cur === 0) return { diff: 0, percent: 0, state: 'flat' };
return { diff, percent: null, state: 'na' };
}
const percent = ((cur - prev) / prev) * 100;
if (percent > 0) return { diff, percent, state: 'up' };
if (percent < 0) return { diff, percent, state: 'down' };
return { diff, percent: 0, state: 'flat' };
}
2-2) 음수 UX를 분리하는 뷰 모델(문구 + 클래스)
/**
* 전월대비 배지 표기(문구 + 클래스) 결정
* - 숫자는 숨기지 않고 그대로 노출(감소면 '-' 유지)
* - 배지 클래스만 분리해서 '감소도 정상 상태'로 인지시키는 UX를 만든다.
*/
function buildMoMBadgeView(thisMonthTotal, lastMonthTotal, lastMonthCount) {
const mom = calcMoM(thisMonthTotal, lastMonthTotal, lastMonthCount);
// 전월 데이터 없음
if (mom.state === 'nodata') {
return { text: '전월 데이터 없음', cls: 'badge-pill badge-neutral' };
}
// 전월 0원 → 퍼센트 비교 불가(na)
if (mom.state === 'na') {
return { text: '전월 0원(비교 불가)', cls: 'badge-pill badge-neutral' };
}
// 증가/감소/동일
if (mom.state === 'up') {
return { text: `전월 대비 +${Math.round(mom.percent)}%`, cls: 'badge-pill badge-up' };
}
if (mom.state === 'down') {
// 감소는 오류가 아니라 정상 상태 → 색/배지 톤을 분리해 의도된 UX로 보이게 한다.
return { text: `전월 대비 ${Math.round(mom.percent)}%`, cls: 'badge-pill badge-down' };
}
return { text: '전월 대비 0%', cls: 'badge-pill badge-neutral' };
}
// buildMoMBadgeView 결과를 실제 DOM에 적용하는 예시
const momEl = document.getElementById('text-mom');
const view = buildMoMBadgeView(vm.thisMonthTotal, vm.lastMonthTotal, vm.lastMonthCount);
if (momEl && view) {
momEl.textContent = view.text;
// 기존 레이아웃 클래스는 유지하고(예: cash-summary__badge),
// 상태 클래스만 교체하는 방식이 안전하다.
momEl.classList.remove('badge-up', 'badge-down', 'badge-neutral');
momEl.classList.add(view.cls.split(' ').pop()); // 'badge-up' 같은 마지막 클래스만 적용
}
2-3) 양수/음수 UX를 표기하는 디자인 CSS
/* 전월 대비 배지: 증가/감소/중립을 분리
포인트: 감소(음수)를 '오류 톤'이 아니라 '정상 감소 톤'으로 설계해서 UX 오해를 줄인다. */
.admin-dashboard .badge-pill {
display: inline-flex;
align-items: center;
height: 32px;
padding: 0 16px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.admin-dashboard .badge-up {
color: #0a8f55;
background: rgba(0, 200, 120, 0.12);
}
.admin-dashboard .badge-down {
color: #d61f69;
background: rgba(255, 77, 140, 0.12);
}
.admin-dashboard .badge-neutral {
color: #475569;
background: rgba(148, 163, 184, 0.18);
}
3. 숫자 포맷과 단위 통일로 신뢰감 만들기
관리자 화면에서 숫자는 신뢰가 생명이다.
KPI 텍스트는 원 단위로 보여주는데,
차트 축은 갑자기 천 단위/만 단위로 바뀌면
사용자가 다른 지표로 오해하기 쉽다.
그래서 금액 포맷(콤마/원 표기)을 통일하고,
차트 y축 단위도 100만 단위처럼 하나로 고정했다.
KPI 보조 문구나 툴팁도 같은 단위를 쓰도록 맞춰서,
숫자와 그래프가 같은 언어로 읽히게 했다.
KPI와 차트의 언어를 맞추기
/**
* 금액 포맷(원)
* - KPI/툴팁에서 통일해서 쓰면 숫자 신뢰도가 올라간다.
*/
function formatWon(amount) {
const n = Number(amount ?? 0);
return `₩ ${n.toLocaleString('ko-KR')}`;
}
/**
* y축 라벨(만 단위)
* - 월별 차트 라벨은 '만'으로 통일(화면 밀도/가독성 좋음)
*/
function toManLabel(amount) {
const n = Number(amount ?? 0);
return `${Math.round(n / 10000)}만`;
}
/**
* y축 스케일 계산(100만 단위 step 고정)
* - tick이 일정하면 연도 바꿔도 차트가 덜 흔들려 보인다.
*/
const Y_STEP = 1000000;
function calcYAxis(monthlyTotals) {
const vals = (monthlyTotals ?? []).map(v => Number(v) || 0);
const maxVal = Math.max(0, ...vals);
const max = Math.max(Y_STEP, Math.ceil(maxVal / Y_STEP) * Y_STEP);
const min = 0;
const tickAmount = Math.round((max - min) / Y_STEP);
return { min, max, tickAmount };
}
4. KPI 원형 그래프와 비교 차트 튜닝
이번 대시보드에서 ‘위젯’처럼 빠르게
상태를 읽게 해주는 요소는 상단 KPI 카드다.
숫자(이번 달 충전 금액)와 전월대비 배지로
핵심 정보를 먼저 보여주고 원형 그래프는 그 상태를
한 번 더 시각적으로 강조하는 보조 지표 역할을 한다.
원형 그래프는 정보량이 많기보다
‘한 눈에 읽히는 밀도’가 중요했다.
그래서 축, 라벨, 여백처럼
시선을 분산시키는 요소를 최대한 줄이고
카드 안에서 균형 있게 보이도록
크기와 위치를 조정했다.
특히 상단에 애매하게 남는 여백이 생기면
카드가 늘어진 느낌이 나기 때문에,
차트 높이/내부 여백/값 라벨 위치를
함께 손봐서 위젯처럼 단단하게 보이게 했다.
또 하나 신경 쓴 부분은 텍스트 영역과
그래프 영역의 기준선 정렬이다.
상단에서 KPI 텍스트(아이콘+타이틀)와
원형 그래프가 같은 높이 선상에 놓이도록 맞추면,
화면 전체가 ‘정돈된 관리자 화면’ 느낌을 준다.
이 부분은 단순히 예쁜 문제가 아니라,
사용자가 정보를 빠르게 스캔할 때
불필요한 시선 이동을 줄여준다.
4-1) 충전 증감 원형 그래프
/**
* 전월대비 라디얼(위젯) 옵션(핵심만)
* - sparkline: 축/여백 제거(위젯처럼 보이게)
* - grid padding/offsetY: 상단이 뜨는 느낌을 줄이는 미세 튜닝 포인트
* - value 라벨은 숨기고(카드 텍스트로 표시), 차트는 '상태(증가/감소)'만 시각화
*/
function buildMoMRadialOptions(ringValue, radialColor, tooltipHtml) {
return {
series: [ringValue], // 0~100 범위(보여주기용)
chart: {
type: 'radialBar',
height: 260,
width: 260,
sparkline: { enabled: true },
offsetY: -18 // 카드 안에서 차트가 아래로 처져 보이면 음수로 올려준다
},
grid: {
// 라디얼은 padding이 조금만 커도 위가 텅 비어 보이기 쉬움
padding: { top: -22, bottom: -22, left: -10, right: -10 }
},
colors: [radialColor], // up/down/neutral에 따라 색 분기
plotOptions: {
radialBar: {
hollow: { size: '40%' },
track: { background: '#e9eef5', strokeWidth: '100%', margin: 0 },
dataLabels: {
name: { show: false },
value: { show: false } // 값은 카드 배지/텍스트로 이미 보여주므로 중복 제거
}
}
},
tooltip: {
enabled: true,
custom: function () {
// tooltip은 '원 단위'로 상세 제공(가독성 + 신뢰도)
return tooltipHtml;
}
}
};
}
4-2) 비교 차트 : 단일 막대로 비율이 즉시 읽히게 구성
/**
* 충전 수단 비교(100% 스택 바) 옵션(핵심만)
* - '막대 1개'를 100%로 쪼개서 카카오/토스 비율을 직관적으로 보여준다.
* - 범례/수치는 카드 텍스트로 보여주고, 차트는 비율 감각만 담당하게 한다.
*/
function buildPayMethodBarOptions(kakaoPercent, tossPercent, tooltipFn) {
return {
series: [
{ name: '카카오페이', data: [kakaoPercent] },
{ name: '토스페이', data: [tossPercent] }
],
chart: {
type: 'bar',
height: 56,
stacked: true,
stackType: '100%',
sparkline: { enabled: true },
fontFamily: 'inherit'
},
plotOptions: {
bar: {
horizontal: true,
barHeight: '100%',
borderRadiusWhenStacked: 'all'
}
},
dataLabels: {
enabled: true,
formatter: (val) => `${Math.round(val)}%`
},
xaxis: { max: 100, labels: { show: false } },
yaxis: { labels: { show: false } },
legend: { show: false },
tooltip: {
custom: tooltipFn // '원 단위 충전금액 + 비율' 같이 상세를 넣으면 좋다
}
};
}
5. 연간 월별 그래프는 ‘최고/최저 태그’로 가시성 보완
연 기준 월별 충전금액 그래프는
추이를 보여주는 것만으로는 아쉬웠다.
그래서 운영자가 즉시 판단할 수 있도록
최고점과 최저점을 태그로 표시하는 기능을 추가했다.
이때 중요한 건 ‘어떻게 표시하느냐’였다.
SVG annotation 방식도 가능하지만,
카드 잘림(overflow) 문제나 레이어 관리,
반응형 위치 보정에서 번거로움이 생기기 쉬웠다.
그래서 태그는 HTML 오버레이 레이어로
올리는 방식으로 정리했다.
차트는 차트대로 그리고,
태그는 절대 위치로 얹어서 관리하니
유지보수가 훨씬 편해졌다.
최고/최저 인덱스 계산
/**
* 최고/최저 값 + 인덱스 계산
* - 월별 데이터가 숫자가 아닐 수도 있으니 안전하게 Number 변환
* - 최고/최저가 같은 달이면(전부 동일한 값) 최저 태그는 숨기는 게 자연스럽다.
*/
function computeMinMax(monthlyTotals) {
const vals = new Array(12).fill(0).map((_, i) => Number(monthlyTotals?.[i]) || 0);
const maxVal = Math.max(...vals);
const minVal = Math.min(...vals);
const maxIdx = vals.indexOf(maxVal);
const minIdx = vals.indexOf(minVal);
return { vals, maxVal, minVal, maxIdx, minIdx };
}
6. 잘림(클리핑) 방지: overflow는 ‘부모 체인’까지 확인
최고/최저 태그나 툴팁을 얹으면
꼭 한 번은 ‘잘림’ 문제가 터진다.
이 문제는 차트 요소만 손봐서는 해결이 안 되고,
카드 → 카드 바디 → 차트 랩 → 차트 캔버스/SVG까지
부모 체인에서 overflow가 걸려 있지 않은지 확인해야 한다.
그래서 필요한 범위만 선택적으로 overflow를 풀어서,
태그가 카드 밖으로 살짝 나와도 잘리지 않게 처리했다.
잘림 방지 CSS: 부모 체인을 전부 열어야 한다
/* 월별 차트: 오버레이 태그(최고/최저)가 차트 밖으로 나가도 안 잘리게 체인 전체를 오픈 */
.admin-dashboard .monthly-kpi-card,
.admin-dashboard .monthly-kpi-card.card,
.admin-dashboard .monthly-kpi-card .card-body,
.admin-dashboard .monthly-kpi-card #chart-monthly-area,
.admin-dashboard .monthly-kpi-card #chart-monthly-area .apexcharts-canvas,
.admin-dashboard .monthly-kpi-card #chart-monthly-area .apexcharts-svg {
overflow: visible !important;
}
/* HTML 오버레이 레이어(차트 위에 태그 띄우기) */
.admin-dashboard .monthly-kpi-card #chart-monthly-area {
position: relative;
}
.admin-dashboard .monthly-kpi-card .monthly-overlay {
position: absolute;
inset: 0;
pointer-events: none; /* 마우스 이벤트는 차트가 그대로 받게 */
z-index: 20;
overflow: visible;
}
.admin-dashboard .monthly-kpi-card .monthly-badge {
position: absolute;
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 8px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.14);
}
7. 경계 튐과 줌/리사이즈 이슈는 ‘숨김 → 재배치’로 방어
태그를 오버레이로 올리면 첫 달/마지막 달처럼
경계에 가까운 포인트에서 태그가 밖으로 튀어나가기 쉽다.
이 경우 좌표를 그대로 쓰기보다 컨테이너 폭 안에
들어오도록 좌표를 제한하는 보정이 필요했다.
또 하나의 실전 이슈는 줌/스크롤 타이밍에서
태그가 순간적으로 어긋나 ‘슝’ 하고
도망가는 듯 보이는 현상이었다.
이런 상황은 사용자에게
‘UI가 깨졌다’는 인상을 주기 쉽다.
그래서 리사이즈/줌 상황에서는 태그를
잠깐 숨겼다가 레이아웃이 안정화된 뒤
재배치해서 다시 보여주는 방식으로 UX를 방어했다.
7-1) 좌표를 '클램프'해서 끝점 튐을 방지
/**
* 값이 컨테이너 범위를 벗어나지 않도록 제한
* - 끝점(1월/12월)은 태그가 밖으로 튀기 쉬워서 반드시 방어가 필요하다.
*/
function clamp(v, min, max) {
return Math.max(min, Math.min(max, v));
}
/**
* 태그 배치(핵심 아이디어)
* - x, y는 '차트 내부 좌표'라고 가정
* - 태그가 컨테이너 밖으로 나가지 않도록 clamp로 보정
*/
function placeTag(tagEl, x, y, containerW, containerH) {
const safeX = clamp(x, 0, containerW);
const safeY = clamp(y, 0, containerH);
tagEl.style.left = `${safeX}px`;
tagEl.style.top = `${safeY}px`;
}
7-2) '숨김 → 재계산 → 표시' 흐름으로 방어
/**
* 오버레이 태그를 잠깐 숨겼다가 재배치 후 다시 보여주는 방식
* - 스크롤/줌/리사이즈에서 순간적으로 좌표가 튀는 현상을 완화한다.
* - opacity를 쓰면 display 토글보다 덜 거칠게 느껴진다.
*/
function safeRelayoutTags(overlayEl, relayoutFn) {
overlayEl.style.opacity = '0';
// 다음 프레임: 레이아웃 안정화 후 좌표 재계산
requestAnimationFrame(() => {
relayoutFn();
overlayEl.style.opacity = '1';
});
}
8. 연도 변경은 차트만 업데이트하고 태그만 재계산
연도 선택(2025/2026)을 바꾸는 UX는
페이지를 새로 그리는 방식보다,
차트 데이터만 교체하는 방식이 훨씬 깔끔했다.
그래서 연도 변경 시에는 series만 업데이트하고,
그 직후 최고/최저 태그 위치만
다시 계산해서 갱신하는 흐름으로 정리했다.
이 구조로 만들면 데이터가 늘어나거나
그래프 종류가 바뀌어도 ‘업데이트 루틴’은
그대로 재사용할 수 있다.
차트만 교체하는 업데이트 루틴
/**
* 연도 변경 시 업데이트 흐름(핵심)
* - 차트는 updateSeries/updateOptions로 '데이터만' 교체
* - 최고/최저 태그는 차트 렌더링 이후에 좌표를 다시 얻어야 하므로 재계산이 필수
*/
function updateYear(areaChart, overlayEl, newMonthlyTotals) {
// 1) 차트 데이터만 교체(페이지 전체 리로드 금지)
areaChart.updateSeries([{ name: 'monthly', data: newMonthlyTotals }], true);
// 2) 차트가 업데이트된 뒤에 태그 위치를 재계산해야 한다.
// (업데이트 직후에는 marker 좌표가 아직 안정화되지 않았을 수 있음)
safeRelayoutTags(overlayEl, () => {
// computeMinMax로 최고/최저 인덱스를 다시 구한다.
// 해당 인덱스의 '좌표 획득'은 구현 방식(Apex marker DOM 등)에 맞춰 연결한다.
// 그리고 placeTag로 태그를 재배치한다.
});
}
[ 마무리 ]
데이터 차트만큼 중요한 건
표현 규칙과 예외 케이스였다.
이번 캐시 대시보드는 차트 라이브러리 기능을
최대한 화려하게 쓰는 작업이 아니라,
운영 화면에서 흔히 발생하는 예외 케이스를
자연스럽게 보이게 만드는 작업에 가까웠다.
특히 전월 대비 음수는 오류가 아니라
정상적인 ‘감소’ 상태이기 때문에,
이를 색/배지로 분리해 의도된 UX로
보이게 만든 것이 가장 큰 포인트였다.
여기에 최고/최저 태그, 잘림 방지,
줌/리사이즈 대응까지 더해지면서
대시보드가 실제 운영에서도
흔들리지 않는 형태로 완성됐다.
'개주 훈련일지 > 🏋️ 전집중 호흡 훈련' 카테고리의 다른 글
| 형상관리 개선 기록: viewdevelop 브랜치를 만든 이유 (스프링 부트 기준) (2) | 2026.02.13 |
|---|---|
| AI 추천 서비스 준비: 챗봇 UI(위젯) 만들기 (0) | 2026.02.13 |
| Git) 팀 프로젝트 협업 루틴 만들기 (develop 브랜치 기반 체크리스트) (0) | 2026.02.10 |
| Postman 핵심 정리: API 테스트 기본부터 JWT 로그인 자동화, 디버깅까지 (0) | 2026.02.09 |
| 스프링부트 이미지 업로드 경로 설정: 상대경로 vs 절대경로, 장단점과 추천 구조 (0) | 2026.02.07 |