Next.js의 App Router: Layout, Page 및 Route
1. 개요
App Router는 Next.js 13부터의 기본 라우팅 메커니즘이며, Pages Router(pages/)를 점진적으로 대체합니다. 이는 app/ 디렉토리의 파일 규칙을 기반으로 합니다: page.tsx는 route를 생성하고, layout.tsx는 공유 UI를 생성하며, route.ts는 API endpoint를 생성합니다. 이 글을 읽은 후에는 App Router에 따라 Next.js 프로젝트를 구성하는 방법, 중첩된 layout을 작성하는 방법, 동적 route와 route handler를 만드는 방법, 그리고 이러한 component들을 테스트하는 방법을 알게 될 것입니다.
2. 기본 폴더 구조
App Router는 폴더 = URL segment를 사용합니다. app/ 내의 각 폴더는 route 동작을 결정하는 특수 파일을 포함할 수 있습니다.
app/
├── layout.tsx # 루트 layout, 필수로 <html>, <body> 포함
├── page.tsx # "/" route
├── about/
│ └── page.tsx # "/about" route
├── products/
│ ├── layout.tsx # /products/* 전용 layout
│ ├── page.tsx # "/products" route
│ └── [id]/
│ └── page.tsx # "/products/123" route
└── api/
└── orders/
└── route.ts # API "/api/orders"
layout.tsx는 같은 수준 또는 더 깊은 모든page.tsx를 감싸줍니다.page.tsx는 URL에 대한 콘텐츠를 실제로 렌더링하는 지점입니다.route.ts는 UI를 렌더링하지 않으며, REST endpoint처럼Response를 반환합니다.
주의: 폴더 이름이 소문자로 시작하지 않아도 (예:
Products) 작동하지만, Next.js는 대소문자를 구분하는 파일 시스템에서의 혼동을 피하기 위해 kebab-case 또는 일관된 camelCase를 사용할 것을 권장합니다.
3. Page — route 정의
page.tsx 파일은 route에 대한 주요 UI가 되는 React component를 export합니다. 기본적으로 Server Component입니다.
// app/products/page.tsx
import { ProductService } from "@/services/product-service";
export default async function ProductsPage() {
const products = await ProductService.findAll();
return (
<main>
<h1>상품 목록</h1>
<ul>
{products.map((product) => (
<li key={product.id}>{product.name} - {product.price}₩</li>
))}
</ul>
</main>
);
}
async function ProductsPage는 이것이 Server Component이기 때문에 component 내에서 직접 데이터를await할 수 있으며,useEffect나getServerSideProps가 필요하지 않습니다.- Next.js는 서버 측에서 HTML을 렌더링하고 콘텐츠가 이미 준비된 상태로 브라우저에 전송합니다 — SEO와 첫 번째 페이지 로드 시간에 좋습니다.
- 이 route는 URL
/products에 해당합니다.
3.1. params를 사용한 동적 route
// app/products/[id]/page.tsx
import { ProductService } from "@/services/product-service";
import { notFound } from "next/navigation";
type ProductPageProps = {
params: { id: string };
};
export default async function ProductDetailPage({ params }: ProductPageProps) {
const product = await ProductService.findById(params.id);
if (!product) {
notFound();
}
return <h1>{product.name}</h1>;
}
- 폴더
[id]는 동적 segment를 생성하며, Next.js는 실제 값을params.id로 전달합니다. notFound()는 app을 중단시키지 않고 가장 가까운not-found.tsx파일을 렌더링합니다 — App Router에서 표준적인 404 처리 방법입니다.
4. Layout — 공유 UI
Layout은 children prop을 받으며, 같은 layout 내의 page들 사이를 이동할 때 state를 유지합니다 (예: sidebar가 다시 렌더링되지 않음).
// app/products/layout.tsx
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="products-layout">
<aside>상품 필터</aside>
<section>{children}</section>
</div>
);
}
- 여기서
children은app/products/page.tsx또는app/products/[id]/page.tsx의 콘텐츠입니다. /products에서/products/123으로 이동할 때,<aside>부분이 unmount/remount되지 않습니다 — 이것이 layout이 각 page에서 component를 직접 import하는 것보다 나은 주요 이점입니다.
4.1. Root layout은 필수입니다
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Nón Lá 상점",
description: "App Router 데모 상점",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<header>Nón Lá 상점</header>
{children}
</body>
</html>
);
}
- Root layout은 전체 애플리케이션을 감싸는 가장 상위 layout이기 때문에 반드시
<html>과<body>를 포함해야 합니다. export const metadata는 선언적 방식으로<title>,<meta>를 설정하는 방법이며, Next.js는 이를 자동으로<head>에 주입합니다 — DOM을 수동으로 조작할 필요가 없습니다.
주의:
app/layout.tsx를 만들지 않으면 Next.js는 빌드 중에 오류를 표시합니다. 왜냐하면 모든 route는 최소한 root layout이 필요하기 때문입니다.
5. Route Groups 및 Parallel Segments
때로는 code를 정리하기 위해 route를 그룹화하고 싶지만, 그 그룹이 URL에 나타나지 않기를 원합니다. 괄호 쌍 (name)을 사용한 route group을 사용하세요.
app/
├── (marketing)/
│ ├── layout.tsx # marketing 페이지 전용 layout
│ ├── page.tsx # "/" route
│ └── pricing/
│ └── page.tsx # "/pricing" route
└── (dashboard)/
├── layout.tsx # dashboard 전용 layout
└── settings/
└── page.tsx # "/settings" route
(marketing)과(dashboard)는 URL에 나타나지 않으며, 각 페이지 그룹에 다른 layout을 적용하는 것을 도와줍니다./pricing과/settings는app/아래에 있지만 다른 layout을 사용합니다.
6. Route Handlers — App Router에서 API 구축
route.ts 파일은 Pages Router의 pages/api/*.ts를 대체하며, HTTP method 이름으로 된 함수를 export합니다.
// app/api/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";
export async function GET() {
const orders = await OrderService.findAll();
return NextResponse.json(orders);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const order = await OrderService.create(body);
return NextResponse.json(order, { status: 201 });
}
GET과POST는 별도의 export이며, Next.js는 자동으로 HTTP method에 따라 request를 올바른 함수로 라우팅합니다.NextResponse.json(...)는Content-Type: application/json을 자동으로 설정하므로, 직접JSON.stringify할 필요가 없습니다.- 이 endpoint는
/api/orders에서 제공되며, UI와 완전히 독립적입니다 — React를 렌더링하지 않습니다.
6.1. 동적 route handler
// app/api/orders/[id]/route.ts
import { NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";
export async function GET(_: Request, { params }: { params: { id: string } }) {
const order = await OrderService.findById(params.id);
if (!order) {
return NextResponse.json({ message: "Order not found" }, { status: 404 });
}
return NextResponse.json(order);
}
- 동적 page처럼, 폴더
[id]는 URL에서 값을 가져올 수 있게 해주며,params.id를 통해 접근합니다. - 명시적으로
status: 404를 반환하여 client가 에러를 추측하게 하지 않습니다.
7. 특수 파일: loading, error, not-found
App Router는 React Suspense와 Error Boundary를 규칙 기반 파일을 통해 암묵적으로 사용하므로, 직접 boundary를 작성할 필요가 없습니다.
// app/products/loading.tsx
export default function ProductsLoading() {
return <p>상품 목록을 로딩 중입니다...</p>;
}
// app/products/error.tsx
"use client";
export default function ProductsError({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<p>오류: {error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}
loading.tsx는 같은 segment의page.tsx가 데이터를await하는 동안 자동으로 표시됩니다 —useState(isLoading)상태를 관리할 필요가 없습니다.error.tsx는 반드시 Client Component("use client")여야 합니다. 왜냐하면reset함수를 사용하기 때문입니다 — 이는 client 측의 상호작용 함수로 오류가 발생한 segment를 다시 렌더링합니다.- Next.js는 이 파일들을 기반으로 segment를
<Suspense>와 해당하는 error boundary로 자동 래핑합니다.
8. Metadata 및 중첩된 layout
각 segment는 고유한 metadata를 export할 수 있으며, Next.js는 root layout에서 page로 순서대로 merge합니다.
// app/products/[id]/page.tsx
import type { Metadata } from "next";
import { ProductService } from "@/services/product-service";
type Props = { params: { id: string } };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const product = await ProductService.findById(params.id);
return { title: product ? `${product.name} - Nón Lá 상점` : "찾을 수 없음" };
}
export default async function ProductDetailPage({ params }: Props) {
const product = await ProductService.findById(params.id);
return <h1>{product?.name}</h1>;
}
generateMetadata는 동적 함수로, 실제 상품 데이터를 기반으로<title>을 설정할 수 있습니다 — SEO 상세 페이지에 유용합니다.- page의 metadata는 parent layout의 같은 이름 필드를 override하고, 다른 필드는 그대로 유지됩니다.
9. page component 테스트
비동기 Server Component의 경우, component 함수를 직접 호출하고 반환된 JSX를 확인하거나 렌더링한 후 React Testing Library를 사용하는 것이 가장 간단합니다.
<!-- package.json (devDependencies 부분, 요약) -->
<dependencies>
<vitest>^2.0.0</vitest>
<testing-library-react>^16.0.0</testing-library-react>
</dependencies>
// services/product-service.test.ts
import { describe, it, expect, vi } from "vitest";
import { ProductService } from "@/services/product-service";
import ProductsPage from "@/app/products/page";
import { render, screen } from "@testing-library/react";
vi.mock("@/services/product-service", () => ({
ProductService: {
findAll: vi.fn(),
},
});
describe("ProductsPage", () => {
it("서비스에서 반환한 상품 목록을 표시합니다", async () => {
(ProductService.findAll as any).mockResolvedValue([
{ id: "1", name: "Nón lá A", price: 50000 },
]);
const jsx = await ProductsPage();
render(jsx);
expect(screen.getByText("Nón lá A - 50000₩")).toBeInTheDocument();
});
});
ProductsPage()는 순수 async 함수처럼 호출되는데, 기본적으로 JSX를 반환하는 함수이기 때문입니다 — Server Component를 위한 특수 테스트 framework이 필요하지 않습니다.vi.mock은ProductService.findAll을 가짜 데이터를 반환하는 stub으로 대체하므로, 테스트가 실제 database에 의존하지 않습니다.render(jsx)는 Testing Library에서 제공하며, JSX가 DOM에 올바른 콘텐츠로 렌더링되었는지 확인합니다.
10. 일반적인 오류 및 사용하면 안 되는 경우
useState/useEffect같은 hook을 사용할 때"use client"빠뜨리기: Server Component는 이러한 hook을 지원하지 않으며, Next.js는 명확한 빌드 오류를 표시합니다.- 민감한 데이터 fetching logic (API key)을 Client Component에 배치하기: code가 browser로 bundle되기 때문입니다. 항상 Server Component 또는 Route Handler에서 fetch를 우선합니다.
- Route group을 단순히 “정리하기” 위해 과도하게 사용하기: 정말로 필요한 다른 layout이 없다면, route group은 폴더 트리만 복잡하게 만듭니다.
- 같은 route에 대해
pages/와app/을 섞어 사용하기: 두 router는 같은 URL을 처리할 수 없으며, Next.js는app/을 우선시하고pages/의 route는 무시됩니다 — 점진적으로 migration할 때 혼동을 야기합니다.
주의: Pages Router에서 프로젝트가 잘 작동하고 있고 Server Components, Streaming, Server Actions가 필요하지 않다면, 지금 당장 App Router로 migration할 필요는 없습니다. 변경하기 전에 이점을 평가해보세요.
11. 결론
App Router는 Next.js 애플리케이션을 세 가지 주요 파일 종류로 구성합니다: route를 정의하는 page.tsx, 여러 route 사이에서 UI를 공유하는 layout.tsx, HTML을 렌더링하지 않는 route.ts입니다. loading.tsx, error.tsx, not-found.tsx와 같은 규칙 기반 파일과 결합하면, boundary를 직접 작성하지 않고도 부드러운 loading/error 경험을 만들 수 있습니다. 폴더 구조를 설계할 때, 실제로 공유해야 하는 layout을 기준으로 그룹화하고, 불필요한 route group을 피하며, Server/Client Component 경계를 항상 확인하여 민감한 logic이 browser로 노출되지 않도록 하세요.