layout.tsx 하나로 프로젝트 구조가 결정된다
Article
폴더를 만드는 건 파일 정리가 아니다
Next.js App Router에서 폴더 구조를 잡는 건 단순한 파일 정리가 아닙니다.
폴더 하나가 곧 라우트고, layout.tsx 하나가 곧 렌더링 경계입니다. 어디에 폴더를 만들고, 어디에 layout을 넣느냐에 따라 전체 앱의 동작 방식이 결정됩니다.
처음에 대충 잡으면 나중에 고치기가 매우 어렵습니다. 라우팅과 렌더링이 폴더 구조에 직접 연결되어 있기 때문입니다.
기본 구조: Nested Layout
App Router의 핵심 개념은 중첩 레이아웃입니다.
app/
├── layout.tsx ← 루트 레이아웃 (html, body)
├── page.tsx ← / 페이지
├── dashboard/
│ ├── layout.tsx ← 대시보드 레이아웃 (사이드바)
│ ├── page.tsx ← /dashboard
│ ├── analytics/
│ │ └── page.tsx ← /dashboard/analytics
│ └── settings/
│ └── page.tsx ← /dashboard/settings
// app/layout.tsx — 루트 레이아웃
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<Header />
{children}
<Footer />
</body>
</html>
);
}
// app/dashboard/layout.tsx — 대시보드 레이아웃
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar />
<main className="flex-1 p-6">{children}</main>
</div>
);
}
/dashboard/analytics에 접속하면:
RootLayout→ Header + FooterDashboardLayout→ SidebarAnalyticsPage→ 본문
이 세 개가 중첩되어 렌더링됩니다. /dashboard/analytics에서 /dashboard/settings로 이동하면? DashboardLayout은 유지되고 page만 교체됩니다. 사이드바가 깜빡이지 않습니다.
Route Group: URL에 영향 없이 레이아웃 분리
괄호를 쓰면 URL에 영향을 주지 않으면서 레이아웃을 분리할 수 있습니다.
app/
├── (marketing)/
│ ├── layout.tsx ← 마케팅 레이아웃 (풀 너비, 화려한 헤더)
│ ├── page.tsx ← / (홈)
│ ├── about/
│ │ └── page.tsx ← /about
│ └── pricing/
│ └── page.tsx ← /pricing
├── (dashboard)/
│ ├── layout.tsx ← 대시보드 레이아웃 (사이드바, 좁은 헤더)
│ ├── dashboard/
│ │ └── page.tsx ← /dashboard
│ └── settings/
│ └── page.tsx ← /settings
├── (auth)/
│ ├── layout.tsx ← 인증 레이아웃 (센터 정렬, 미니멀)
│ ├── login/
│ │ └── page.tsx ← /login
│ └── signup/
│ └── page.tsx ← /signup
핵심: (marketing), (dashboard), (auth)는 URL에 나타나지 않습니다.
/→(marketing)/layout.tsx+page.tsx/dashboard→(dashboard)/layout.tsx+dashboard/page.tsx/login→(auth)/layout.tsx+login/page.tsx
왜 이렇게 나누는가?
마케팅 페이지와 대시보드 페이지는 레이아웃이 완전히 다릅니다. 마케팅은 풀 너비에 화려한 네비게이션, 대시보드는 사이드바에 좁은 헤더. 이걸 하나의 layout에서 조건문으로 처리하면 금방 복잡해집니다.
// ❌ 이렇게 하지 마라
export default function RootLayout({ children }) {
const pathname = usePathname();
const isDashboard = pathname.startsWith('/dashboard');
return (
<div>
{isDashboard ? <DashboardHeader /> : <MarketingHeader />}
{isDashboard ? <Sidebar /> : null}
{children}
</div>
);
}
Route Group으로 나누면 각 레이아웃이 깔끔하게 분리됩니다.
인증 레이아웃 분리 패턴
실무에서 가장 흔한 패턴입니다.
app/
├── (public)/
│ ├── layout.tsx
│ ├── page.tsx ← /
│ ├── login/page.tsx ← /login
│ └── signup/page.tsx ← /signup
├── (protected)/
│ ├── layout.tsx ← 여기서 인증 체크
│ ├── dashboard/page.tsx ← /dashboard
│ └── profile/page.tsx ← /profile
// app/(protected)/layout.tsx
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';
export default async function ProtectedLayout({ children }: { children: React.ReactNode }) {
const session = await getSession();
if (!session) {
redirect('/login');
}
return (
<div className="flex">
<Sidebar user={session.user} />
<main className="flex-1">{children}</main>
</div>
);
}
장점:
- 인증 로직이 한 곳에 집중됩니다
- 각 페이지에서 개별적으로 인증 체크할 필요 없습니다
(public)라우트는 인증 없이 접근 가능하다는 게 구조만 봐도 명확합니다
loading.tsx와 error.tsx 배치 전략
app/
├── loading.tsx ← 전역 로딩 (거의 안 씀)
├── error.tsx ← 전역 에러 바운더리
├── (dashboard)/
│ ├── layout.tsx
│ ├── loading.tsx ← 대시보드 전체 로딩
│ ├── error.tsx ← 대시보드 전체 에러
│ ├── analytics/
│ │ ├── loading.tsx ← 분석 페이지 로딩
│ │ └── page.tsx
│ └── settings/
│ └── page.tsx ← settings는 별도 loading 없음 → 상위 loading.tsx 사용
배치 원칙:
-
loading.tsx는 사용자에게 의미 있는 단위로 배치합니다.
- 페이지마다 넣으면 스켈레톤 UI 커스터마이징이 가능합니다
- 상위에 넣으면 하위 모든 라우트가 같은 로딩 UI를 공유합니다
-
error.tsx는 복구 가능한 단위로 배치합니다.
- 전역 error.tsx: "문제가 발생했습니다" + 새로고침 버튼
- 섹션별 error.tsx: 해당 섹션만 에러 표시, 나머지는 정상 동작
// app/(dashboard)/error.tsx
'use client';
export default function DashboardError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="p-6 text-center">
<h2>대시보드 로딩에 실패했습니다</h2>
<p className="text-gray-500 mt-2">{error.message}</p>
<button
onClick={reset}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
>
다시 시도
</button>
</div>
);
}
실제 프로젝트 폴더 구조 예시
중간 규모 프로젝트에서 제가 실제로 쓰는 구조입니다.
app/
├── layout.tsx ← 루트: html, body, 폰트, 메타데이터
├── not-found.tsx ← 404 페이지
├── (marketing)/
│ ├── layout.tsx ← GNB + 풀 너비 + 푸터
│ ├── page.tsx ← 홈
│ ├── about/page.tsx
│ └── contact/page.tsx
├── (app)/
│ ├── layout.tsx ← 인증 체크 + 사이드바 + 좁은 헤더
│ ├── loading.tsx ← 앱 영역 공통 로딩
│ ├── error.tsx ← 앱 영역 공통 에러
│ ├── dashboard/
│ │ ├── page.tsx
│ │ └── loading.tsx ← 대시보드 전용 스켈레톤
│ ├── projects/
│ │ ├── page.tsx ← /projects (목록)
│ │ └── [id]/
│ │ ├── page.tsx ← /projects/123 (상세)
│ │ └── edit/page.tsx ← /projects/123/edit
│ └── settings/
│ ├── layout.tsx ← 설정 탭 네비게이션
│ ├── page.tsx ← /settings (프로필)
│ └── notifications/
│ └── page.tsx ← /settings/notifications
├── (auth)/
│ ├── layout.tsx ← 센터 정렬, 미니멀
│ ├── login/page.tsx
│ └── signup/page.tsx
├── api/
│ └── webhooks/
│ └── route.ts ← API 라우트 (외부 웹훅용)
의사결정 과정:
- 마케팅/앱/인증을 Route Group으로 분리 → 레이아웃이 완전히 다르기 때문
- settings에 별도 layout → 탭 네비게이션이 settings 하위에만 필요
- projects/[id] 동적 라우트 → 상세 페이지 패턴
- api는 webhooks만 → 대부분의 데이터 조회는 Server Component에서 직접, 변경은 Server Action으로
자주 하는 실수
1. Route Group 안에 같은 이름의 page.tsx
app/
├── (marketing)/
│ └── page.tsx ← / 경로
├── (dashboard)/
│ └── page.tsx ← / 경로 → ❌ 충돌!
같은 URL 경로에 두 개의 page가 있으면 빌드 에러가 납니다.
2. layout.tsx에 use client 넣기
앞서 Server vs Client Component 편에서 다뤘듯이, layout을 Client Component로 만들면 하위 모든 컴포넌트에 영향을 줄 수 있습니다. 상태가 필요한 부분만 별도 컴포넌트로 추출하세요.
3. 너무 깊은 중첩
app/admin/projects/[id]/tasks/[taskId]/comments/page.tsx
7단계 중첩은 과합니다. 이 정도면 Route Group이나 평탄화를 고려해야 합니다.
정리
Next.js App Router에서 폴더 구조는 아키텍처 그 자체입니다.
layout.tsx= 렌더링 경계 (무엇이 유지되고 무엇이 교체되는가)- Route Group
()= URL 없이 레이아웃 분리 loading.tsx/error.tsx= UX 경계 (어디까지 로딩하고, 어디까지 에러를 격리하는가)
폴더를 만들기 전에 물어보세요. "이 레이아웃이 정말 여기서 분리되어야 하는가?"
답이 "예"일 때만 폴더를 만드세요. 폴더 구조가 단순할수록 앱은 예측 가능해집니다.