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로 노출되지 않도록 하세요.

Next.jsApp RouterReactServer ComponentsRouting