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 프로젝트 생성 #
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 기반입니다.
생성이 끝나면 폴더로 들어가 dev 서버를 띄워 봅니다.
cd modern-react-demo
pnpm devhttp://localhost:3000에 접속하면 Next.js 기본 화면이 보입니다.
프로젝트 구조 살펴보기 #
처음 본 분에게 익숙한 부분과 낯선 부분이 섞여 있을 것입니다. 핵심만 추리면:
modern-react-demo/
├── public/ ← 정적 파일 (이미지 등)
├── src/
│ └── app/ ← 여기가 핵심. 라우팅이 시작되는 곳
│ ├── layout.tsx ← 모든 페이지의 공통 레이아웃
│ ├── page.tsx ← '/' 경로의 페이지
│ ├── globals.css ← 전역 스타일
│ └── favicon.ico
├── package.json
├── next.config.ts
└── tsconfig.jsonVite 프로젝트와 가장 큰 차이는 src/app/ 폴더의 파일과 폴더 구조 자체가 라우팅이 된다는 점입니다. URL 경로 하나하나가 폴더이고, 그 안의 page.tsx가 화면을 그립니다. 이게 파일 기반 라우팅입니다.
15장 (React Router)의 <Route path="/about" element={<About />} /> 모델과 비교하면, 라우트 정의를 코드가 아니라 디렉토리 구조가 한다는 것이 결정적 차이입니다.
가장 단순한 페이지 #
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:
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:
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를 쓰세요.
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 (이미 자동 생성돼 있음):
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이 중첩되는 것입니다.
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:
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.tsx | Suspense fallback | 26장 |
error.tsx | 에러 경계 | 33장 (Sentry)와 짝 |
not-found.tsx | 404 화면 | 본 챕터 (선택) |
route.ts | API 라우트 (페이지가 아닌 엔드포인트) | 27장에서 일부 |
template.tsx | layout과 비슷하지만 매번 재마운트되는 버전 | (이 책 범위 밖) |
(group) | route group (URL에 안 나타나는 그룹화 폴더) | 34장 capstone |
지금 외울 필요는 없고, “이런 게 있구나” 정도만 알아 두면 됩니다. 이 4부 진행하면서 차례로 등장합니다.
not-found.tsx — 404 #
매칭되는 라우트가 없을 때의 화면은 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.tsx | app/page.tsx |
| 동적 경로 | pages/posts/[slug].tsx | app/posts/[slug]/page.tsx |
| 레이아웃 | _app.tsx 단일 / 직접 합성 | app/layout.tsx 자동 중첩 |
| 데이터 페칭 | getServerSideProps / getStaticProps | Server Component 함수 본문 (25장) |
| API 라우트 | pages/api/*.ts | app/.../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:
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:
export default function HomePage() {
return (
<div style={{ padding: '24px' }}>
<h1>홈</h1>
<p>Next.js로 만든 리액트 데모입니다.</p>
</div>
);
}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:
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도 바르게 갱신됩니다.
연습문제 #
- 위 미니 사이트에
/posts/[slug]/comments같은 중첩 동적 라우트를 추가해 보세요. 폴더 구조는app/posts/[slug]/comments/page.tsx.params의 타입은Promise<{ slug: string }>그대로입니다 (자식 경로지만 부모의 동적 세그먼트가 그대로 옴). /docs/(marketing)/landing같은 route group을 만들어 보세요.app/docs/(marketing)/landing/page.tsx. 괄호로 감싼 폴더는 URL에 안 나타나지만 layout 그루핑에는 쓰입니다. 실제 URL은/docs/landing이 됩니다.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' 디렉티브의 역할, 그리고 둘을 어떻게 섞어 써야 하는지를 배우겠습니다.