Claude Code로 신뢰할 수 있는 SaaS 대시보드 만들기
KPI 정의, SQL 집계, Next.js UI, 접근성, 권한, 리뷰 루프까지 SaaS 대시보드 실전 가이드.
SaaS 대시보드는 예쁜 화면보다 신뢰가 먼저입니다
SaaS 대시보드는 차트를 나열하는 페이지가 아닙니다. 경영, 재무, 고객 성공, 영업, 제품 팀이 다음에 무엇을 고칠지 판단하는 작업 화면입니다. MRR의 통화가 빠지거나, 활성화율의 분모가 불명확하거나, SLA 집계의 시간대가 숨겨지면 의사결정이 흔들립니다.
Claude Code는 레이아웃을 빠르게 만들 수 있지만, 먼저 어떤 숫자가 신뢰 가능한지 알려줘야 합니다. 여기서 KPI는 핵심 성과 지표, API contract는 백엔드와 프런트엔드 사이의 데이터 약속, stale data는 의사결정에 쓰기에는 오래된 데이터라는 뜻입니다. 이 글은 KPI 정의, API contract, SQL 집계, React/Next.js UI, 차트 접근성, 필터, 로딩과 오류 상태, 역할 경계, 리뷰 루프를 순서대로 다룹니다.
flowchart LR
A["KPI 정의"] --> B["API contract"]
B --> C["SQL 집계"]
C --> D["Next.js API"]
D --> E["React UI"]
E --> F["권한과 리뷰"]
F --> A
KPI의 신뢰 조건부터 정합니다
Claude Code에 카드와 그래프부터 만들게 하지 말고, 단위, 기간, 시간대, 권한 범위, 데이터 신선도를 먼저 고정합니다.
| 항목 | 얇은 대시보드 | 신뢰할 수 있는 대시보드 |
|---|---|---|
| 단위 | 123,456만 표시 | JPY, USD, users, %를 명시 |
| 기간 | ”이번 달”만 표시 | 2026-05-01부터 2026-05-31까지 표시 |
| 시간대 | DB 기본값 사용 | API와 UI에 Asia/Tokyo 또는 UTC 표시 |
| 권한 | React에서 카드만 숨김 | API와 SQL에서 tenant와 role을 검증 |
| 신선도 | 오래된 데이터도 정상처럼 보임 | generatedAt과 stale 상태를 표시 |
| 설명 | 차트만 있음 | 공식, 비교 기간, 제외 조건을 함께 표시 |
실무 유스케이스는 최소 세 가지입니다. 경영진은 MRR, ARR, 이탈률, ARPA, 플랜 구성을 보고 통화와 월 배분 규칙을 확인합니다. 제품 팀은 활성화율, trial conversion, 기능 사용률을 보고 분모 정의를 검증합니다. 고객 성공 팀은 미처리 티켓, SLA 초과, 위험 계정, 담당자 큐를 보며 역할 경계를 지켜야 합니다. 재무 팀은 결제 완료, 환불, 연체, 플랜별 매출을 회계 데이터와 대조합니다.
복사해서 쓸 수 있는 API contract
/api/dashboard/summary가 반환할 형태를 먼저 고정합니다. 이 JSON은 mock API, fixture, 프런트엔드 테스트에 바로 사용할 수 있습니다.
{
"meta": {
"tenantId": "tenant_123",
"dateRange": {
"from": "2026-05-01",
"to": "2026-05-31"
},
"timezone": "Asia/Tokyo",
"generatedAt": "2026-06-01T09:00:00+09:00",
"staleAfterMinutes": 60,
"permissions": ["dashboard:read", "finance:read"]
},
"metrics": [
{
"id": "mrr",
"label": "MRR",
"unit": "JPY",
"current": 4820000,
"previous": 4510000,
"deltaPct": 6.87,
"formula": "paid monthly subscription revenue excluding tax and refunds"
},
{
"id": "activation_rate",
"label": "Activation rate",
"unit": "percent",
"current": 42.3,
"previous": 39.8,
"deltaPct": 2.5,
"formula": "activated accounts divided by new trial accounts"
}
],
"series": [
{ "date": "2026-05-01", "mrr": 4380000, "activationRate": 37.5 },
{ "date": "2026-05-08", "mrr": 4510000, "activationRate": 39.1 },
{ "date": "2026-05-15", "mrr": 4620000, "activationRate": 40.4 },
{ "date": "2026-05-22", "mrr": 4740000, "activationRate": 41.8 },
{ "date": "2026-05-31", "mrr": 4820000, "activationRate": 42.3 }
]
}
핵심은 meta입니다. 화면이 기간, 시간대, 권한, stale 여부를 추측하면 안 됩니다. API route와 client component의 경계는 공식 Next.js App Router 문서를 기준으로 잡고, React의 조건부 렌더링과 상태 관리는 React Learn에서 확인하면 됩니다.
SQL 집계는 경계 조건을 명시합니다
아래 PostgreSQL 예시는 invoices 테이블에 tenant_id, status, amount_cents, paid_at, currency가 있다고 가정합니다. 종료일은 포함하지 않는 배타 경계로 두어 월말과 시간대 버그를 줄입니다.
WITH params AS (
SELECT
'tenant_123'::text AS tenant_id,
'2026-05-01'::date AS from_date,
'2026-06-01'::date AS exclusive_to_date,
'Asia/Tokyo'::text AS report_timezone
),
paid_invoices AS (
SELECT
date_trunc('day', paid_at AT TIME ZONE params.report_timezone)::date AS paid_day,
amount_cents,
currency
FROM invoices
CROSS JOIN params
WHERE invoices.tenant_id = params.tenant_id
AND invoices.status = 'paid'
AND invoices.paid_at >= params.from_date AT TIME ZONE params.report_timezone
AND invoices.paid_at < params.exclusive_to_date AT TIME ZONE params.report_timezone
),
daily AS (
SELECT
paid_day,
currency,
SUM(amount_cents) / 100.0 AS revenue,
COUNT(*) AS paid_invoice_count
FROM paid_invoices
GROUP BY paid_day, currency
)
SELECT
paid_day,
currency,
revenue,
paid_invoice_count,
SUM(revenue) OVER (PARTITION BY currency ORDER BY paid_day) AS cumulative_revenue
FROM daily
ORDER BY paid_day, currency;
자주 터지는 함정은 23:59:59로 월말을 처리하는 것, 일별 비율을 다시 평균내는 것, 차트 쿼리에서 tenant 필터를 빠뜨리는 것, 환불 매출을 결제 매출에 섞는 것입니다. Claude Code 리뷰에서는 이것을 스타일 문제가 아니라 비즈니스 정확성 문제로 다뤄야 합니다.
TypeScript 타입으로 계약을 지킵니다
export type MetricUnit = "JPY" | "USD" | "users" | "percent" | "count";
export type DashboardMetric = {
id: "mrr" | "activation_rate" | "trial_conversion" | "support_sla";
label: string;
unit: MetricUnit;
current: number;
previous: number;
deltaPct: number;
formula: string;
};
export type DashboardPoint = {
date: string;
mrr: number;
activationRate: number;
};
export type DashboardPayload = {
meta: {
tenantId: string;
dateRange: {
from: string;
to: string;
};
timezone: string;
generatedAt: string;
staleAfterMinutes: number;
permissions: string[];
};
metrics: DashboardMetric[];
series: DashboardPoint[];
};
React UI는 모든 상태를 보여줘야 합니다
이 client component는 로딩, 403, API 오류, 빈 데이터, stale data, KPI 카드, 테이블 fallback이 있는 차트를 처리합니다. 접근성은 MDN Web Accessibility와 Recharts accessibility wiki를 기준으로 점검하세요.
"use client";
import { useEffect, useMemo, useState } from "react";
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type { DashboardPayload, DashboardMetric } from "./dashboard-types";
type LoadState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "empty" }
| { status: "ready"; data: DashboardPayload };
const money = new Intl.NumberFormat("ja-JP", {
style: "currency",
currency: "JPY",
maximumFractionDigits: 0
});
function formatMetric(metric: DashboardMetric) {
if (metric.unit === "JPY") return money.format(metric.current);
if (metric.unit === "percent") return `${metric.current.toFixed(1)}%`;
return metric.current.toLocaleString("ko-KR");
}
function isStale(data: DashboardPayload) {
const generated = new Date(data.meta.generatedAt).getTime();
const limit = data.meta.staleAfterMinutes * 60 * 1000;
return Date.now() - generated > limit;
}
export default function DashboardPage() {
const [state, setState] = useState<LoadState>({ status: "loading" });
useEffect(() => {
const controller = new AbortController();
async function loadDashboard() {
try {
setState({ status: "loading" });
const response = await fetch(
"/api/dashboard/summary?from=2026-05-01&to=2026-05-31&timezone=Asia/Tokyo",
{ signal: controller.signal }
);
if (response.status === 403) {
setState({ status: "error", message: "이 대시보드를 볼 권한이 없습니다." });
return;
}
if (!response.ok) {
throw new Error(`Dashboard API failed: ${response.status}`);
}
const data = (await response.json()) as DashboardPayload;
setState(data.metrics.length === 0 ? { status: "empty" } : { status: "ready", data });
} catch (error) {
if (!controller.signal.aborted) {
setState({ status: "error", message: error instanceof Error ? error.message : "Unknown dashboard error." });
}
}
}
loadDashboard();
return () => controller.abort();
}, []);
if (state.status === "loading") return <section aria-busy="true" className="p-6">대시보드를 불러오는 중입니다...</section>;
if (state.status === "empty") return <section className="p-6">이 필터에 표시할 KPI 데이터가 없습니다.</section>;
if (state.status === "error") {
return (
<section role="alert" className="p-6">
<h2 className="text-lg font-semibold">대시보드를 불러오지 못했습니다</h2>
<p>{state.message}</p>
<button className="mt-4 rounded bg-slate-900 px-4 py-2 text-white" onClick={() => location.reload()}>
다시 시도
</button>
</section>
);
}
return <DashboardContent data={state.data} />;
}
function DashboardContent({ data }: { data: DashboardPayload }) {
const stale = useMemo(() => isStale(data), [data]);
return (
<main className="space-y-6 p-6">
<header className="space-y-2">
<h1 className="text-2xl font-bold">SaaS KPI Dashboard</h1>
<p className="text-sm text-slate-600">
{data.meta.dateRange.from}부터 {data.meta.dateRange.to}까지, 시간대 {data.meta.timezone}.
생성 시각 {data.meta.generatedAt}.
</p>
{stale && (
<p role="status" className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
이 데이터는 오래되었습니다. 의사결정 전에 다시 집계하세요.
</p>
)}
</header>
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-4" aria-label="Key metrics">
{data.metrics.map((metric) => (
<article key={metric.id} className="rounded border bg-white p-4">
<p className="text-sm text-slate-500">{metric.label}</p>
<p className="mt-2 text-2xl font-bold">{formatMetric(metric)}</p>
<p className="text-sm text-slate-600">Previous period {metric.deltaPct >= 0 ? "+" : ""}{metric.deltaPct.toFixed(2)}%</p>
<p className="mt-2 text-xs text-slate-500">Formula: {metric.formula}</p>
</article>
))}
</section>
<section className="rounded border bg-white p-4">
<h2 className="text-lg font-semibold">MRR trend</h2>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={data.series} accessibilityLayer aria-label="MRR trend chart">
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis tickFormatter={(value) => money.format(Number(value))} />
<Tooltip formatter={(value) => money.format(Number(value))} />
<Line type="monotone" dataKey="mrr" stroke="#2563eb" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
<table className="sr-only">
<caption>MRR trend table</caption>
<thead><tr><th>Date</th><th>MRR</th><th>Activation rate</th></tr></thead>
<tbody>{data.series.map((point) => <tr key={point.date}><td>{point.date}</td><td>{point.mrr}</td><td>{point.activationRate}</td></tr>)}</tbody>
</table>
</section>
</main>
);
}
색만으로 의미를 전달하지 마세요. 상승과 하락은 라벨, 숫자, 비교 기간, 표로도 보여줘야 합니다. 필터도 마찬가지입니다. 플랜, 지역, 담당자, 기간을 UI에서 바꿀 수 있다면 URL, API, SQL이 같은 조건을 사용해야 합니다.
Claude Code 리뷰 프롬프트
공식 Claude Code common workflows처럼 조사, 수정, 테스트, 리뷰를 작게 반복하면 안정적입니다.
You are reviewing a SaaS KPI dashboard implementation.
Check these files:
- app/api/dashboard/summary/route.ts
- app/dashboard/page.tsx
- lib/dashboard-types.ts
- sql/dashboard-summary.sql
Review priorities:
1. KPI correctness: units, formulas, date range, timezone, and previous-period comparison.
2. Trust signals: generatedAt, staleAfterMinutes, empty state, loading state, error state.
3. Security: tenant isolation, role boundaries, finance-only metrics, API-side authorization.
4. Accessibility: chart labels, non-color-only meaning, keyboard navigation, table fallback.
5. Maintainability: duplicated formatter logic, unsafe casts, missing tests.
Return findings as P0, P1, or P2. Include file paths, exact code references, and a suggested fix.
Do not rewrite the whole dashboard unless a finding requires it.
관련 글로는 Claude Code 데이터 시각화와 RBAC 구현 가이드를 함께 보세요. 이 흐름을 팀 표준으로 만들고 싶다면 ClaudeCodeLab의 Claude Code 템플릿과 Claude Code 교육 및 컨설팅을 활용할 수 있습니다.
이 글의 흐름을 ClaudeCodeLab 샘플 SaaS 데이터에 적용해 보니, 가장 큰 차이는 화면부터 만들지 않은 데서 나왔습니다. contract에 시간대, 생성 시각, stale 상태, 권한을 넣자 Claude Code의 리뷰가 훨씬 구체적이었습니다. 신뢰 조건을 먼저 고정하면 UI 다듬기는 더 빠르고 안전해집니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.