Next.js 13에서 도입되어 14, 15에서 안정화된 App Router는 기존 Pages Router와는 근본적으로 다른 패러다임을 가져왔습니다. React Server Components를 기반으로 서버와 클라이언트의 경계를 명확히 나누고, 중첩 레이아웃과 스트리밍으로 사용자 경험을 한 단계 끌어올립니다. 이 글에서는 App Router의 핵심 개념부터 실전 활용법까지 Pages Router와 비교하며 살펴봅니다.

App Router vs Pages Router — 무엇이 다를까

Pages Router에서는 pages/ 디렉토리 아래 파일을 만들면 그게 곧 라우트였습니다. App Router는 app/ 디렉토리를 사용하며 훨씬 강력한 파일 컨벤션을 제공합니다.

특성 Pages Router App Router
기본 렌더링 클라이언트 컴포넌트 서버 컴포넌트
레이아웃 _app.js / _document.js layout.js (중첩 가능)
데이터 패칭 getServerSideProps / getStaticProps 컴포넌트 내 async/await
스트리밍 미지원 Suspense + loading.js

파일 컨벤션 이해하기

App Router는 특별한 파일명으로 역할을 부여합니다. 자주 쓰는 것들을 정리하면 다음과 같습니다.

app/
├── layout.js        # 공유 레이아웃 (필수)
├── page.js          # 라우트 UI
├── loading.js       # 로딩 UI (Suspense 자동)
├── error.js         # 에러 UI (Error Boundary 자동)
├── not-found.js     # 404 UI
├── route.js         # API 엔드포인트
└── blog/
    ├── layout.js    # blog/ 전용 레이아웃
    ├── page.js      # /blog 페이지
    └── [slug]/
        └── page.js  # /blog/:slug 페이지

React Server Components — 기본이 서버

App Router에서 모든 컴포넌트는 기본적으로 서버 컴포넌트입니다. 서버에서만 실행되므로 DB에 직접 접근하거나 API 키를 안전하게 사용할 수 있고, 번들 크기에도 영향을 미치지 않습니다.

// app/posts/page.tsx — 서버 컴포넌트 (기본값)
// 'use client' 없음 → 서버에서만 실행
async function PostsPage() {
  // DB 직접 접근 가능, API 키 노출 없음
  const posts = await db.post.findMany({
    orderBy: { createdAt: 'desc' },
    take: 10,
  });

  return (
    <main>
      <h1>블로그</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <a href={`/posts/${post.slug}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </main>
  );
}

export default PostsPage;
서버 컴포넌트 제약사항
서버 컴포넌트에서는 useState, useEffect, 이벤트 핸들러를 사용할 수 없습니다. 상호작용이 필요한 부분만 'use client' 지시어로 클라이언트 컴포넌트로 분리하세요.

'use client' — 클라이언트 컴포넌트

상태, 이벤트, 브라우저 API가 필요한 컴포넌트는 파일 최상단에 'use client'를 선언합니다. 이 컴포넌트와 그 자식들은 클라이언트 번들에 포함됩니다.

'use client';
// app/components/LikeButton.tsx
import { useState } from 'react';

export function LikeButton({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
  const [liked, setLiked] = useState(false);

  const handleLike = async () => {
    setLiked(true);
    setCount(prev => prev + 1);
    await fetch('/api/like', { method: 'POST' });
  };

  return (
    <button onClick={handleLike} aria-pressed={liked}>
      {liked ? '❤️' : '🤍'} {count}
    </button>
  );
}

중첩 레이아웃

App Router의 가장 강력한 기능 중 하나는 중첩 레이아웃입니다. 각 디렉토리에 layout.js를 두면 해당 경로와 하위 경로에 자동으로 적용됩니다. 페이지 이동 시 레이아웃은 리렌더링 없이 유지됩니다.

// app/layout.tsx — 루트 레이아웃
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <nav>사이트 전체 네비게이션</nav>
        {children}
        <footer>사이트 전체 푸터</footer>
      </body>
    </html>
  );
}

// app/dashboard/layout.tsx — 대시보드 전용 레이아웃
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="dashboard">
      <aside>사이드바 메뉴</aside>
      <main>{children}</main>
    </div>
  );
}

데이터 패칭 — async/await 컴포넌트

Pages Router의 getServerSideProps는 사라졌습니다. 서버 컴포넌트는 그냥 async 함수로 선언하고 내부에서 await로 데이터를 가져옵니다.

// app/posts/[slug]/page.tsx
interface Props {
  params: { slug: string };
}

async function PostPage({ params }: Props) {
  // 동시 데이터 패칭 — Promise.all로 병렬 처리
  const [post, comments] = await Promise.all([
    fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json()),
    fetch(`https://api.example.com/posts/${params.slug}/comments`).then(r => r.json()),
  ]);

  if (!post) notFound(); // not-found.js 렌더링

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <CommentList comments={comments} />
    </article>
  );
}

// 정적 경로 생성 (SSG)
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  return posts.map((p: { slug: string }) => ({ slug: p.slug }));
}

export default PostPage;

스트리밍과 Suspense

느린 데이터 패칭이 있어도 페이지 전체를 기다리지 않고, 준비된 부분부터 먼저 보여줄 수 있습니다. loading.js는 해당 경로의 로딩 상태를 자동으로 처리합니다.

// app/dashboard/loading.tsx — 자동 Suspense fallback
export default function DashboardLoading() {
  return (
    <div className="skeleton">
      <div className="skeleton-title" />
      <div className="skeleton-body" />
    </div>
  );
}

// 컴포넌트 단위 스트리밍
import { Suspense } from 'react';

async function Dashboard() {
  return (
    <div>
      <h1>대시보드</h1>
      {/* 빠른 통계는 즉시 렌더링 */}
      <QuickStats />
      {/* 느린 차트는 별도로 스트리밍 */}
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
    </div>
  );
}

Route Handlers — API 엔드포인트

Pages Router의 pages/api/는 App Router에서 route.js로 대체되었습니다. HTTP 메서드별로 named export를 사용합니다.

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const page = searchParams.get('page') ?? '1';

  const posts = await db.post.findMany({
    skip: (Number(page) - 1) * 10,
    take: 10,
  });

  return NextResponse.json({ posts });
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const post = await db.post.create({ data: body });
  return NextResponse.json(post, { status: 201 });
}

메타데이터 API

App Router는 SEO를 위한 메타데이터를 layout.jspage.js에서 직접 내보낼 수 있습니다. 동적 메타데이터도 generateMetadata 함수로 처리합니다.

// 정적 메타데이터
export const metadata = {
  title: 'My Blog',
  description: '개발 블로그',
  openGraph: {
    title: 'My Blog',
    description: '개발 블로그',
    images: ['/og-image.png'],
  },
};

// 동적 메타데이터
export async function generateMetadata({ params }: Props) {
  const post = await getPost(params.slug);
  return {
    title: `${post.title} — My Blog`,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      images: [post.coverImage],
    },
  };
}
언제 App Router를 써야 할까?
신규 프로젝트라면 App Router를 선택하세요. 서버 컴포넌트, 스트리밍, 중첩 레이아웃의 이점이 명확합니다. 기존 Pages Router 프로젝트는 점진적 마이그레이션이 가능합니다 — 두 라우터는 같은 Next.js 앱에서 공존할 수 있습니다.