Next.js App Router로 SaaS 대시보드를 사이드바 레이아웃으로 마이그레이션하기
- engineering
- nextjs
- app-router
- claude-code
TL;DR
- 문제: "아무것도 찾을 수 없는" 단일 페이지 레이아웃이 구조적 한계에 도달 — "Settings 추가할 공간이 없다"
- 해결: Supabase, Vercel, Linear가 사용하는 사이드바 + 멀티 페이지 구조로 마이그레이션 (5페이지, 약 2시간 작업)
- 기법: 새 페이지 = Next.js App Router에서 디렉토리 추가; sticky 다층 사이드바 (
top-14+h-[calc(100vh-3.5rem)]);usePathname으로 활성 상태 감지;<div> + aria-disabled를 사용한 Coming Soon 항목; LP와 대시보드 간 SSOT로서의 공유 i18n 딕셔너리- 트레이드오프: 모바일 사이드바 드로어 설계, 분산 상태 관리, 새 페이지의 높은 초기 구현 비용 (아래에서 논의)
왜 모든 SaaS가 사이드바 + 멀티 페이지 레이아웃을 사용하는가
Supabase, Vercel, Linear, Notion, Stripe Dashboard — 주요 SaaS 제품은 모두 좌측 사이드바 + 멀티 페이지 레이아웃을 사용합니다. 서로 경쟁하는 두 가지 요구를 해결하는 표준 솔루션입니다. 네비게이션 비용을 최소화하면서 미래 기능을 위한 구조적 공간을 확보하는 것이죠.
"모든 걸 여기 다 넣는" 단일 페이지 접근법은 일정 수준까지만 통합니다. 뚜렷한 기능이 세 개를 넘어가면 구조적 균열이 생기기 시작합니다. HeatMapX가 그 벽에 부딪혔습니다.
배경: 단일 페이지 레이아웃의 한계
오랫동안 HeatMapX 대시보드는 /dashboard 단일 페이지 안에 모든 것이 있었습니다.
- 공지 배너
- 플랜 사용량 카드 (PlanCard)
- 등록된 사이트 목록
- CLI 설치 가이드 (CliOnboarding)
- 시작하기 가이드
- 각종 모달 (OnboardingModal, UpgradeDialog, AddSiteDialog)
모든 것이 세로로 쌓였습니다. 처음에는 괜찮았지만 기능이 쌓이면서 두 가지 문제가 무시할 수 없게 됐습니다. "아무것도 어디 있는지 모르겠다"와 "Settings를 넣을 깔끔한 공간이 없다". 단일 페이지 구조가 구조적 막다른 골목이 된 것입니다.
해결책: Supabase, Vercel, Linear가 모두 사용하는 좌측 사이드바 + 멀티 페이지 레이아웃으로 마이그레이션.
최종 라우트 구조
| 라우트 | 콘텐츠 |
|---|---|
/dashboard |
홈 (개요: 통계 + 사용량 + 빠른 작업) |
/dashboard/sites |
히트맵 목록 (사이트 목록) |
/dashboard/cli |
CLI & Skills (CLI 설정 + Claude Code Skill 가이드) |
/dashboard/billing |
플랜 & 결제 (현재 사용량 + 전체 플랜 비교표) |
/dashboard/settings |
설정 (언어 선택 등) |
Next.js App Router가 이걸 쉽게 만드는 이유
App Router에서는 디렉토리 구조가 곧 URL 구조입니다. 새 페이지 추가는 폴더와 page.tsx 파일을 만드는 것이 전부입니다.
src/app/dashboard/
├── layout.tsx ← 모든 dashboard/*에서 공유 (사이드바 + 헤더)
├── page.tsx ← /dashboard (홈)
├── sites/
│ └── page.tsx ← /dashboard/sites
├── cli/
│ └── page.tsx ← /dashboard/cli
├── billing/
│ └── page.tsx ← /dashboard/billing
├── settings/
│ └── page.tsx ← /dashboard/settings
└── components/
├── Sidebar.tsx
├── PricingPlans.tsx
├── LanguagePicker.tsx
└── SkillOnboarding.tsx
5개의 새 페이지 = 5개의 새 파일 + 공유 layout.tsx 업데이트. 서버 사이드 렌더링 (Server Components), 인증 체크 (requireUser), i18n (useLocale) 모두 그냥 작동합니다.
Sticky 헤더 + Sticky 사이드바: 공존시키기
레이아웃 구조
// dashboard/layout.tsx
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-30 h-14 ...">
{/* 로고 + 테마 + 로케일 */}
</header>
<div className="flex flex-1">
<Sidebar />
<main className="flex-1">
<div className="mx-auto max-w-6xl px-8 py-8">
{children}
</div>
</main>
</div>
</div>
사이드바를 헤더 아래에 고정시키기
sticky top-0을 사용한 사이드바는 헤더 아래로 밀려들어갑니다. 해결책: 헤더 높이만큼 오프셋을 적용하면 됩니다.
// dashboard/components/Sidebar.tsx
<aside className="sticky top-14 hidden h-[calc(100vh-3.5rem)] w-60 shrink-0 flex-col border-r ... sm:flex">
<nav className="flex-1 overflow-y-auto">
{/* 메뉴 항목 */}
</nav>
<div className="border-t">
{/* 로그아웃 */}
</div>
</aside>
정확히 맞춰야 할 두 가지:
top-14= 3.5rem = 56px (헤더 높이와 일치)h-[calc(100vh-3.5rem)]은 뷰포트 높이에서 헤더를 빼줍니다 — 없으면 사이드바가 하단을 넘쳐흐름
flex-col + flex-1 overflow-y-auto + 하단의 border-t 패턴이 로그아웃 버튼을 사이드바 푸터에 고정시킵니다.
💡 Lesson 1: 여러 sticky 요소를 쌓을 때는
top값을 정확히 계산하세요. Tailwind의top-14(56px)와h-[calc(100vh-3.5rem)]은 쌍을 이룹니다. 나중에 헤더 높이를 바꾸면 두 값을 동시에 업데이트해야 합니다.
사이드바의 활성 상태 감지
usePathname을 사용해 현재 페이지에 해당하는 메뉴 항목을 하이라이트합니다.
'use client'
import { usePathname } from 'next/navigation'
const menuItems = [
{ href: '/dashboard', en: 'Home', ja: 'ホーム' },
{ href: '/dashboard/sites', en: 'Heatmaps', ja: 'ヒートマップ一覧' },
// ...
]
export function Sidebar() {
const pathname = usePathname() ?? '/dashboard'
return (
<aside>
{menuItems.map(item => {
// /dashboard는 완전 일치, 나머지는 전위 일치
const isActive = item.href === '/dashboard'
? pathname === '/dashboard'
: pathname.startsWith(item.href)
return (
<Link href={item.href} className={
isActive
? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50'
}>
...
</Link>
)
})}
</aside>
)
}
/dashboard는 엄격한 동등성(===)을 사용하고, 나머지는 startsWith를 사용합니다. 덕분에 /dashboard/sites/abc123 같은 상세 페이지에서도 사이드바에서 "Heatmaps"가 하이라이트 상태를 유지합니다.
Coming Soon 패턴: 아직 만들지 않은 기능 예고하기
A/B 테스팅과 Dynamic UI를 "coming soon" 상태로 — 보이지만 클릭할 수 없게 — 노출하고 싶었습니다. 해결책: <Link> 대신 aria-disabled가 붙은 <div>를 사용합니다.
const comingSoonItems = [
{ en: 'A/B Testing', ja: 'A/B テスト' },
{ en: 'Dynamic UI', ja: 'ダイナミック UI' },
]
<p className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Coming soon
</p>
{comingSoonItems.map(item => (
<div
aria-disabled="true"
className="flex cursor-not-allowed items-center gap-2.5 rounded-md px-3 py-2 text-sm text-slate-400"
>
<Icon className="text-slate-300" />
<Bi en={item.en} ja={item.ja} />
<span className="ml-auto rounded bg-slate-100 px-1.5 py-0.5 text-[10px] uppercase text-slate-500">
Soon
</span>
</div>
))}
기능이 출시되면 해당 항목을 comingSoonItems에서 menuItems로 이동하면 됩니다. 레이아웃과 스타일은 자동으로 재사용됩니다.
💡 Lesson 2: 아직 만들지 않은 기능을 예고하면 기대를 설정하고 기대감을 만들 수 있습니다. 사이드바 하단의 Coming Soon 항목 두 개가 "A/B 테스팅과 Dynamic UI가 곧 출시된다"는 신호를 전달합니다 — 유지할 추가 콘텐츠 없이. 로드맵 페이지보다 더 많은 사람에게 닿는 신호입니다. 앱을 열 때마다 보이니까요.
LP와 대시보드 간 가격 데이터 공유: SSOT로서의 i18n 딕셔너리
가격 플랜 정보는 두 곳에 나타납니다. /ko/pricing LP와 /dashboard/billing 대시보드. 두 곳에 데이터를 따로 정의하면 가격 변경 시 두 번 업데이트해야 하고 — 언젠가 반드시 하나를 놓치게 됩니다.
해결책: i18n 딕셔너리를 단일 진실 공급원(SSOT)으로 만드는 것입니다. LP와 대시보드 모두 동일한 t(locale).pricing.plans에서 읽어옵니다.
// LP: src/components/marketing/Pricing.tsx
const d = t(locale).pricing
{d.plans.map(plan => (
<article className="dark-theme-styles">...</article>
))}
// 대시보드: src/app/dashboard/components/PricingPlans.tsx
const d = t(dashLocale).pricing
{d.plans.map(plan => (
<article className="light-theme-styles">...</article>
))}
데이터 구조는 동일합니다. 스타일(다크 vs. 라이트)과 CTA 동작만 다릅니다(LP는 로그인 유도, 대시보드는 체크아웃으로 바로 이동). 가격 업데이트는 파일 하나만 수정하면 됩니다.
Server Components에서 Promise.all로 병렬 데이터 페칭
홈 페이지에서는 여러 집계 값을 가져와야 합니다.
- 유저의 현재 플랜
- 등록된 사이트 수
- 월간 페이지뷰
- 월간 AI 분석 사용량
- 오늘의 이벤트 수 (전체 사이트 합산)
Server Component에서 이를 순차적으로 await하면 느립니다. Promise.all로 병렬화하세요.
export default async function DashboardHome() {
const user = await requireUser()
const [plan, siteCount, monthlyPv, aiUsage] = await Promise.all([
getUserPlan(user.id),
getSiteCount(user.id),
getMonthlyPageViews(user.id),
getMonthlyUsage(user.id),
])
// ...
}
로드 시간이 약 1.5s에서 ~600ms로 단축됩니다. Next.js가 Server Component를 렌더링하기 전에 모든 await를 해결하므로, 병렬화를 염두에 두고 설계하면 즉각적인 효과가 납니다.
사용량 바의 색상 코딩으로 "위험" 신호 전달
사용 비율에 따라 프로그레스 바 색상이 동적으로 바뀝니다.
function UsageCard({ used, max }) {
const pct = Math.min(100, (used / max) * 100)
const isOver = pct >= 100
const isHigh = pct >= 80
return (
<div className="h-1.5 w-full rounded-full bg-slate-200">
<div
className={
isOver ? 'bg-red-500' :
isHigh ? 'bg-amber-500' :
'bg-orange-500'
}
style={{ width: `${pct}%` }}
/>
</div>
)
}
유저가 쿼터에 가까워지면 색상 변화가 업그레이드를 유도합니다. 평상시 색상으로 orange-500을 사용하면 HeatMapX의 브랜드 컬러도 항상 노출됩니다.
로그아웃을 헤더에서 사이드바 푸터로 이동
초기 구현에서는 로그아웃 링크가 헤더 우측 상단에 있었습니다. 사이드바가 생기면서 사이드바 하단 섹션으로 이동했습니다. 이유:
- 사이드바 푸터는 "중요하지만 자주 쓰지 않는" 작업의 표준 위치 — Slack, Discord, Notion 모두 이렇게 합니다
- 헤더는 전역 컨트롤(테마, 언어)과 브랜딩에 집중
- 실수로 로그아웃할 위험 감소 (우측 상단은 클릭 빈도가 높은 영역)
Claude Code Skill 온보딩
HeatMapX는 Claude Code 플러그인으로 공개되어 있어서, /dashboard/cli 페이지는 CLI와 Skill을 나란히 보여줘야 했습니다. 큰 화면에서는 2열 레이아웃:
// dashboard/cli/page.tsx
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<CliOnboarding /> {/* npm install -g heatmapx */}
<SkillOnboarding /> {/* /plugin install heatmapx-skill@... */}
</div>
SkillOnboarding은 영어와 일본어 양쪽으로 샘플 커맨드를 보여줍니다.
// 영어 예시
"Analyze /pricing with HeatMapX and give me CRO ideas."
// 일본어 예시
"HeatMapX で /pricing を分析して、改善案を提案して。"
이렇게 하면 CLI 우선 유저와 Claude Code Skill 유저 모두 동일한 페이지에서 자신의 온보딩 경로를 찾을 수 있습니다.
마이그레이션 단계
- Sidebar 컴포넌트 생성 (5개 nav 항목 + 2개 Coming Soon 항목, 인라인 SVG 아이콘)
- layout.tsx를 flex 레이아웃으로 리팩터 (헤더 + 사이드바 + 메인)
- 사이트 목록을 /dashboard/sites로 추출 (page.tsx 내용을 sites/page.tsx로 이동)
- /dashboard를 새 홈 화면으로 재작성 (통계 카드 + 사용량 바 + 빠른 작업)
- billing / cli / settings 페이지 추가 (3개의 새 page.tsx 파일)
- PricingPlans 컴포넌트 생성 (LP와 공유 데이터, 다른 스타일)
- SkillOnboarding 컴포넌트 생성 (Claude Code Skill 가이드)
- LanguagePicker 컴포넌트 생성 (설정 페이지용 라디오 스타일 선택기)
총계: 4개의 새 컴포넌트 + 5개의 새 page.tsx 파일 + layout.tsx와 루트 page.tsx 업데이트 = 파일 11개. 단일 Claude Code 세션에서 약 2시간 만에 완료.
트레이드오프: 사이드바 레이아웃의 단점
이 마이그레이션 글에 신뢰성을 부여하기 위해 솔직한 단점을 적습니다.
- 모바일 처리:
sm:브레이크포인트 이하에서는 사이드바가 숨겨지고 헤더에 햄버거 메뉴가 필요합니다. 이 글을 쓰는 시점에는 미완성 상태 — 모바일에서 사이드바가 그냥 사라지는데, 꽤 거친 중간 상태입니다. - 분산 상태 관리: 5개 페이지에 데이터가 분산되면서 "사이트 수"나 "현재 플랜" 같은 공유 값이 각 페이지에서 독립적으로 패칭됩니다. Server Components에서는 지연 비용이 작지만, 클라이언트 상태에 의존한다면 더 의도적인 접근이 필요합니다.
- 높은 초기 구현 비용: 예전에는 페이지 하나 = 파일 하나. 이제는 5개 페이지 + 공유 레이아웃 = 파일 11개. 기능이 적을 때는 명백히 과한 구조입니다.
- "홈 화면에 뭘 보여줄까" 문제: 단일 페이지 레이아웃은 이 문제를 완전히 피합니다 — 그냥 모든 걸 보여주면 되니까요. 개요 화면에 뭘 넣을지 결정하는 건 생각보다 미묘한 설계 판단입니다.
- 하위 호환 URL 관리:
/dashboard가 홈 화면으로 계속 작동하도록 설계해서 기존 북마크가 깨지지 않습니다. 하지만/dashboard/cli같은 하위 경로가 미래에 재구성된다면 301 리다이렉트를 제대로 설정해야 합니다.
우리의 판단: 뚜렷한 기능이 세 개를 넘어가면 이 비용은 지불할 가치가 있습니다. 반대로 말하면 동등하게 사실입니다 — 제품이 아직 초기이고 기능이 적다면, 단일 페이지 레이아웃이 아마 올바른 선택일 것입니다.
결론
Next.js App Router는 멀티 페이지 마이그레이션을 위한 강력한 구조를 제공합니다 — 새 페이지는 그냥 새 디렉토리입니다. Sticky 사이드바는 top-14 + h-[calc(100vh-3.5rem)]으로 sticky 헤더와 공존합니다. 활성 상태는 usePathname 체크입니다. Coming Soon 항목은 <div> + aria-disabled입니다. 그리고 단일 i18n 딕셔너리를 LP와 대시보드 간 SSOT로 공유하면 가격 업데이트 실수를 방지합니다.
이 패턴들을 조합하면 "단일 페이지 덤프"에서 "Supabase 스타일의 확장 가능한 다층 SaaS UI"로 새 페이지를 거의 장벽 없이 추가할 수 있게 됩니다. 그렇다고 해도 위의 트레이드오프는 실재합니다 — 즉 마이그레이션의 올바른 시점은 뚜렷한 기능이 세 개를 넘어설 때이지, 그 이전이 아닙니다.