Next.js App Router의 파일 기반 라우팅, 레이아웃 계층, 템플릿, 라우트 그룹, 에러/로딩 경계의 동작 원리와 실전 패턴을 다룹니다.
1장에서 Next.js 15~16의 전체 그림을 살펴보았습니다. 이번 장에서는 App Router의 핵심 구성 요소인 파일 컨벤션, 레이아웃 시스템, 템플릿, 라우트 그룹, 에러와 로딩 경계를 깊이 있게 다룹니다.
App Router는 app/ 디렉토리의 폴더 구조가 곧 URL 경로가 되는 파일 기반 라우팅을 사용합니다.
app/
page.tsx → /
about/
page.tsx → /about
blog/
page.tsx → /blog
[slug]/
page.tsx → /blog/hello-world, /blog/nextjs-guide
shop/
[...slug]/
page.tsx → /shop/a, /shop/a/b, /shop/a/b/c
각 라우트 세그먼트에서 사용할 수 있는 특수 파일들이 있습니다.
| 파일 | 역할 |
|---|---|
page.tsx | 해당 라우트의 UI (이 파일이 있어야 라우트가 공개적으로 접근 가능) |
layout.tsx | 자식 라우트와 공유되는 UI (상태 유지) |
template.tsx | layout과 유사하지만 매 탐색마다 새 인스턴스 생성 |
loading.tsx | Suspense 기반 로딩 UI |
error.tsx | Error Boundary 기반 에러 UI |
not-found.tsx | 404 UI |
route.ts | API 엔드포인트 (Route Handler) |
default.tsx | 병렬 라우트의 기본 fallback |
이 특수 파일들은 정해진 순서로 중첩됩니다.
layout.tsx가 가장 바깥을 감싸고, page.tsx가 가장 안쪽에 위치합니다.
레이아웃은 여러 라우트 간에 공유되는 UI입니다. 탐색(navigation) 시 레이아웃은 상태를 유지하고 다시 렌더링되지 않습니다.
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<header>
<nav>
<a href="/">홈</a>
<a href="/blog">블로그</a>
<a href="/about">소개</a>
</nav>
</header>
<main>{children}</main>
<footer>Footer</footer>
</body>
</html>
);
}루트 레이아웃은 필수이며, <html>과 <body> 태그를 정의해야 합니다.
폴더 구조에 따라 레이아웃이 자동으로 중첩됩니다.
app/
layout.tsx ← 루트 레이아웃 (모든 페이지에 적용)
blog/
layout.tsx ← 블로그 레이아웃 (blog/* 페이지에 적용)
page.tsx
[slug]/
page.tsx
export default function BlogLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="mx-auto max-w-3xl">
<aside className="mb-8">
<h2>카테고리</h2>
<ul>
<li><a href="/blog?category=tech">기술</a></li>
<li><a href="/blog?category=life">일상</a></li>
</ul>
</aside>
{children}
</div>
);
}/blog와 /blog/hello-world를 탐색할 때 BlogLayout은 다시 렌더링되지 않습니다. 사이드바의 상태가 유지되고, children만 교체됩니다.
레이아웃에서는 searchParams에 접근할 수 없습니다. searchParams는 page.tsx에서만 사용 가능합니다.
// app/blog/layout.tsx
// 이 패턴은 동작하지 않습니다
export default function Layout({
searchParams, // ← 레이아웃에서는 사용 불가
}: {
searchParams: Promise<{ category?: string }>;
}) {
// ...
}이 제약은 의도적인 설계입니다. searchParams가 변경될 때마다 레이아웃이 다시 렌더링되는 것을 방지하여 성능을 보장합니다.
템플릿은 레이아웃과 동일한 위치에서 사용되지만, 매 탐색마다 새 인스턴스가 생성됩니다.
// layout.tsx: 상태가 유지됨
// /blog → /blog/hello 탐색 시 Layout은 다시 렌더링되지 않음
export default function Layout({ children }) {
return <div className="blog-layout">{children}</div>;
}
// template.tsx: 매 탐색마다 새 인스턴스
// /blog → /blog/hello 탐색 시 Template이 새로 마운트됨
export default function Template({ children }) {
return <div className="blog-template">{children}</div>;
}useEffect: 각 페이지 방문마다 실행되어야 하는 로직'use client';
import { useEffect, useState } from 'react';
export default function Template({ children }: { children: React.ReactNode }) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// 매 페이지 전환마다 실행
setIsVisible(true);
return () => setIsVisible(false);
}, []);
return (
<div
className={`transition-opacity duration-300 ${
isVisible ? 'opacity-100' : 'opacity-0'
}`}
>
{children}
</div>
);
}대부분의 경우 레이아웃이 적합합니다. 템플릿은 "매 탐색마다 새로운 인스턴스가 반드시 필요한" 특수한 상황에서만 사용합니다.
라우트 그룹은 폴더 이름을 괄호로 감싸서((folderName)) URL 경로에 영향을 주지 않고 라우트를 조직할 수 있게 합니다.
app/
(marketing)/
page.tsx → /
about/
page.tsx → /about
pricing/
page.tsx → /pricing
(shop)/
products/
page.tsx → /products
cart/
page.tsx → /cart
(marketing)과 (shop)은 URL에 나타나지 않습니다.
라우트 그룹별로 다른 루트 레이아웃을 적용할 수 있습니다.
app/
(marketing)/
layout.tsx ← 마케팅 전용 레이아웃
page.tsx
about/page.tsx
(app)/
layout.tsx ← 앱 전용 레이아웃
dashboard/page.tsx
settings/page.tsx
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<nav className="marketing-nav">
<a href="/">홈</a>
<a href="/pricing">가격</a>
</nav>
{children}
</body>
</html>
);
}export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<div className="flex">
<aside className="w-64">
<nav>
<a href="/dashboard">대시보드</a>
<a href="/settings">설정</a>
</nav>
</aside>
<main className="flex-1">{children}</main>
</div>
</body>
</html>
);
}서로 다른 루트 레이아웃 간의 탐색은 전체 페이지 새로고침(full page reload)을 발생시킵니다. 예를 들어, (marketing)의 /에서 (app)의 /dashboard로 이동하면 클라이언트 사이드 탐색이 아닌 전체 페이지 로드가 발생합니다.
loading.tsx는 React Suspense 위에 구축된 로딩 UI입니다. 같은 디렉토리의 page.tsx를 자동으로 Suspense 경계로 감쌉니다.
app/
blog/
loading.tsx ← /blog 로딩 시 표시
page.tsx ← 실제 콘텐츠
export default function BlogLoading() {
return (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-6 w-3/4 rounded bg-gray-200" />
<div className="mt-2 h-4 w-full rounded bg-gray-200" />
<div className="mt-1 h-4 w-2/3 rounded bg-gray-200" />
</div>
))}
</div>
);
}이 코드는 내부적으로 다음과 동일합니다.
<Suspense fallback={<BlogLoading />}>
<BlogPage />
</Suspense>loading.tsx의 핵심 이점은 탐색 즉시 로딩 UI가 표시된다는 것입니다. 서버에서 데이터를 가져오는 동안 사용자는 skeleton UI를 봅니다.
스트리밍 SSR에서 loading.tsx는 정적 파일로 먼저 전송되고, 서버 렌더링이 완료되면 실제 콘텐츠로 교체됩니다.
loading.tsx는 해당 세그먼트의 page.tsx만 감싸므로, 레이아웃은 그대로 표시됩니다. 더 세분화된 로딩이 필요하면 page.tsx 내에서 직접 Suspense를 사용합니다.
import { Suspense } from 'react';
async function BlogPage() {
return (
<div>
<h1>블로그</h1>
{/* 최신 글은 빠르게 로드 */}
<Suspense fallback={<RecentPostsSkeleton />}>
<RecentPosts />
</Suspense>
{/* 인기 글은 별도로 스트리밍 */}
<Suspense fallback={<PopularPostsSkeleton />}>
<PopularPosts />
</Suspense>
</div>
);
}error.tsx는 React Error Boundary를 기반으로 합니다. 같은 세그먼트의 page.tsx에서 발생하는 에러를 잡습니다.
'use client'; // error.tsx는 반드시 Client Component
export default function BlogError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-6">
<h2 className="text-lg font-semibold text-red-800">
문제가 발생했습니다
</h2>
<p className="mt-2 text-sm text-red-600">{error.message}</p>
<button
onClick={reset}
className="mt-4 rounded bg-red-600 px-4 py-2 text-sm text-white
hover:bg-red-700"
>
다시 시도
</button>
</div>
);
}error.tsx는 같은 세그먼트의 layout.tsx에서 발생한 에러는 잡지 않습니다. 레이아웃의 에러를 잡으려면 상위 세그먼트에 error.tsx를 배치해야 합니다.
app/
error.tsx ← blog/layout.tsx의 에러를 잡음
blog/
layout.tsx ← 여기서 에러 발생 시
error.tsx ← 이 error.tsx는 잡지 못함 (같은 세그먼트)
page.tsx ← 이 page.tsx의 에러는 잡음
루트 레이아웃의 에러를 잡으려면 global-error.tsx를 사용합니다.
'use client';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html>
<body>
<h2>심각한 오류가 발생했습니다</h2>
<button onClick={reset}>다시 시도</button>
</body>
</html>
);
}global-error.tsx는 <html>과 <body> 태그를 포함해야 합니다. 루트 레이아웃을 대체하기 때문입니다.
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center">
<h1 className="text-6xl font-bold">404</h1>
<p className="mt-4 text-xl text-gray-600">
페이지를 찾을 수 없습니다
</p>
<Link
href="/"
className="mt-8 rounded bg-blue-600 px-6 py-3 text-white
hover:bg-blue-700"
>
홈으로 돌아가기
</Link>
</div>
);
}notFound() 함수를 호출하여 수동으로 404를 트리거할 수도 있습니다.
import { notFound } from 'next/navigation';
async function ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const article = await getArticle(slug);
if (!article) {
notFound(); // 가장 가까운 not-found.tsx를 렌더링
}
return <article>{article.content}</article>;
}app/
(marketing)/
layout.tsx # 마케팅 레이아웃 (네비게이션, 푸터)
page.tsx # 랜딩 페이지
pricing/page.tsx # 가격 페이지
blog/
layout.tsx # 블로그 사이드바
page.tsx # 글 목록
[slug]/page.tsx # 개별 글
(app)/
layout.tsx # 앱 레이아웃 (사이드바, 앱바)
dashboard/
page.tsx # 대시보드
loading.tsx # 대시보드 로딩
error.tsx # 대시보드 에러
settings/
page.tsx # 설정
layout.tsx # 설정 탭 레이아웃
not-found.tsx # 전역 404
global-error.tsx # 전역 에러
app/ 디렉토리의 폴더 구조로 라우팅을 정의하며, 특수 파일(page.tsx, layout.tsx, loading.tsx, error.tsx 등)이 각 세그먼트의 동작을 결정합니다.(folderName))은 URL에 영향 없이 라우트를 조직하며, 다중 루트 레이아웃을 가능하게 합니다.loading.tsx는 Suspense 기반으로 즉시 로딩 UI를 표시하고, error.tsx는 Error Boundary 기반으로 에러를 격리합니다.error.tsx는 같은 세그먼트의 layout.tsx 에러를 잡지 못하므로, 상위 세그먼트에 배치해야 합니다.다음 장에서는 동적 라우팅과 병렬 라우트를 다룹니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
Next.js의 동적 세그먼트, catch-all 라우트, 병렬 라우트(@slot)의 동작 원리와 대시보드, 조건부 렌더링 등 실전 패턴을 다룹니다.
Next.js 15부터 16까지의 진화를 조망합니다. App Router의 완성, 캐싱 전략 변화, Turbopack, Cache Components 등 핵심 변경사항을 개괄합니다.
Next.js의 인터셉팅 라우트로 모달, 사진 갤러리, 미리보기 패턴을 구현합니다. 병렬 라우트와의 조합, 딥 링킹, 공유 가능한 URL을 다룹니다.