Next.js App Router 완벽 가이드 | Server Components·Streaming·Parallel Routes

Next.js App Router 완벽 가이드 | Server Components·Streaming·Parallel Routes

이 글의 핵심

Next.js 13+ App Router의 모든 것을 실전 예제로 완벽 정리합니다. Server Components, Streaming, Server Actions, Parallel Routes, Intercepting Routes까지 실무에 바로 적용할 수 있는 가이드입니다.

실무 경험 공유: Pages Router에서 App Router로 마이그레이션하면서, 초기 로딩 속도를 50% 향상시키고 코드 복잡도를 크게 줄인 경험을 공유합니다.

들어가며: “Pages Router가 복잡해요”

실무 문제 시나리오

시나리오 1: getServerSideProps가 복잡해요
데이터 페칭 로직이 분리되어 있습니다. App Router는 컴포넌트 안에서 직접 fetch합니다.

시나리오 2: 레이아웃 공유가 어려워요
_app.tsx가 복잡합니다. App Router는 중첩 레이아웃을 지원합니다.

시나리오 3: 로딩 상태 관리가 번거로워요
useState로 관리해야 합니다. App Router는 Suspense로 간단합니다.


1. App Router란?

핵심 특징

App Router는 Next.js 13+의 새로운 라우팅 시스템입니다.

주요 장점:

  • Server Components: 서버에서 렌더링
  • Streaming: 점진적 렌더링
  • Server Actions: Form 처리 간소화
  • 중첩 레이아웃: 레이아웃 공유
  • Parallel Routes: 병렬 렌더링

2. 파일 기반 라우팅

기본 구조

app/
├── layout.tsx          # 루트 레이아웃
├── page.tsx            # / (홈)
├── about/
│   └── page.tsx        # /about
├── blog/
│   ├── layout.tsx      # 블로그 레이아웃
│   ├── page.tsx        # /blog
│   └── [slug]/
│       └── page.tsx    # /blog/:slug
└── dashboard/
    ├── layout.tsx
    ├── page.tsx        # /dashboard
    └── settings/
        └── page.tsx    # /dashboard/settings

3. Server Components

기본 사용

// app/posts/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    cache: 'no-store',  // 항상 최신 데이터
  });
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <a href={`/posts/${post.id}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

캐싱 전략

// 정적 생성 (기본값)
fetch('https://api.example.com/posts', {
  cache: 'force-cache',
});

// 매 요청마다 새로 가져오기
fetch('https://api.example.com/posts', {
  cache: 'no-store',
});

// 10초마다 재검증
fetch('https://api.example.com/posts', {
  next: { revalidate: 10 },
});

4. Client Components

’use client’ 지시어

// app/components/Counter.tsx
'use client';

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

Server + Client 조합

// app/posts/page.tsx (Server Component)
import { Counter } from './Counter';

async function getPosts() {
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();

  return (
    <div>
      <h1>Posts</h1>
      <Counter />  {/* Client Component */}
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

5. Loading & Error

loading.tsx

// app/posts/loading.tsx
export default function Loading() {
  return (
    <div>
      <p>Loading posts...</p>
    </div>
  );
}

error.tsx

// app/posts/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

6. Server Actions

Form 처리

// app/posts/new/page.tsx
import { redirect } from 'next/navigation';

async function createPost(formData: FormData) {
  'use server';

  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, content }),
  });

  redirect('/posts');
}

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />
      <button type="submit">Create</button>
    </form>
  );
}

useFormStatus

'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Creating...' : 'Create Post'}
    </button>
  );
}

7. Parallel Routes

구조

app/
└── dashboard/
    ├── layout.tsx
    ├── @analytics/
    │   └── page.tsx
    ├── @notifications/
    │   └── page.tsx
    └── page.tsx

Layout

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  notifications,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  notifications: React.ReactNode;
}) {
  return (
    <div>
      <div>{children}</div>
      <div>{analytics}</div>
      <div>{notifications}</div>
    </div>
  );
}

8. Intercepting Routes

모달 구현

app/
├── photos/
│   ├── page.tsx
│   └── [id]/
│       └── page.tsx
└── @modal/
    └── (.)photos/
        └── [id]/
            └── page.tsx
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/Modal';

export default function PhotoModal({ params }: { params: { id: string } }) {
  return (
    <Modal>
      <img src={`/photos/${params.id}.jpg`} alt="Photo" />
    </Modal>
  );
}

9. 실전 예제: 블로그

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 60 },
  });

  if (!res.ok) {
    return null;
  }

  return res.json();
}

export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);

  if (!post) {
    return {};
  }

  return {
    title: post.title,
    description: post.excerpt,
  };
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);

  if (!post) {
    notFound();
  }

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

정리 및 체크리스트

핵심 요약

  • App Router: Next.js 13+의 새로운 라우팅
  • Server Components: 서버에서 렌더링
  • Streaming: 점진적 렌더링
  • Server Actions: Form 처리 간소화
  • Parallel Routes: 병렬 렌더링
  • Intercepting Routes: 모달 구현

구현 체크리스트

  • App Router 프로젝트 생성
  • Server Components 이해
  • Loading/Error 처리
  • Server Actions 구현
  • Parallel Routes 활용
  • 메타데이터 최적화
  • 배포

같이 보면 좋은 글

  • React 18 심화 가이드
  • Remix 완벽 가이드
  • Prisma 완벽 가이드

이 글에서 다루는 키워드

Next.js, App Router, React, Server Components, Streaming, SSR, Full Stack

자주 묻는 질문 (FAQ)

Q. Pages Router에서 마이그레이션해야 하나요?

A. 새 프로젝트는 App Router를 권장합니다. 기존 프로젝트는 점진적 마이그레이션이 가능합니다.

Q. Server Components는 항상 사용해야 하나요?

A. 기본적으로 Server Components를 사용하고, 상호작용이 필요한 경우만 Client Components를 사용하세요.

Q. 성능이 더 좋아지나요?

A. 네, Server Components로 JavaScript 번들이 줄어들고 초기 로딩이 빨라집니다.

Q. 학습 곡선이 가파른가요?

A. Server/Client Components 구분이 처음엔 헷갈릴 수 있지만, 익숙해지면 더 직관적입니다.

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3