master-project
CHAPTER 16 / 50
읽기 약 2분
FUNCTION
대시보드: 메인 화면 + 통계 카드
핵심 개념
통계 카드 4개·차트·최근 활동 — Server Component로 데이터 페치 + Skeleton + 에러 처리.
본문
대시보드 페이지
// src/app/(app)/dashboard/page.tsx
import { Suspense } from 'react'
import { StatsCards } from './stats-cards'
import { UsageChart } from './usage-chart'
import { RecentActivity } from './recent-activity'
import { CardSkeleton } from '@/components/ui/skeleton'
export default function DashboardPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">대시보드</h1>
<Suspense fallback={<CardSkeleton count={4} />}>
<StatsCards />
</Suspense>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Suspense fallback={<CardSkeleton />}>
<UsageChart />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<RecentActivity />
</Suspense>
</div>
</div>
)
}통계 카드 (Server Component)
// src/app/(app)/dashboard/stats-cards.tsx
import { createClient } from '@/lib/supabase/server'
import { Sparkles, FileText, Clock, TrendingUp } from 'lucide-react'
export async function StatsCards() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { count: totalGen } = await supabase
.from('generations')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
const monthStart = new Date(Date.now() - 30 * 86400 * 1000).toISOString()
const { count: monthGen } = await supabase
.from('generations')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.gte('created_at', monthStart)
const { data: usage } = await supabase
.from('usages')
.select('count')
.eq('user_id', user.id)
.single()
const stats = [
{ label: '총 생성', value: totalGen ?? 0, icon: Sparkles, color: 'text-purple-500' },
{ label: '이번 달', value: monthGen ?? 0, icon: FileText, color: 'text-blue-500' },
{ label: '잔여 무료', value: 5 - (usage?.count ?? 0), icon: Clock, color: 'text-green-500' },
{ label: '플랜', value: 'Free', icon: TrendingUp, color: 'text-amber-500' },
]
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((s, i) => (
<div key={i} className="border rounded-lg p-4 bg-card">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">{s.label}</span>
<s.icon className={`h-4 w-4 ${s.color}`} />
</div>
<div className="text-2xl font-bold">{s.value}</div>
</div>
))}
</div>
)
}차트 (Recharts)
pnpm add recharts// src/app/(app)/dashboard/usage-chart.tsx
'use client'
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
export function UsageChart({ data }: { data: { date: string; count: number }[] }) {
return (
<div className="border rounded-lg p-6 bg-card">
<h3 className="font-semibold mb-4">최근 30일 사용량</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data}>
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Line type="monotone" dataKey="count" stroke="hsl(var(--primary))" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
)
}최근 활동
// src/app/(app)/dashboard/recent-activity.tsx
import { createClient } from '@/lib/supabase/server'
import { formatDistanceToNow } from 'date-fns'
import { ko } from 'date-fns/locale'
export async function RecentActivity() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const { data: gens } = await supabase
.from('generations')
.select('id, prompt, created_at')
.eq('user_id', user!.id)
.order('created_at', { ascending: false })
.limit(5)
return (
<div className="border rounded-lg p-6 bg-card">
<h3 className="font-semibold mb-4">최근 활동</h3>
<ul className="space-y-3">
{gens?.map(g => (
<li key={g.id} className="text-sm flex justify-between">
<span className="truncate flex-1">{g.prompt}</span>
<span className="text-xs text-muted-foreground ml-2">
{formatDistanceToNow(new Date(g.created_at), { locale: ko, addSuffix: true })}
</span>
</li>
))}
</ul>
</div>
)
}빈 상태 (Empty State)
{!gens || gens.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Sparkles className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>아직 생성한 콘텐츠가 없어요</p>
<Link href="/generate" className="text-primary hover:underline text-sm">
첫 카피 생성하기 →
</Link>
</div>
) : (
/* 목록 */
)}다음 챕터
CH.17 "CRUD UI: 목록/상세/생성/수정/삭제".
AI 프롬프트
🤖 AI에게 잘 물어보는 법 — 모델·전략별 프롬프트
Claude
무료: Sonnet 4.6 / Pro $20/mo: Opus 4.6
내 마스터 프로젝트의 대시보드 구현 부분을 분석해서 실전 적용 + 개선 우선순위 3가지를 알려줘.
ChatGPT
무료: GPT-5.5 / Plus $20/mo: GPT-5.5 Pro
대시보드 구현 관련 모범 사례·안티패턴 5개를 비교 분석해서 실전 적용를 위한 추천 방안을 알려줘.
Gemini
무료: 2.5 Flash / Pro $19.99/mo: 3.1 Pro
내 프로젝트 전체에서 대시보드 구현 최적화 가능 위치와 리스크를 보고해줘.
Grok
무료: Grok 4.1 / SuperGrok $30/mo
2026년 한국 1인 개발자 시장의 대시보드 구현 트렌드와 차별화 포인트를 정리해줘.
⭐ 이것만 기억하세요
대시보드: 메인 화면 + 통계 카드는 이 3가지만 확실히 잡으세요
1.Server Component + Suspense = SEO + 빠른 첫 화면
2.통계 카드 4개 + 차트 + 최근 활동 = MVP 대시보드
3.다음 챕터에서 CRUD UI
공유하기
진행도 16 / 50