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 경계 (어디까지 로딩하고, 어디까지 에러를 격리하는가)
폴더를 만들기 전에 물어보자. "이 레이아웃이 정말 여기서 분리되어야 하는가?"
답이 "예"일 때만 폴더를 만든다. 폴더 구조가 단순할수록 앱은 예측 가능해진다.