--- id: wiki-2026-0508-saas-플랫폼-및-인터랙티브-대시보드-개발 title: SaaS 플랫폼 및 인터랙티브 대시보드 개발 category: 10_Wiki/Topics status: verified canonical_id: self aliases: [SaaS Dashboard Development, Interactive Dashboard SaaS] duplicate_of: none source_trust_level: A confidence_score: 0.85 verification_status: applied tags: [saas, dashboard, frontend, multi-tenant, b2b] raw_sources: [] last_reinforced: 2026-05-10 github_commit: pending tech_stack: language: TypeScript framework: Next.js 15 + React 19 --- # SaaS 플랫폼 및 인터랙티브 대시보드 개발 ## 매 한 줄 > **"매 SaaS dashboard는 multi-tenant + RBAC + real-time data viz 의 합"**. 2026 기준 Next.js 15 (App Router, RSC) + Postgres (RLS) + Drizzle/Prisma + TanStack Query/Table + Tremor/Recharts 가 standard stack. 매 핵심 challenge 는 tenant isolation, query 성능, real-time sync. ## 매 핵심 ### 매 Tenant 모델 - **Pool model**: 단일 DB, `tenant_id` column + Postgres RLS - **Silo model**: tenant 마다 별도 DB / schema - **Bridge model**: shared services + tenant-specific schema ### 매 Dashboard 구성요소 - KPI cards (metric + delta + sparkline) - Time-series charts (line, area) - Distribution (bar, pie, treemap) - Table (sortable, filterable, paginated) - Filters (date range, segment, dimension) ### 매 응용 1. B2B analytics (Mixpanel-like). 2. Operations dashboard (Datadog-like). 3. Admin console (Stripe-like). 4. Customer portal. ## 💻 패턴 ### Postgres RLS for tenant isolation ```sql ALTER TABLE invoices ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON invoices USING (tenant_id = current_setting('app.tenant_id')::uuid); -- 매 connection per request: -- SET app.tenant_id = '...'; ``` ### Drizzle multi-tenant query ```typescript import { drizzle } from "drizzle-orm/node-postgres"; import { eq } from "drizzle-orm"; import { invoices } from "./schema"; export async function getInvoices(db: typeof drizzle, tenantId: string) { await db.execute(`SET app.tenant_id = '${tenantId}'`); return db.select().from(invoices); } ``` ### Next.js 15 RSC dashboard page ```tsx // app/(dashboard)/metrics/page.tsx import { Suspense } from "react"; import { KpiCard } from "@/components/kpi-card"; import { RevenueChart } from "@/components/revenue-chart"; import { getMetrics } from "@/lib/data"; export default async function Page({ searchParams, }: { searchParams: Promise<{ range?: string }> }) { const { range = "30d" } = await searchParams; return (
}> }>
); } async function KpiPanel({ range }: { range: string }) { const m = await getMetrics(range); return ; } ``` ### TanStack Query with WebSocket invalidation ```typescript import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; export function useLiveMetrics(tenantId: string) { const qc = useQueryClient(); const q = useQuery({ queryKey: ["metrics", tenantId], queryFn: () => fetch(`/api/metrics`).then((r) => r.json()), staleTime: 30_000, }); useEffect(() => { const ws = new WebSocket(`/ws/${tenantId}`); ws.onmessage = (e) => { const ev = JSON.parse(e.data); if (ev.type === "metrics.updated") { qc.invalidateQueries({ queryKey: ["metrics", tenantId] }); } }; return () => ws.close(); }, [tenantId, qc]); return q; } ``` ### Tremor KPI card ```tsx import { Card, Metric, Text, Flex, BadgeDelta } from "@tremor/react"; export function KpiCard({ label, value, delta, }: { label: string; value: string; delta: number }) { return (
{label} {value}
= 0 ? "increase" : "decrease"}> {delta >= 0 ? "+" : ""}{delta}%
); } ``` ### TanStack Table virtualized ```tsx import { useReactTable, getCoreRowModel, flexRender } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useRef } from "react"; export function DataTable({ data, columns }: { data: T[]; columns: any }) { const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }); const ref = useRef(null); const v = useVirtualizer({ count: table.getRowModel().rows.length, getScrollElement: () => ref.current, estimateSize: () => 40, }); return (
{v.getVirtualItems().map((vi) => { const row = table.getRowModel().rows[vi.index]; return (
{row.getVisibleCells().map((c) => flexRender(c.column.columnDef.cell, c.getContext()) )}
); })}
); } ``` ### RBAC middleware ```typescript // lib/rbac.ts type Role = "owner" | "admin" | "member" | "viewer"; const PERMS: Record> = { owner: new Set(["*"]), admin: new Set(["billing.read", "users.write", "metrics.read"]), member: new Set(["metrics.read", "users.read"]), viewer: new Set(["metrics.read"]), }; export function can(role: Role, action: string): boolean { const p = PERMS[role]; return p.has("*") || p.has(action); } ``` ### Server Action with audit log ```typescript "use server"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; import { auditLog, users } from "@/lib/db/schema"; export async function inviteUser(email: string) { const session = await auth(); if (!session) throw new Error("unauth"); await db.insert(users).values({ email, tenantId: session.tenantId }); await db.insert(auditLog).values({ actor: session.userId, action: "user.invited", target: email, tenantId: session.tenantId, }); } ``` ## 매 결정 기준 | 상황 | Approach | |---|---| | <100 tenants | Pool + RLS | | Compliance heavy (HIPAA) | Silo (per-tenant DB) | | Heavy analytics | Pool + ClickHouse for aggregates | | Real-time updates | WebSocket + TanStack Query invalidation | | Charts | Tremor (out-of-box) / Recharts (custom) | **기본값**: Next.js 15 RSC + Postgres+RLS + Drizzle + TanStack Query + Tremor. ## 🔗 Graph - 변형: [[Embedded Analytics]] - Adjacent: [[RBAC]] · [[Tremor]] ## 🤖 LLM 활용 **언제**: B2B analytics product, ops dashboard, admin console scaffold. 매 multi-tenant 모델 결정. **언제 X**: Internal-only single-tenant tool (overkill). 매 marketing site. ## ❌ 안티패턴 - **No tenant isolation**: 매 RLS 또는 query-level scope 필수. - **Client-only filters**: 100k row 를 client 로 보내고 filter → 매 server-side filter+pagination. - **Polling instead of WS**: 5s interval polling → WebSocket / SSE. - **Charting heavyweight**: Plotly for simple line chart → 매 Tremor / Recharts. - **Forgotten audit log**: 매 sensitive action 은 immutable log. ## 🧪 검증 / 중복 - Verified (Vercel SaaS templates, Stripe / Linear engineering blogs, 2026). - 신뢰도 A. ## 🕓 Changelog | 날짜 | 변경 | |---|---| | 2026-05-08 | Phase 1 | | 2026-05-10 | Manual cleanup — SaaS dashboard development full content |