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 앱에서 공존할 수 있습니다.

Next.js 캐싱 전략

App Router는 4가지 캐싱 레이어를 제공합니다. 적절히 제어해야 불필요한 캐시 문제를 피할 수 있습니다.

// 1. 빌드 시 정적 생성 (Static Generation)
async function BlogPage() {
  const posts = await fetch('https://api.example.com/posts', {
    cache: 'force-cache',  // 기본값, 빌드 시 캐시
  }).then(r => r.json());
  return <PostList posts={posts} />;
}

// 2. 시간 기반 재검증 (ISR)
const data = await fetch('/api/products', {
  next: { revalidate: 3600 }, // 1시간마다 재생성
});

// 3. 항상 최신 데이터 (SSR)
const data = await fetch('/api/cart', {
  cache: 'no-store', // 매 요청마다 새로 가져옴
});

정적 데이터(블로그 글 목록)는 force-cache, 시간별 변경 데이터(상품 재고)는 revalidate, 사용자별 데이터(장바구니, 알림)는 no-store를 사용하는 것이 기본 원칙입니다.

Middleware — 요청 인터셉트

미들웨어는 요청이 처리되기 전에 실행되어 인증, 리다이렉트, 헤더 추가 등을 처리합니다.

// middleware.ts (루트에 위치)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token');

  // 인증이 필요한 경로 보호
  if (request.nextUrl.pathname.startsWith('/dashboard')) {
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }

  // 헤더 추가
  const response = NextResponse.next();
  response.headers.set('X-Frame-Options', 'DENY');
  return response;
}

// 미들웨어 실행 경로 설정
export const config = {
  matcher: ['/dashboard/:path*', '/api/:path*'],
};

next/image — 이미지 자동 최적화

Next.js의 Image 컴포넌트는 WebP/AVIF 변환, 크기 최적화, 지연 로딩을 자동으로 처리합니다.

import Image from 'next/image';

// 로컬 이미지 — 크기 자동 감지
import heroImg from '/public/hero.jpg';
<Image src={heroImg} alt="히어로 이미지" priority />

// 외부 이미지 — 크기 명시 필요
<Image
  src="https://cdn.example.com/photo.jpg"
  alt="설명"
  width={800}
  height={600}
  sizes="(max-width: 768px) 100vw, 50vw"
/>

priority prop은 LCP 이미지에 추가하세요. 해당 이미지를 preload하여 LCP 점수를 개선합니다. next.config.js의 images.domains에 허용할 외부 도메인을 등록해야 외부 이미지를 최적화할 수 있습니다.

App Router 도입 시 팀이 겪는 학습 곡선

Next.js App Router는 기존 Pages Router와 패러다임이 크게 다르기 때문에, 팀 전체가 새로운 개념을 이해하는 데 시간이 필요합니다. 가장 낯선 개념은 서버 컴포넌트와 클라이언트 컴포넌트의 구분입니다. 기존에는 모든 컴포넌트에서 자유롭게 상태와 이펙트를 사용할 수 있었지만, App Router에서는 'use client' 지시어가 없으면 기본적으로 서버에서 실행됩니다. 이 경계를 잘못 이해하면 "이 훅은 서버 컴포넌트에서 사용할 수 없습니다"라는 오류가 자주 발생합니다. 좋은 규칙은 데이터 패칭과 DB 접근은 서버 컴포넌트에서, 상태 관리와 이벤트 핸들러가 필요한 부분만 클라이언트 컴포넌트로 분리하는 것입니다.

라우팅 방식도 익숙해지는 데 시간이 걸립니다. 폴더가 곧 라우트이고, 특수 파일(layout.tsx, page.tsx, loading.tsx, error.tsx)이 각각 다른 역할을 담당합니다. 특히 레이아웃이 중첩되는 방식을 이해하면 이전에는 복잡하게 구현하던 네스티드 레이아웃을 매우 간단하게 만들 수 있습니다. 마이그레이션을 계획 중이라면 Pages Router와 App Router를 동시에 사용할 수 있다는 점을 활용해 점진적으로 전환하는 것을 권장합니다. 새로운 기능은 App Router로 개발하고, 기존 페이지는 안정성이 확인될 때 하나씩 옮기는 방식이 리스크를 줄이는 현실적인 전략입니다.