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

Claude Code로 데이터 시각화 수익 대시보드 만들기

Claude Code로 전환에 쓰이는 데이터 시각화를 구현합니다. Recharts, D3, CSV 집계, 접근성, Playwright 점검까지 다룹니다.

Claude Code로 데이터 시각화 수익 대시보드 만들기

차트보다 먼저 의사결정을 정한다

Claude Code에 “데이터 시각화를 만들어줘”라고만 요청하면 보기 좋은 데모는 빨리 나오지만, 실제 운영 판단에는 약한 경우가 많습니다. 먼저 정해야 할 것은 어떤 숫자로 누가 무엇을 결정할지입니다. ClaudeCodeLab에서는 페이지뷰보다 글 완독, CTA 클릭, 제품 페이지 이동, 교육 상담 신청, 무료 체크리스트에서 유료 템플릿으로 이어지는 흐름이 더 중요합니다.

이 글은 React, TypeScript, Recharts, 작은 D3 헬퍼, CSV 집계, 접근성 라벨, 반응형 상태, Playwright 스크린샷 확인까지 포함한 콘텐츠 분석 및 수익화 대시보드를 만듭니다. Recharts는 React에서 바로 쓰기 좋은 차트 컴포넌트이고, D3는 스케일과 데이터 변환을 세밀하게 다루는 도구 상자라고 보면 됩니다. 처음부터 모든 것을 D3로 만들기보다 Recharts로 화면을 만들고 필요한 계산만 D3로 보완하는 편이 안정적입니다.

데이터 계약은 화면과 데이터 소스가 지키는 약속입니다. 예를 들어 date는 ISO 날짜, sessions는 숫자, revenue는 USD 숫자, channel은 정해진 값만 허용한다고 정합니다. 집계는 원본 행을 채널별 매출이나 전환율처럼 읽기 쉬운 요약으로 만드는 작업입니다. 접근성은 색과 마우스에만 의존하지 않고 제목, 표, 대비, 키보드 조작으로도 의미가 전달되게 만드는 것입니다.

관련 글은 analytics implementation, dashboard development, accessibility를 함께 보세요. 공식 문서는 Claude Code docs, Recharts getting started, D3 getting started, Playwright screenshots, WCAG non-text contrast를 기준으로 삼습니다.

실무 유스케이스

유스케이스핵심 지표적합한 차트개선 행동
글 개선완독률, CTA 클릭, 검색 유입선 그래프, 막대 그래프도입부, 내부 링크, CTA 위치 수정
제품 판매제품 클릭, 매출, 채널 비중막대 그래프, 비중 차트제품 카드, 가격 문구, 비교표 개선
교육 및 상담문의 완료, 자료 다운로드, B2B 유입KPI 카드, 퍼널상담 페이지 진입 경로 명확화
광고 품질완독률, 스크롤, 광고 주변 이탈주석 있는 추세 차트광고 확대 전에 읽기 경험 보호

시간 흐름은 선 그래프, 카테고리 비교는 막대 그래프, 소수 카테고리의 구성비는 원형 또는 비중 차트, 즉시 봐야 하는 수치는 KPI 카드가 맞습니다. 매출과 전환율을 이중축으로 겹치면 관계가 있는 것처럼 보일 수 있어 초보자용 운영 화면에서는 피하는 것이 좋습니다.

flowchart LR
  Raw["CSV / analytics events"]
  Contract["data contract"]
  Aggregate["aggregation"]
  Chart["Recharts dashboard"]
  Review["accessibility and Playwright check"]
  CTA["training / products / consultation CTA"]

  Raw --> Contract --> Aggregate --> Chart --> Review --> CTA

Claude Code 프롬프트

Build a React + TypeScript content analytics dashboard for ClaudeCodeLab.
Use Recharts for the main charts and a small D3 helper only for scale calculations.
Input data must follow a documented data contract: date, channel, sessions, signups, revenue, readRate.
Include sample data, CSV parsing, channel aggregation, loading state, error state, empty state, accessible labels, a table fallback, and responsive layout.
Avoid misleading charts: no truncated bar axis, no dual-axis chart, no color-only meaning.
Add Playwright screenshot checks for mobile and desktop.
Return copy-pasteable code and explain the failure modes to review.

이 프롬프트는 단순한 시각화 데모가 아니라 데이터 형식, 실패 상태, 접근성, 검증 증거까지 함께 만들도록 압박합니다.

설치

npm i recharts d3
npm i -D @types/d3 @playwright/test

데이터 계약과 CSV 집계

차트보다 데이터 모양을 먼저 고정합니다. 그래야 Claude Code가 필드 이름을 추측하거나, 매출을 문자열로 처리하거나, NaN을 tooltip에 흘려보내는 일을 줄일 수 있습니다.

// dashboard-data.ts
export type Channel = "organic" | "email" | "social" | "referral" | "paid";

export type TrafficRow = {
  date: string;
  channel: Channel;
  sessions: number;
  signups: number;
  revenue: number;
  readRate: number;
};

export type ChannelSummary = {
  channel: Channel;
  sessions: number;
  signups: number;
  revenue: number;
  conversionRate: number;
  arps: number;
};

const channels: Channel[] = ["organic", "email", "social", "referral", "paid"];

export const sampleRows: TrafficRow[] = [
  { date: "2026-05-01", channel: "organic", sessions: 1280, signups: 42, revenue: 840, readRate: 0.61 },
  { date: "2026-05-01", channel: "email", sessions: 420, signups: 31, revenue: 1240, readRate: 0.74 },
  { date: "2026-05-01", channel: "social", sessions: 680, signups: 18, revenue: 260, readRate: 0.49 },
  { date: "2026-05-02", channel: "organic", sessions: 1360, signups: 48, revenue: 980, readRate: 0.64 },
  { date: "2026-05-02", channel: "referral", sessions: 310, signups: 17, revenue: 510, readRate: 0.58 },
  { date: "2026-05-02", channel: "paid", sessions: 540, signups: 22, revenue: 730, readRate: 0.52 },
];

function toNumber(value: string | undefined, fallback = 0) {
  const parsed = Number(value);
  return Number.isFinite(parsed) ? parsed : fallback;
}

function toChannel(value: string | undefined): Channel {
  return channels.includes(value as Channel) ? (value as Channel) : "referral";
}

export function parseAnalyticsCsv(csv: string): TrafficRow[] {
  const [headerLine, ...lines] = csv.trim().split(/\r?\n/);
  if (!headerLine) return [];

  const headers = headerLine.split(",").map((header) => header.trim());

  return lines
    .filter(Boolean)
    .map((line) => {
      const columns = line.split(",").map((column) => column.trim());
      const get = (name: string) => columns[headers.indexOf(name)];

      return {
        date: get("date") ?? "",
        channel: toChannel(get("channel")),
        sessions: toNumber(get("sessions")),
        signups: toNumber(get("signups")),
        revenue: toNumber(get("revenue")),
        readRate: toNumber(get("readRate")),
      };
    })
    .filter((row) => row.date && !Number.isNaN(Date.parse(row.date)));
}

export function aggregateByChannel(rows: TrafficRow[]): ChannelSummary[] {
  const map = new Map<Channel, ChannelSummary>();

  for (const row of rows) {
    const current =
      map.get(row.channel) ??
      { channel: row.channel, sessions: 0, signups: 0, revenue: 0, conversionRate: 0, arps: 0 };

    current.sessions += row.sessions;
    current.signups += row.signups;
    current.revenue += row.revenue;
    map.set(row.channel, current);
  }

  return [...map.values()]
    .map((row) => ({
      ...row,
      conversionRate: row.sessions === 0 ? 0 : row.signups / row.sessions,
      arps: row.sessions === 0 ? 0 : row.revenue / row.sessions,
    }))
    .sort((a, b) => b.revenue - a.revenue);
}

이 파서는 단순한 CSV 내보내기에 맞춘 것입니다. 따옴표가 있는 필드, 쉼표가 포함된 컬럼, 지역별 소수점, 시차가 섞이면 전용 파서와 fixture를 추가해야 합니다.

React 대시보드 구현

KPI 카드, 차트 전환, loading/error/empty 상태, 반응형 컨테이너, 표 fallback을 넣습니다. 표는 접근성뿐 아니라 수치 검토에도 필요합니다.

// ContentAnalyticsDashboard.tsx
"use client";

import { useMemo, useState } from "react";
import {
  Bar,
  BarChart,
  CartesianGrid,
  Cell,
  Legend,
  Pie,
  PieChart,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from "recharts";
import { aggregateByChannel, sampleRows, type Channel, type TrafficRow } from "./dashboard-data";

type DashboardProps = {
  rows?: TrafficRow[];
  isLoading?: boolean;
  error?: string | null;
};

type ViewMode = "revenue" | "conversion" | "mix";

const colors: Record<Channel, string> = {
  organic: "#2563eb",
  email: "#16a34a",
  social: "#f97316",
  referral: "#7c3aed",
  paid: "#dc2626",
};

const money = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

const percent = new Intl.NumberFormat("en-US", {
  style: "percent",
  maximumFractionDigits: 1,
});

export function ContentAnalyticsDashboard({
  rows = sampleRows,
  isLoading = false,
  error = null,
}: DashboardProps) {
  const [view, setView] = useState<ViewMode>("revenue");
  const summary = useMemo(() => aggregateByChannel(rows), [rows]);

  const totals = useMemo(
    () =>
      summary.reduce(
        (total, row) => ({
          sessions: total.sessions + row.sessions,
          signups: total.signups + row.signups,
          revenue: total.revenue + row.revenue,
        }),
        { sessions: 0, signups: 0, revenue: 0 },
      ),
    [summary],
  );

  if (isLoading) return <p role="status">Loading dashboard data...</p>;
  if (error) return <p role="alert">Dashboard data could not be loaded: {error}</p>;
  if (summary.length === 0) return <p role="status">No data matches this date range. Try widening the filters.</p>;

  const totalConversion = totals.sessions === 0 ? 0 : totals.signups / totals.sessions;

  return (
    <section aria-labelledby="content-analytics-title" style={{ display: "grid", gap: 24 }}>
      <header>
        <p style={{ margin: 0, color: "#64748b" }}>ClaudeCodeLab revenue dashboard</p>
        <h2 id="content-analytics-title" style={{ margin: "4px 0 0" }}>Content analytics dashboard</h2>
      </header>

      <div style={{ display: "grid", gap: 16, gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))" }}>
        <MetricCard label="Sessions" value={totals.sessions.toLocaleString("en-US")} />
        <MetricCard label="Signups" value={totals.signups.toLocaleString("en-US")} />
        <MetricCard label="Revenue" value={money.format(totals.revenue)} />
        <MetricCard label="Conversion" value={percent.format(totalConversion)} />
      </div>

      <div role="tablist" aria-label="Chart view" style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
        {[
          ["revenue", "Revenue by channel"],
          ["conversion", "Conversion rate"],
          ["mix", "Revenue mix"],
        ].map(([key, label]) => (
          <button
            key={key}
            type="button"
            role="tab"
            aria-selected={view === key}
            onClick={() => setView(key as ViewMode)}
            style={{
              border: "1px solid #cbd5e1",
              borderRadius: 8,
              padding: "8px 12px",
              background: view === key ? "#0f172a" : "#ffffff",
              color: view === key ? "#ffffff" : "#0f172a",
            }}
          >
            {label}
          </button>
        ))}
      </div>

      <figure aria-labelledby="dashboard-chart-title" style={{ margin: 0 }}>
        <h3 id="dashboard-chart-title" style={{ margin: "0 0 12px" }}>Dashboard chart</h3>
        <ResponsiveContainer width="100%" height={320}>
          {view === "revenue" ? (
            <BarChart data={summary} margin={{ top: 8, right: 24, bottom: 8, left: 8 }}>
              <CartesianGrid strokeDasharray="3 3" />
              <XAxis dataKey="channel" />
              <YAxis tickFormatter={(value) => money.format(Number(value))} domain={[0, "dataMax"]} />
              <Tooltip formatter={(value) => money.format(Number(value))} />
              <Bar dataKey="revenue" name="Revenue">
                {summary.map((row) => <Cell key={row.channel} fill={colors[row.channel]} />)}
              </Bar>
            </BarChart>
          ) : view === "conversion" ? (
            <BarChart data={summary} margin={{ top: 8, right: 24, bottom: 8, left: 8 }}>
              <CartesianGrid strokeDasharray="3 3" />
              <XAxis dataKey="channel" />
              <YAxis tickFormatter={(value) => percent.format(Number(value))} domain={[0, "dataMax"]} />
              <Tooltip formatter={(value) => percent.format(Number(value))} />
              <Bar dataKey="conversionRate" name="Conversion rate">
                {summary.map((row) => <Cell key={row.channel} fill={colors[row.channel]} />)}
              </Bar>
            </BarChart>
          ) : (
            <PieChart>
              <Pie data={summary} dataKey="revenue" nameKey="channel" outerRadius={110} label>
                {summary.map((row) => <Cell key={row.channel} fill={colors[row.channel]} />)}
              </Pie>
              <Tooltip formatter={(value) => money.format(Number(value))} />
              <Legend />
            </PieChart>
          )}
        </ResponsiveContainer>
        <figcaption style={{ color: "#64748b" }}>
          Bars start at zero, values are available in the table, and color is never the only label.
        </figcaption>
      </figure>

      <table aria-label="Channel summary" style={{ borderCollapse: "collapse", width: "100%" }}>
        <thead>
          <tr>
            {["Channel", "Sessions", "Signups", "Revenue", "CVR", "Revenue/session"].map((heading) => (
              <th key={heading} scope="col" style={{ borderBottom: "1px solid #cbd5e1", textAlign: "left", padding: 8 }}>{heading}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {summary.map((row) => (
            <tr key={row.channel}>
              <th scope="row" style={{ borderBottom: "1px solid #e2e8f0", textAlign: "left", padding: 8 }}>{row.channel}</th>
              <td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{row.sessions.toLocaleString("en-US")}</td>
              <td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{row.signups.toLocaleString("en-US")}</td>
              <td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{money.format(row.revenue)}</td>
              <td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{percent.format(row.conversionRate)}</td>
              <td style={{ borderBottom: "1px solid #e2e8f0", padding: 8 }}>{money.format(row.arps)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </section>
  );
}

function MetricCard({ label, value }: { label: string; value: string }) {
  return (
    <div style={{ border: "1px solid #cbd5e1", borderRadius: 8, padding: 16 }}>
      <p style={{ margin: 0, color: "#64748b" }}>{label}</p>
      <strong style={{ display: "block", fontSize: 24, marginTop: 4 }}>{value}</strong>
    </div>
  );
}

D3는 보조 도구로만 쓴다

// d3-scales.ts
import { extent, scaleLinear, scaleTime } from "d3";

export function buildRevenueScales(
  rows: Array<{ date: string; revenue: number }>,
  width: number,
  height: number,
) {
  const dates = rows.map((row) => new Date(row.date)).filter((date) => !Number.isNaN(date.valueOf()));
  const revenues = rows.map((row) => row.revenue).filter((value) => Number.isFinite(value));
  const dateExtent = extent(dates);
  const revenueExtent = extent(revenues);

  const minDate = dateExtent[0] ?? new Date("2026-01-01");
  const maxDate = dateExtent[1] ?? minDate;
  const maxRevenue = Math.max(revenueExtent[1] ?? 0, 1);

  return {
    x: scaleTime().domain([minDate, maxDate]).range([0, width]),
    y: scaleLinear().domain([0, maxRevenue]).nice().range([height, 0]),
  };
}

반드시 확인할 실패 모드

첫째, 막대 그래프의 Y축을 0에서 시작하지 않는 실수입니다. 작은 매출 차이가 과장되어 CTA나 광고 판단을 망칠 수 있습니다. 둘째, CSV의 빈 값과 잘못된 날짜를 정리하지 않는 문제입니다. 셋째, 색만으로 의미를 전달하는 문제입니다. 넷째, loading/error/empty 상태가 없는 문제입니다. 다섯째, 모바일 폭에서 라벨과 표가 깨지는 문제입니다.

Playwright 스크린샷 확인

// tests/content-analytics-dashboard.spec.ts
import { expect, test } from "@playwright/test";

test.describe("content analytics dashboard", () => {
  for (const viewport of [
    { width: 390, height: 844 },
    { width: 1280, height: 900 },
  ]) {
    test(`renders at ${viewport.width}px`, async ({ page }) => {
      await page.setViewportSize(viewport);
      await page.goto("/analytics-demo");

      await expect(page.getByRole("heading", { name: /Content analytics dashboard/i })).toBeVisible();
      await expect(page.getByRole("figure", { name: /Dashboard chart/i })).toBeVisible();
      await expect(page.getByRole("table", { name: /Channel summary/i })).toBeVisible();
      await expect(page).toHaveScreenshot(`content-analytics-${viewport.width}.png`, {
        fullPage: true,
        animations: "disabled",
      });
    });
  }
});
npx playwright test tests/content-analytics-dashboard.spec.ts

스크린샷은 계산 정확성을 증명하지 않습니다. 집계 함수는 별도 단위 테스트가 필요합니다. 하지만 빈 차트, 겹친 라벨, 가로 넘침, 빠진 표는 빠르게 잡아낼 수 있습니다.

수익 경로와 연결하기

데이터 시각화는 장식이 아닙니다. 독자가 글을 읽고도 제품 목록으로 이동하지 않는다면 CTA가 약할 수 있습니다. 상담 글에 트래픽은 있지만 신청이 없다면 교육 및 상담 페이지의 제안이 흐릴 수 있습니다. 초보자가 여러 글을 읽는다면 무료 체크리스트가 자연스러운 다음 단계가 됩니다.

이 패턴을 적용해 보니, 차트보다 데이터 계약을 먼저 고정했을 때 Claude Code의 결과가 가장 좋아졌습니다. 빈 데이터, 오류 상태, 표 fallback, 모바일 스크린샷, 오해를 부르는 차트 금지를 처음부터 요구하면 공개 가능한 대시보드에 가까워집니다. 다만 실제 CSV의 따옴표, 시간대, 분석 도구별 필드는 반드시 실제 샘플로 다시 확인해야 합니다.

#Claude Code #데이터 시각화 #Recharts #D3 #대시보드 #React
무료

무료 PDF: Claude Code 치트시트

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

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

Masa

작성자 소개

Masa

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