Use Cases (업데이트: 2026. 6. 1.)

Claude Code로 신뢰할 수 있는 SaaS 대시보드 만들기

KPI 정의, SQL 집계, Next.js UI, 접근성, 권한, 리뷰 루프까지 SaaS 대시보드 실전 가이드.

Claude Code로 신뢰할 수 있는 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 AccessibilityRecharts 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 다듬기는 더 빠르고 안전해집니다.

#Claude Code #SaaS 대시보드 #React #Next.js #데이터 시각화 #Recharts
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.