monetization
CHAPTER 48 / 69
읽기 약 2분
FUNCTION
매출 대시보드 만들기
핵심 개념
Recharts + Supabase로 MRR/Churn/LTV를 한눈에 보는 관리자 페이지.
본문
데이터 소스 통합
-- Supabase 결제 테이블 스키마
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
amount NUMERIC NOT NULL,
currency TEXT DEFAULT 'KRW',
source TEXT NOT NULL, -- 'stripe', 'toss', 'gumroad', 'inflearn'
status TEXT NOT NULL, -- 'paid', 'refunded', 'failed'
billing_period TEXT, -- 'monthly', 'yearly', 'one_time'
paid_at TIMESTAMPTZ NOT NULL,
metadata JSONB
);
CREATE INDEX idx_payments_paid_at ON payments(paid_at DESC);
CREATE INDEX idx_payments_user_id ON payments(user_id);
-- 월별 MRR 뷰
CREATE VIEW monthly_mrr AS
SELECT
DATE_TRUNC('month', paid_at) AS month,
SUM(CASE
WHEN billing_period = 'yearly' THEN amount / 12
ELSE amount
END) AS mrr,
COUNT(DISTINCT user_id) AS active_subscribers
FROM payments
WHERE status = 'paid'
GROUP BY 1
ORDER BY 1 DESC;API 라우트 — 데이터 수집
// app/api/admin/dashboard/route.ts
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';
export async function GET() {
const supabase = createClient();
// 1. 월별 MRR (최근 12개월)
const { data: mrr } = await supabase
.from('monthly_mrr')
.select('*')
.limit(12);
// 2. 수익원별 분포 (이번 달)
const { data: bySource } = await supabase
.from('payments')
.select('source, amount.sum()')
.gte('paid_at', new Date(new Date().setDate(1)).toISOString())
.eq('status', 'paid');
// 3. 해지율 (Churn)
const { data: churn } = await supabase.rpc('calculate_churn_rate', {
period: 'last_30_days',
});
// 4. LTV (평균 사용자 평생 가치)
const { data: ltv } = await supabase.rpc('calculate_avg_ltv');
return NextResponse.json({ mrr, bySource, churn, ltv });
}React 대시보드 — Recharts
'use client';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { useEffect, useState } from 'react';
interface DashboardData {
mrr: { month: string; mrr: number; active_subscribers: number }[];
bySource: { source: string; sum: number }[];
churn: number;
ltv: number;
}
export default function RevenueDashboard() {
const [data, setData] = useState<DashboardData | null>(null);
useEffect(() => {
fetch('/api/admin/dashboard')
.then(r => r.json())
.then(setData);
}, []);
if (!data) return <div>Loading...</div>;
const colors = ['#6366f1', '#10b981', '#f59e0b', '#a78bfa', '#60a5fa'];
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: 24 }}>
{/* KPI 카드 */}
<div className="kpi-card">
<div className="kpi-label">월 MRR</div>
<div className="kpi-value">${data.mrr[0]?.mrr.toLocaleString()}</div>
<div className="kpi-change">전월 대비 +12%</div>
</div>
<div className="kpi-card">
<div className="kpi-label">활성 구독자</div>
<div className="kpi-value">{data.mrr[0]?.active_subscribers}</div>
</div>
<div className="kpi-card">
<div className="kpi-label">월 해지율</div>
<div className="kpi-value">{(data.churn * 100).toFixed(1)}%</div>
<div className={data.churn > 0.05 ? 'warning' : 'good'}>
{data.churn > 0.05 ? '⚠️ 위험' : '✅ 건강'}
</div>
</div>
<div className="kpi-card">
<div className="kpi-label">평균 LTV</div>
<div className="kpi-value">${data.ltv.toFixed(0)}</div>
</div>
{/* MRR 추이 */}
<div className="chart-card" style={{ gridColumn: 'span 2' }}>
<h3>MRR 추이 (12개월)</h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data.mrr.slice().reverse()}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="mrr" stroke="#6366f1" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
{/* 수익원 분포 */}
<div className="chart-card">
<h3>수익원 분포 (이번 달)</h3>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={data.bySource} dataKey="sum" nameKey="source" cx="50%" cy="50%" outerRadius={80} label>
{data.bySource.map((_, i) => (
<Cell key={i} fill={colors[i % colors.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
</div>
);
}관리자 전용 페이지 보호
// app/admin/layout.tsx
import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect('/login');
const { data: profile } = await supabase
.from('profiles')
.select('role')
.eq('id', user.id)
.single();
if (profile?.role !== 'admin') redirect('/');
return <div>{children}</div>;
}알림 자동화
// 매일 오전 9시 — 어제 매출 슬랙으로 보내기
async function dailyRevenueAlert() {
const yesterday = await fetchYesterdayRevenue();
await fetch(process.env.SLACK_WEBHOOK!, {
method: 'POST',
body: JSON.stringify({
text: `📊 어제 매출: $${yesterday.total}
` +
`신규 구독: ${yesterday.newSubs}
` +
`해지: ${yesterday.churn}
` +
`MRR: $${yesterday.mrr}`,
}),
});
}AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude
무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6
내 매출 대시보드 SQL 쿼리·React 컴포넌트를 성능·정확성·UX 측면에서 분석하고 프로덕션 수준으로 개선해줘.
ChatGPT
무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro
Recharts vs Chart.js vs Apache ECharts SaaS 대시보드 적합도를 비교한 실전 코드 예시를 보여줘.
Gemini
무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro
내 결제 테이블 스키마와 데이터를 분석해서 매출 통합·해지 분해·코호트 분석이 가능한 뷰/저장 함수 설계를 만들어줘.
Grok
무료: Grok 4.1 / SuperGrok $30/mo
2026년 1인 SaaS 대시보드 트렌드 — 자체 구축 vs ChartMogul/Baremetrics 같은 SaaS 어떤 게 가성비 좋은지 솔직히 알려줘.
⭐ 이것만 기억하세요
매출 대시보드 만들기는 이 3가지만 확실히 잡으세요
1.결제 데이터를 한 테이블(payments)에 source 컬럼으로 통합하면 모든 수익원을 한눈에 추적할 수 있다
2.KPI 카드 4개(MRR/구독자/해지율/LTV) + MRR 추이 차트만으로 SaaS 건강 상태를 매일 파악할 수 있다
3.다음 챕터에서 데이터를 활용한 A/B 테스트로 전환율을 올리는 방법을 다룬다
공유하기
진행도 48 / 69