목차
23 장

Next.js 시작과 App Router

Next.js 15 프로젝트를 만들고 App Router의 파일 기반 라우팅과 layout 시스템을 손에 익힙니다. 파일 규약(page · layout · loading · error · not-found · route group)을 한 번에 다룹니다.

22장에서 왜 Server Components가 필요한지 그 배경을 다뤘습니다. 본 챕터에서는 실제로 손에 잡히는 코드로 들어가겠습니다. Next.js 15 프로젝트를 만들고 App Router의 파일 기반 라우팅을 익히는 것이 목표입니다.

본 챕터의 모델은 15장 (React Router)의 클라이언트 사이드 라우팅과 같은 문제(URL → 화면)를 다른 방식, 즉 파일 시스템 기반으로 풉니다. 15장 마지막의 비교표를 머릿속에 두고 읽으면 가볍게 따라갈 수 있습니다.

Next.js 프로젝트 생성 #

새 Next.js 프로젝트 만들기
pnpm create next-app@latest modern-react-demo

질문이 나오면 다음과 같이 선택합니다 (이 4부 기준).

설정 옵션
✔ TypeScript? ........ Yes  (3부에서 다룬 기준으로 모든 코드 TS)
✔ ESLint? ............ Yes
✔ Tailwind CSS? ...... No  (이 책은 예제 간결성을 위해 인라인 스타일)
✔ src/ directory? .... Yes
✔ App Router? ........ Yes  (반드시 Yes)
✔ Turbopack? ......... Yes
✔ import alias? ...... No

가장 중요한 건 App Router를 Yes로 선택 하는 것입니다. App Router가 Server Components를 지원하는 새 라우터이고, 이 4부는 전부 App Router 기반입니다.

노트
이전부터 있던 Pages Router라는 시스템도 여전히 Next.js에 남아 있지만, 새 프로젝트라면 App Router를 쓰는 게 표준입니다. 두 시스템은 구조와 동작 방식이 다르고, Server Components는 App Router에서만 제대로 동작합니다. Pages Router에서 App Router로 옮기는 절차는 부록 A (옛 리액트 마이그레이션)에서 다룹니다.

생성이 끝나면 폴더로 들어가 dev 서버를 띄워 봅니다.

dev 서버 실행
cd modern-react-demo
pnpm dev

http://localhost:3000에 접속하면 Next.js 기본 화면이 보입니다.

프로젝트 구조 살펴보기 #

처음 본 분에게 익숙한 부분과 낯선 부분이 섞여 있을 것입니다. 핵심만 추리면:

modern-react-demo/
modern-react-demo/
├── public/                ← 정적 파일 (이미지 등)
├── src/
│   └── app/               ← 여기가 핵심. 라우팅이 시작되는 곳
│       ├── layout.tsx     ← 모든 페이지의 공통 레이아웃
│       ├── page.tsx       ← '/' 경로의 페이지
│       ├── globals.css    ← 전역 스타일
│       └── favicon.ico
├── package.json
├── next.config.ts
└── tsconfig.json

Vite 프로젝트와 가장 큰 차이는 src/app/ 폴더의 파일과 폴더 구조 자체가 라우팅이 된다는 점입니다. URL 경로 하나하나가 폴더이고, 그 안의 page.tsx가 화면을 그립니다. 이게 파일 기반 라우팅입니다.

15장 (React Router)의 <Route path="/about" element={<About />} /> 모델과 비교하면, 라우트 정의를 코드가 아니라 디렉토리 구조가 한다는 것이 결정적 차이입니다.

가장 단순한 페이지 #

src/app/page.tsx를 비우고 새로 작성해 봅시다.

src/app/page.tsx
export default function HomePage() {
  return (
    <main style={{ padding: '24px' }}>
      <h1> 페이지</h1>
      <p>리액트 4부에 오신 것을 환영합니다.</p>
    </main>
  );
}

저장하면 / 경로가 갱신됩니다. 이 컴포넌트는 Server Component입니다. 'use client'가 없으면 기본이 그렇기 때문입니다. 콘솔이나 dev 서버 터미널에 console.log를 적어 보면 그 출력이 브라우저가 아니라 dev 서버 쪽에 찍힙니다.

실험
export default function HomePage() {
  console.log('이게 어디 찍히는지?');  // dev 서버 터미널
  return <h1> 페이지</h1>;
}

서버에서 실행되는 코드라는 게 이렇게 직관적으로 확인됩니다. 자세한 건 다음 24장 (Server vs Client Components)에서 봅니다.

새 라우트 추가하기 #

/about 페이지를 만들어 봅시다. 폴더를 만들고 그 안에 page.tsx를 두면 끝입니다.

폴더 구조
src/app/
├── layout.tsx
├── page.tsx              ← '/'
└── about/
    └── page.tsx          ← '/about'

src/app/about/page.tsx:

src/app/about/page.tsx
export default function AboutPage() {
  return (
    <main style={{ padding: '24px' }}>
      <h1>소개</h1>
      <p> 사이트는 modern-react  4 학습용 데모입니다.</p>
    </main>
  );
}

http://localhost:3000/about에 접속하면 새 페이지가 보입니다. 라우팅 설정 코드는 한 줄도 안 썼는데 폴더만으로 라우팅이 됐습니다.

동적 경로 #

URL에 동적 파라미터가 들어가는 라우트는 폴더 이름을 [parameter] 형태로 지어 만듭니다.

동적 경로
src/app/
└── posts/
    └── [slug]/
        └── page.tsx      ← '/posts/anything'

src/app/posts/[slug]/page.tsx:

src/app/posts/[slug]/page.tsx
type Props = {
  params: Promise<{ slug: string }>;
};

export default async function PostPage({ params }: Props) {
  const { slug } = await params;

  return (
    <main style={{ padding: '24px' }}>
      <h1>포스트: {slug}</h1>
      <p> 페이지의 슬러그는 "{slug}" 입니다.</p>
    </main>
  );
}

/posts/hello-world, /posts/리액트-입문 같은 URL이 모두 이 파일에 매칭되고, params.slug로 동적 부분을 꺼냅니다. Next.js 15부터 params가 Promise 라서 await을 거쳐야 하는 점을 주의하세요.

15장의 useParams와 비슷하지만, 여기서는 컴포넌트의 props로 들어옵니다. Server Component 안에서 훅을 쓸 수 없기 때문입니다 (24장에서 자세히 다룸).

링크로 이동하기 — <Link> #

페이지 간 이동은 Next.js가 제공하는 Link 컴포넌트로 합니다. 일반 <a>는 페이지 새로고침을 일으키므로, 클라이언트 사이드 전환을 위해 항상 Link를 쓰세요.

src/app/page.tsx
import Link from 'next/link';

export default function HomePage() {
  return (
    <main style={{ padding: '24px' }}>
      <h1> 페이지</h1>
      <ul>
        <li><Link href="/about">소개</Link></li>
        <li><Link href="/posts/hello-world"> 번째 포스트</Link></li>
      </ul>
    </main>
  );
}

Link는 화면에 보이기 시작하면 그 페이지를 미리 prefetch까지 해서 클릭 즉시 전환되도록 만들어 줍니다. 15장의 <Link to=...>와 같은 역할이지만, prefetch까지 자동인 점이 차이입니다.

Layout — 공통 껍데기 #

웹사이트의 헤더, 푸터, 사이드바처럼 여러 페이지가 공유하는 부분을 어떻게 처리할까요? Next.js에서는 layout.tsx 파일이 그 역할을 합니다.

src/app/layout.tsx (이미 자동 생성돼 있음):

src/app/layout.tsx
import './globals.css';
import type { ReactNode } from 'react';

export const metadata = {
  title: '리액트 데모',
  description: 'Next.js 학습용',
};

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <header style={{ padding: '12px', background: '#f4f4f4' }}>
          <strong>나의 사이트</strong>
        </header>
        <div>{children}</div>
        <footer style={{ padding: '12px', background: '#f4f4f4', marginTop: '40px' }}>
          © 2026
        </footer>
      </body>
    </html>
  );
}

핵심:

  • <html><body>가 여기에 있어야 한다 (root layout이 페이지의 뼈대)
  • children은 그 layout 아래의 페이지 (또는 더 하위 layout)
  • metadata<head> 정보. Next.js가 알아서 처리

이제 모든 페이지에 헤더와 푸터가 자동으로 붙습니다. 각 page.tsx는 본문 부분만 작성하면 됩니다.

중첩 layout #

폴더에 layout.tsx를 두면 그 폴더와 하위 경로에만 적용되는 layout을 추가할 수 있습니다. layout이 중첩되는 것입니다.

중첩 layout 구조
src/app/
├── layout.tsx              ← 모든 페이지 공통 (root layout)
├── page.tsx                ← '/'
└── docs/
    ├── layout.tsx          ← '/docs/...' 모든 페이지에 적용
    ├── page.tsx            ← '/docs'
    └── [slug]/
        └── page.tsx        ← '/docs/anything'

src/app/docs/layout.tsx:

src/app/docs/layout.tsx
import Link from 'next/link';
import type { ReactNode } from 'react';

export default function DocsLayout({ children }: { children: ReactNode }) {
  return (
    <div style={{ display: 'flex', gap: '24px', padding: '24px' }}>
      <aside style={{ width: '180px', borderRight: '1px solid #eee', paddingRight: '16px' }}>
        <h3>문서</h3>
        <ul>
          <li><Link href="/docs/intro">시작하기</Link></li>
          <li><Link href="/docs/api">API</Link></li>
        </ul>
      </aside>
      <section style={{ flex: 1 }}>
        {children}
      </section>
    </div>
  );
}

이제 /docs로 시작하는 모든 페이지에 사이드바가 자동으로 붙습니다. 다른 경로 (/about, /posts/...)에는 영향 없습니다. layout이 페이지 트리를 따라 자연스럽게 중첩 됩니다. 15장의 <Outlet /> + 중첩 라우트 패턴이 파일 시스템으로 자동화된 모양입니다.

페이지 사이를 이동할 때 layout 자체는 재마운트되지 않고, 변경된 부분만 다시 그려집니다. 그래서 사이드바의 스크롤 위치 같은 게 유지되는 부드러운 UX가 자연스럽게 나옵니다.

App Router의 파일 규약 정리 #

App Router에는 page.tsx, layout.tsx 외에도 폴더에 두면 자동으로 동작하는 특별한 파일들이 있습니다.

파일역할이 책에서 다루는 위치
page.tsx라우트의 화면 (필수)본 챕터
layout.tsx그 폴더 이하 공통 레이아웃본 챕터
loading.tsxSuspense fallback26장
error.tsx에러 경계33장 (Sentry)와 짝
not-found.tsx404 화면본 챕터 (선택)
route.tsAPI 라우트 (페이지가 아닌 엔드포인트)27장에서 일부
template.tsxlayout과 비슷하지만 매번 재마운트되는 버전(이 책 범위 밖)
(group)route group (URL에 안 나타나는 그룹화 폴더)34장 capstone

지금 외울 필요는 없고, “이런 게 있구나” 정도만 알아 두면 됩니다. 이 4부 진행하면서 차례로 등장합니다.

not-found.tsx — 404 #

매칭되는 라우트가 없을 때의 화면은 not-found.tsx로 정의합니다.

src/app/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div style={{ padding: '24px' }}>
      <h1>404  페이지를 찾을  없습니다</h1>
      <Link href="/">홈으로</Link>
    </div>
  );
}

15장의 path="*"와이드카드 라우트 대신, 파일 이름 규약으로 처리합니다.

Pages Router vs App Router — 한 줄 비교 #

옛 Next.js 프로젝트나 자료에서 만나는 Pages Router와의 비교를 짧게 둡니다. 자세한 마이그레이션 절차는 부록 A에서 다룹니다.

항목Pages Router (옛)App Router (이 책)
라우트 정의pages/index.tsxapp/page.tsx
동적 경로pages/posts/[slug].tsxapp/posts/[slug]/page.tsx
레이아웃_app.tsx 단일 / 직접 합성app/layout.tsx 자동 중첩
데이터 페칭getServerSideProps / getStaticPropsServer Component 함수 본문 (25장)
API 라우트pages/api/*.tsapp/.../route.ts
Server Components✓ (기본)

이 4부는 전부 App Router 기준입니다.

동작 확인 — 작은 사이트 만들기 #

지금까지 배운 걸 종합해 작은 사이트를 만들어 봅시다.

만들 구조
src/app/
├── layout.tsx                     ← 헤더 + 푸터
├── page.tsx                       ← '/'
├── about/page.tsx                 ← '/about'
└── posts/
    ├── page.tsx                   ← '/posts' (목록)
    └── [slug]/page.tsx            ← '/posts/[slug]' (상세)

src/app/layout.tsx:

src/app/layout.tsx
import Link from 'next/link';
import type { ReactNode } from 'react';
import './globals.css';

export const metadata = { title: '리액트 데모' };

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <header style={{ padding: '12px 24px', background: '#222', color: '#fff' }}>
          <Link href="/" style={{ color: '#fff', textDecoration: 'none', marginRight: '16px' }}></Link>
          <Link href="/about" style={{ color: '#fff', textDecoration: 'none', marginRight: '16px' }}>소개</Link>
          <Link href="/posts" style={{ color: '#fff', textDecoration: 'none' }}>포스트</Link>
        </header>
        <main>{children}</main>
      </body>
    </html>
  );
}

src/app/page.tsx:

src/app/page.tsx
export default function HomePage() {
  return (
    <div style={{ padding: '24px' }}>
      <h1></h1>
      <p>Next.js로 만든 리액트 데모입니다.</p>
    </div>
  );
}

src/app/posts/page.tsx:

src/app/posts/page.tsx
import Link from 'next/link';

const POSTS = [
  { slug: 'hello-world', title: '첫 글' },
  { slug: 'about-rsc', title: 'RSC란 무엇인가?' },
  { slug: 'tips', title: '학습 팁' },
];

export default function PostsPage() {
  return (
    <div style={{ padding: '24px' }}>
      <h1>포스트</h1>
      <ul>
        {POSTS.map(post => (
          <li key={post.slug}>
            <Link href={`/posts/${post.slug}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

src/app/posts/[slug]/page.tsx:

src/app/posts/[slug]/page.tsx
type Props = {
  params: Promise<{ slug: string }>;
};

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  return (
    <div style={{ padding: '24px' }}>
      <h1>{slug}</h1>
      <p> 페이지는 슬러그 "{slug}"  본문입니다.</p>
    </div>
  );
}

저장하고 헤더의 링크들을 클릭해 이동해 보세요. 화면 전환이 깜빡임 없이 부드럽게 일어나고, URL도 바르게 갱신됩니다.

연습문제 #

  1. 위 미니 사이트에 /posts/[slug]/comments 같은 중첩 동적 라우트를 추가해 보세요. 폴더 구조는 app/posts/[slug]/comments/page.tsx. params의 타입은 Promise<{ slug: string }> 그대로입니다 (자식 경로지만 부모의 동적 세그먼트가 그대로 옴).
  2. /docs/(marketing)/landing 같은 route group을 만들어 보세요. app/docs/(marketing)/landing/page.tsx. 괄호로 감싼 폴더는 URL에 안 나타나지만 layout 그루핑에는 쓰입니다. 실제 URL은 /docs/landing이 됩니다.
  3. not-found.tsx를 root가 아니라 app/posts/ 안에 두고 /posts/존재하지않는슬러그로 접근해 보세요. (slug 페이지에서 notFound()를 호출하면 가장 가까운 not-found.tsx가 렌더됩니다.) 부록: import { notFound } from 'next/navigation'; if (!post) notFound(); 패턴.

한 줄 요약: Next.js 15 + App Router가 이 4부의 환경. src/app/의 폴더 구조가 곧 라우팅이고, page.tsx는 화면, layout.tsx는 공유 껍데기다. 동적 경로는 [param] 폴더, params는 Promise. <Link>로 클라이언트 사이드 전환 + prefetch. 15장의 React Router가 코드로 정의하던 라우트를 파일 시스템이 자동화한 모양이다.

다음 챕터 #

지금까지 만든 페이지들은 모두 Server Components였습니다. 그런데 클릭 이벤트나 useState 같은 인터랙션이 들어가면 어떻게 될까요? 다음 24장 Server Components vs Client Components에서는 두 종류의 컴포넌트 차이를 명확히 하고, 'use client' 디렉티브의 역할, 그리고 둘을 어떻게 섞어 써야 하는지를 배우겠습니다.

X