본문으로 건너뛰기 Next.js App Router 완벽 가이드 | Server Components·Streaming

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

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

이 글의 핵심

Next.js 13+ App Router 완벽 가이드. Server Components, Streaming, Server Actions, Parallel Routes, Intercepting Routes까지 실전 예제로 정리.

1. App Router 소개

Next.js 13에서 도입된 App Router는 React Server Components를 기반으로 한 새로운 라우팅 시스템입니다. 기존 Pages Router와 병행 사용이 가능하며, 더 나은 성능, 개선된 개발자 경험, 그리고 강력한 레이아웃 시스템을 제공합니다.

App Router의 핵심 이점

서버 우선 아키텍처
기본적으로 모든 컴포넌트는 서버에서 렌더링됩니다. 이는 초기 로딩 속도를 개선하고 클라이언트 번들 크기를 줄여줍니다. 클라이언트 상호작용이 필요한 부분만 선택적으로 'use client' 지시어를 사용하여 클라이언트 컴포넌트로 만들 수 있습니다.

중첩 레이아웃 시스템
각 경로 세그먼트는 자체 layout.tsx를 가질 수 있으며, 이는 자동으로 중첩되어 적용됩니다. 이를 통해 공통 UI 요소를 재사용하고, 네비게이션 시 레이아웃이 유지되어 부드러운 사용자 경험을 제공할 수 있습니다.

스트리밍과 점진적 렌더링
Suspense와 통합되어 페이지의 일부분을 우선적으로 렌더링하고, 느린 데이터는 나중에 스트리밍할 수 있습니다. 이는 Time To First Byte(TTFB)를 개선하고 사용자 체감 성능을 향상시킵니다.

내장 데이터 페칭
getServerSidePropsgetStaticProps 대신, 서버 컴포넌트 내에서 직접 async/await를 사용하여 데이터를 가져올 수 있습니다. Next.js는 자동으로 요청을 중복 제거하고 캐싱합니다.

2. App Router vs Pages Router

주요 차이점 비교

항목Pages Router (pages/)App Router (app/)
라우팅 방식파일 = 페이지폴더 기반, 특수 파일로 역할 정의
레이아웃_app.tsx로 전역 관리layout.tsx로 중첩 가능
데이터 페칭getServerSideProps, getStaticPropsasync 서버 컴포넌트
기본 렌더링클라이언트 컴포넌트서버 컴포넌트
스트리밍지원하지 않음loading.tsx, Suspense
마이그레이션Pages Router와 병행 가능

언제 App Router를 사용해야 하는가?

App Router를 권장하는 경우:

  • 새로운 프로젝트를 시작하는 경우
  • 서버 컴포넌트의 이점을 활용하고 싶은 경우
  • 복잡한 중첩 레이아웃이 필요한 경우
  • 최신 React 기능(Suspense, Streaming 등)을 사용하고 싶은 경우

Pages Router를 유지해도 되는 경우:

  • 레거시 프로젝트가 안정적으로 운영 중인 경우
  • 점진적 마이그레이션 전략을 선택한 경우
  • 특정 서드파티 라이브러리가 Server Components를 지원하지 않는 경우

3. 파일 규약과 라우팅 구조

특수 파일 규약

App Router는 특정 파일명에 특별한 의미를 부여합니다:

파일명목적설명
layout.tsx레이아웃여러 페이지에 공통으로 적용되는 UI
page.tsx페이지경로의 고유한 UI, 공개적으로 접근 가능
loading.tsx로딩 UISuspense 경계를 자동으로 생성
error.tsx에러 UIError Boundary를 자동으로 생성
template.tsx템플릿네비게이션마다 새로 마운트되는 레이아웃
not-found.tsx404 UI리소스를 찾을 수 없을 때 표시

루트 레이아웃 설정

루트 레이아웃은 애플리케이션의 최상위 레이아웃으로, <html><body> 태그를 포함해야 합니다.

// app/layout.tsx
import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  title: {
    template: '%s | My App',
    default: 'My App',
  },
  description: 'My awesome application',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <body>
        <nav>
          {/* 글로벌 네비게이션 */}
        </nav>
        <main>{children}</main>
        <footer>
          {/* 글로벌 푸터 */}
        </footer>
      </body>
    </html>
  );
}

폴더 구조 예시

app/
├── layout.tsx          # 루트 레이아웃
├── page.tsx           # 홈페이지 (/)
├── about/
│   └── page.tsx       # About 페이지 (/about)
├── blog/
│   ├── layout.tsx     # 블로그 레이아웃
│   ├── page.tsx       # 블로그 목록 (/blog)
│   └── [slug]/
│       └── page.tsx   # 블로그 상세 (/blog/[slug])
└── dashboard/
    ├── layout.tsx     # 대시보드 레이아웃
    ├── page.tsx       # 대시보드 홈
    └── settings/
        └── page.tsx   # 설정 페이지

4. 서버 컴포넌트 심화

React Server Components (RSC)란?

서버 컴포넌트는 서버에서만 실행되는 React 컴포넌트입니다. 이는 다음과 같은 이점을 제공합니다:

  1. 제로 번들 크기: 서버 컴포넌트의 코드는 클라이언트 번들에 포함되지 않음
  2. 직접 데이터 액세스: 데이터베이스, 파일 시스템 등에 직접 접근 가능
  3. 민감한 정보 보호: API 키, 토큰 등을 안전하게 서버에서만 사용 가능
  4. 자동 코드 분할: 클라이언트 컴포넌트만 별도로 분할됨

서버 컴포넌트에서 데이터 페칭

// app/dashboard/page.tsx
import { Suspense } from 'react';

// 데이터 페칭 함수 (서버에서만 실행)
async function getMetrics() {
  // API 키를 안전하게 서버에서만 사용
  const res = await fetch(`${process.env.API_URL}/metrics`, {
    headers: {
      Authorization: `Bearer ${process.env.API_TOKEN}`,
    },
    // 캐시 전략 설정
    next: { 
      revalidate: 60, // 60초마다 재검증
      tags: ['metrics'], // 태그 기반 재검증
    },
  });

  if (!res.ok) {
    throw new Error(`Failed to fetch metrics: ${res.status}`);
  }

  return res.json();
}

// 서버 컴포넌트 (async 사용 가능)
export default async function DashboardPage() {
  const metrics = await getMetrics();

  return (
    <div>
      <h1>Dashboard</h1>
      <MetricsDisplay metrics={metrics} />
    </div>
  );
}

// 서버 컴포넌트 내부의 컴포넌트도 서버에서 실행
function MetricsDisplay({ metrics }: { metrics: any }) {
  return (
    <div>
      <p>Total Users: {metrics.totalUsers}</p>
      <p>Active Sessions: {metrics.activeSessions}</p>
    </div>
  );
}

병렬 데이터 페칭

여러 데이터 소스에서 병렬로 데이터를 가져와 성능을 개선할 수 있습니다.

// app/dashboard/page.tsx
async function getUser() {
  const res = await fetch(`${process.env.API_URL}/user`);
  return res.json();
}

async function getPosts() {
  const res = await fetch(`${process.env.API_URL}/posts`);
  return res.json();
}

async function getComments() {
  const res = await fetch(`${process.env.API_URL}/comments`);
  return res.json();
}

export default async function DashboardPage() {
  // 병렬로 데이터 페칭 (Promise.all 사용)
  const [user, posts, comments] = await Promise.all([
    getUser(),
    getPosts(),
    getComments(),
  ]);

  return (
    <div>
      <UserProfile user={user} />
      <PostsList posts={posts} />
      <CommentsList comments={comments} />
    </div>
  );
}

서버 컴포넌트의 제약사항

서버 컴포넌트에서는 다음을 사용할 수 없습니다:

  • React Hooks (useState, useEffect, useContext 등)
  • 브라우저 전용 API (window, document, localStorage 등)
  • 이벤트 리스너 (onClick, onChange 등)
  • React Context의 Provider

이러한 기능이 필요한 경우 클라이언트 컴포넌트를 사용해야 합니다.

5. 클라이언트 컴포넌트 전략

’use client’ 지시어

'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>
  );
}

클라이언트 경계 최소화 전략

클라이언트 번들 크기를 최소화하려면, 클라이언트 컴포넌트를 가능한 한 작게 유지하고 리프 노드 근처에 배치합니다.

잘못된 예 (상위 컴포넌트가 클라이언트)

// ❌ 전체 페이지가 클라이언트 컴포넌트가 됨
'use client';

import { useState } from 'react';

export default function Page() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <header>
        <h1>My Page</h1>
        <nav>{/* 많은 콘텐츠 */}</nav>
      </header>
      <main>
        <article>{/* 많은 콘텐츠 */}</article>
      </main>
      <button onClick={() => setIsOpen(!isOpen)}>
        Toggle
      </button>
      {isOpen && <Modal />}
    </div>
  );
}

올바른 예 (클라이언트 컴포넌트 최소화)

// ✅ 서버 컴포넌트를 기본으로 사용
export default function Page() {
  return (
    <div>
      <header>
        <h1>My Page</h1>
        <nav>{/* 많은 콘텐츠 */}</nav>
      </header>
      <main>
        <article>{/* 많은 콘텐츠 */}</article>
      </main>
      {/* 상호작용이 필요한 부분만 클라이언트 컴포넌트로 */}
      <ToggleButton />
    </div>
  );
}
// components/ToggleButton.tsx
'use client';

import { useState } from 'react';
import { Modal } from './Modal';

export function ToggleButton() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(!isOpen)}>
        Toggle
      </button>
      {isOpen && <Modal />}
    </>
  );
}

서버와 클라이언트 컴포넌트 조합

서버 컴포넌트는 클라이언트 컴포넌트를 자식으로 가질 수 있으며, 그 반대도 가능합니다(children prop을 통해).

// app/page.tsx (서버 컴포넌트)
import { ClientComponent } from './ClientComponent';

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

export default async function Page() {
  const data = await getData();

  return (
    <div>
      <h1>Server-side Data</h1>
      <pre>{JSON.stringify(data, null, 2)}</pre>
      
      {/* 클라이언트 컴포넌트에 서버 데이터 전달 */}
      <ClientComponent initialData={data} />
    </div>
  );
}
// app/ClientComponent.tsx (클라이언트 컴포넌트)
'use client';

import { useState } from 'react';

export function ClientComponent({ initialData }: { initialData: any }) {
  const [data, setData] = useState(initialData);

  const handleUpdate = () => {
    // 클라이언트에서 상태 업데이트
    setData({ ...data, updated: true });
  };

  return (
    <div>
      <button onClick={handleUpdate}>Update</button>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}

6. 중첩 레이아웃과 템플릿

layout.tsx의 동작 방식

layout.tsx는 여러 페이지에 공통으로 적용되는 UI를 정의합니다. 레이아웃은 네비게이션 시 상태를 유지하며 리렌더링되지 않습니다.

// app/dashboard/layout.tsx
import { Sidebar } from './components/Sidebar';

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="dashboard-container">
      <Sidebar />
      <main className="dashboard-content">
        {children}
      </main>
    </div>
  );
}

template.tsx의 사용

template.tsx는 레이아웃과 유사하지만, 네비게이션마다 새로운 인스턴스가 생성됩니다. 이는 다음과 같은 경우에 유용합니다:

  • 페이지 진입/이탈 애니메이션
  • 페이지뷰 로깅
  • 폼 상태 초기화
// app/dashboard/template.tsx
'use client';

import { useEffect } from 'react';
import { usePathname } from 'next/navigation';

export default function DashboardTemplate({
  children,
}: {
  children: React.ReactNode;
}) {
  const pathname = usePathname();

  useEffect(() => {
    // 페이지 변경마다 실행됨
    console.log('Page view:', pathname);
    // 분석 도구에 페이지뷰 전송
  }, [pathname]);

  return <div className="fade-in">{children}</div>;
}

Route Groups로 레이아웃 조직화

Route Groups (folderName)를 사용하면 URL 구조에 영향을 주지 않고 라우트를 논리적으로 그룹화할 수 있습니다.

app/
├── (marketing)/
│   ├── layout.tsx      # 마케팅 레이아웃
│   ├── about/
│   │   └── page.tsx    # /about
│   └── contact/
│       └── page.tsx    # /contact
└── (shop)/
    ├── layout.tsx      # 쇼핑 레이아웃
    ├── products/
    │   └── page.tsx    # /products
    └── cart/
        └── page.tsx    # /cart

7. 데이터 페칭과 캐싱 전략

fetch() API 확장

Next.js는 네이티브 fetch() API를 확장하여 자동 캐싱, 중복 제거, 재검증을 지원합니다.

정적 데이터 페칭 (기본 동작)

// 빌드 시 한 번만 페칭하고 캐시됨
async function getData() {
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

동적 데이터 페칭

// 요청마다 새로운 데이터 페칭
async function getDynamicData() {
  const res = await fetch('https://api.example.com/user', {
    cache: 'no-store',
  });
  return res.json();
}

재검증 기반 캐싱 (ISR)

// 60초마다 백그라운드에서 재검증
async function get ISRData() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 },
  });
  return res.json();
}

태그 기반 재검증

// app/lib/data.ts
export async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] },
  });
  return res.json();
}
// app/actions.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function createPost(formData: FormData) {
  // 포스트 생성 로직...
  
  // 'posts' 태그가 지정된 모든 캐시 재검증
  revalidateTag('posts');
}

요청 중복 제거

Next.js는 동일한 렌더 패스 내에서 같은 URL과 옵션을 가진 fetch 요청을 자동으로 중복 제거합니다.

// app/page.tsx
async function getUser() {
  const res = await fetch('https://api.example.com/user');
  return res.json();
}

// 같은 렌더링 내에서 여러 번 호출해도 실제로는 한 번만 요청됨
export default async function Page() {
  const user1 = await getUser(); // 실제 요청
  const user2 = await getUser(); // 중복 제거됨
  const user3 = await getUser(); // 중복 제거됨

  return <div>{user1.name}</div>;
}

8. Server Actions

Server Actions란?

Server Actions는 서버에서 실행되는 비동기 함수로, 클라이언트와 서버 컴포넌트 모두에서 호출할 수 있습니다. 이를 통해 별도의 API 엔드포인트 없이 서버 측 로직을 실행할 수 있습니다.

기본 사용법

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
  // 입력 검증
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  if (!title || !content) {
    return { error: 'Title and content are required' };
  }

  // 데이터베이스 작업
  try {
    const response = await fetch(`${process.env.API_URL}/posts`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, content }),
    });

    if (!response.ok) {
      throw new Error('Failed to create post');
    }

    const post = await response.json();

    // 캐시 재검증
    revalidatePath('/blog');

    // 새 포스트 페이지로 리다이렉트
    redirect(`/blog/${post.id}`);
  } catch (error) {
    return { error: 'Failed to create post' };
  }
}

폼에서 Server Actions 사용

// app/blog/new/page.tsx
import { createPost } from '@/app/actions';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <div>
        <label htmlFor="title">Title</label>
        <input type="text" id="title" name="title" required />
      </div>
      
      <div>
        <label htmlFor="content">Content</label>
        <textarea id="content" name="content" required />
      </div>
      
      <button type="submit">Create Post</button>
    </form>
  );
}

클라이언트 컴포넌트에서 Server Actions 사용

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

import { likePost } from '@/app/actions';
import { useTransition } from 'react';

export function LikeButton({ postId }: { postId: string }) {
  const [isPending, startTransition] = useTransition();

  const handleLike = () => {
    startTransition(async () => {
      await likePost(postId);
    });
  };

  return (
    <button onClick={handleLike} disabled={isPending}>
      {isPending ? 'Liking...' : 'Like'}
    </button>
  );
}

Server Actions의 보안 고려사항

Server Actions는 서버에서 실행되지만, 클라이언트에서 호출할 수 있으므로 보안에 주의해야 합니다:

  1. 입력 검증: 모든 입력은 서버에서 검증해야 합니다
  2. 인증/인가: 사용자 권한을 확인해야 합니다
  3. Rate Limiting: 남용을 방지하기 위한 제한이 필요할 수 있습니다
  4. CSRF 보호: Next.js는 기본적으로 CSRF 보호를 제공합니다

9. 스트리밍과 Suspense

loading.tsx를 사용한 즉시 로딩 상태

loading.tsx는 해당 세그먼트의 로딩 UI를 정의하며, 자동으로 Suspense 경계를 생성합니다.

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="loading-skeleton">
      <div className="skeleton-header" />
      <div className="skeleton-content" />
      <div className="skeleton-content" />
    </div>
  );
}

Suspense를 사용한 세밀한 스트리밍

페이지 내에서 특정 컴포넌트만 지연 로딩하려면 Suspense를 직접 사용합니다.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { SlowComponent } from './SlowComponent';
import { FastComponent } from './FastComponent';

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      
      {/* 빠른 컴포넌트는 즉시 표시 */}
      <FastComponent />
      
      {/* 느린 컴포넌트는 로딩 상태로 스트리밍 */}
      <Suspense fallback={<div>Loading slow data...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  );
}
// app/dashboard/SlowComponent.tsx
async function getSlowData() {
  // 느린 데이터 페칭 시뮬레이션
  await new Promise((resolve) => setTimeout(resolve, 3000));
  return { message: 'Slow data loaded!' };
}

export async function SlowComponent() {
  const data = await getSlowData();
  
  return <div>{data.message}</div>;
}

병렬 데이터 로딩과 Suspense

// app/dashboard/page.tsx
import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      {/* 각 컴포넌트가 독립적으로 로딩됨 */}
      <Suspense fallback={<div>Loading stats...</div>}>
        <Stats />
      </Suspense>
      
      <Suspense fallback={<div>Loading chart...</div>}>
        <Chart />
      </Suspense>
      
      <Suspense fallback={<div>Loading table...</div>}>
        <Table />
      </Suspense>
    </div>
  );
}

10. 고급 라우팅 패턴

Parallel Routes

Parallel Routes를 사용하면 동일한 레이아웃 내에서 여러 페이지를 동시에 렌더링할 수 있습니다.

app/
└── dashboard/
    ├── layout.tsx
    ├── @analytics/
    │   └── page.tsx
    ├── @team/
    │   └── page.tsx
    └── page.tsx
// app/dashboard/layout.tsx
export default function Layout({
  children,
  analytics,
  team,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  team: React.ReactNode;
}) {
  return (
    <div>
      <div>{children}</div>
      <div className="grid grid-cols-2 gap-4">
        <div>{analytics}</div>
        <div>{team}</div>
      </div>
    </div>
  );
}

Intercepting Routes

Intercepting Routes를 사용하면 현재 레이아웃 내에서 다른 경로의 콘텐츠를 로드할 수 있습니다 (예: 모달).

app/
└── photos/
    ├── page.tsx
    ├── [id]/
    │   └── page.tsx
    └── (.)[id]/
        └── page.tsx
// app/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>
  );
}

11. 마이그레이션 전략

Pages Router에서 App Router로 점진적 마이그레이션

  1. 공존 설정: app 디렉토리와 pages 디렉토리를 동시에 사용
  2. 경로별 마이그레이션: 한 번에 하나의 경로씩 이동
  3. 우선순위 결정: 정적 페이지부터 시작하여 동적 페이지로 진행
  4. 테스트: 각 마이그레이션 후 철저한 테스트 수행

마이그레이션 체크리스트

  • getServerSideProps를 서버 컴포넌트의 async/await로 변경
  • getStaticPropsfetchrevalidate 옵션으로 변경
  • _app.tsxlayout.tsx로 변경
  • _document.tsx를 루트 layout.tsx로 통합
  • 클라이언트 전용 코드에 'use client' 추가
  • API Routes를 Route Handlers로 마이그레이션 (선택사항)

12. 실무 Best Practices

성능 최적화

  1. 서버 컴포넌트 우선: 기본적으로 서버 컴포넌트를 사용하고, 필요할 때만 클라이언트 컴포넌트 사용
  2. 코드 분할: dynamic import로 큰 컴포넌트 지연 로딩
  3. 이미지 최적화: next/image 컴포넌트 활용
  4. 적절한 캐싱: 데이터 특성에 맞는 캐싱 전략 선택

보안

  1. 환경 변수: 민감한 정보는 환경 변수로 관리
  2. 입력 검증: 모든 사용자 입력을 서버에서 검증
  3. 인증/인가: 보호가 필요한 경로에 미들웨어 사용
  4. CORS 설정: API Routes의 CORS 정책 명확히 설정

개발 경험

  1. 타입 안전성: TypeScript 적극 활용
  2. 컴포넌트 조직화: 재사용 가능한 컴포넌트 라이브러리 구축
  3. 에러 처리: error.tsx로 일관된 에러 UI 제공
  4. 로딩 상태: loading.tsx와 Suspense로 좋은 UX 제공

13. 마치며

Next.js App Router는 React Server Components를 기반으로 한 강력한 프레임워크로, 더 나은 성능과 개발자 경험을 제공합니다. 이 가이드에서 다룬 핵심 개념들을 이해하고 실무에 적용한다면, 현대적이고 효율적인 웹 애플리케이션을 구축할 수 있습니다.

핵심 요약:

  • 서버 컴포넌트를 기본으로, 클라이언트 컴포넌트는 필요할 때만 사용
  • 중첩 레이아웃으로 코드 재사용성 향상
  • 적절한 캐싱 전략으로 성능 최적화
  • Server Actions로 간편한 서버 측 로직 실행
  • 점진적 마이그레이션으로 안전한 전환

관련 가이드

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.