지난 시간에는 왜 Server Components가 필요한지 그 배경을 다뤘습니다. 이번에는 실제로 손에 잡히는 코드로 들어갑니다. Next.js 프로젝트를 만들고 App Router의 파일 기반 라우팅을 익히는 게 목표입니다.
Next.js 프로젝트 생성
npx create-next-app@latest modern-react-demo질문이 나오면 다음과 같이 선택합니다 (이 시리즈 기준).
✔ TypeScript? ........ No
✔ ESLint? ............ Yes
✔ Tailwind CSS? ...... No
✔ src/ directory? .... Yes
✔ App Router? ........ Yes (반드시 Yes!)
✔ Turbopack? ......... Yes
✔ import alias? ...... No가장 중요한 건 App Router를 Yes로 선택하는 것입니다. App Router가 Server Components를 지원하는 새 라우터이고, 이 시리즈는 전부 App Router 기반이에요.
이전부터 있던 Pages Router라는 시스템도 여전히 Next.js에 남아 있지만, 새 프로젝트라면 App Router를 쓰는 게 표준입니다. 두 시스템은 멘탈 모델이 다르고, Server Components는 App Router에서만 제대로 동작합니다.
생성이 끝나면 폴더로 들어가 개발 서버를 띄워봅니다.
cd modern-react-demo
npm run devhttp://localhost:3000 에 접속하면 Next.js 기본 화면이 보입니다.
프로젝트 구조 살펴보기
처음 본 분에게 익숙한 부분과 낯선 부분이 섞여 있을 거예요. 핵심만 추려보면:
modern-react-demo/
├── public/ ← 정적 파일 (이미지 등)
├── src/
│ └── app/ ← 여기가 핵심! 라우팅이 시작되는 곳
│ ├── layout.js ← 모든 페이지의 공통 레이아웃
│ ├── page.js ← '/' 경로의 페이지
│ ├── globals.css ← 전역 스타일
│ └── favicon.ico
├── package.json
├── next.config.mjs
└── jsconfig.jsonVite 프로젝트와 가장 큰 차이는 src/app/ 폴더의 파일과 폴더 구조 자체가 라우팅이 된다는 점입니다. URL 경로 하나하나가 폴더이고, 그 안의 page.js가 화면을 그립니다. 이게 파일 기반 라우팅입니다.
가장 단순한 페이지
src/app/page.js를 비우고 새로 작성해봅시다.
export default function HomePage() {
return (
<main style={{ padding: '24px' }}>
<h1>홈 페이지</h1>
<p>모던 리액트 시리즈에 오신 것을 환영합니다.</p>
</main>
);
}저장하면 / 경로가 갱신됩니다. 이 컴포넌트는 Server Component입니다 — 'use client'가 없으면 기본이 그렇거든요. 콘솔이나 dev 서버 터미널에 console.log를 박아보면 그 출력이 브라우저가 아니라 dev 서버 쪽에 찍힙니다.
export default function HomePage() {
console.log('이게 어디 찍히는지?'); // dev 서버 터미널!
return <h1>홈 페이지</h1>;
}서버에서 실행되는 코드라는 게 이렇게 직관적으로 확인됩니다. 자세한 건 #3에서 다룹니다.
새 라우트 추가하기
/about 페이지를 만들어봅시다. 폴더를 만들고 그 안에 page.js를 두면 끝입니다.
src/app/
├── layout.js
├── page.js ← '/'
└── about/
└── page.js ← '/about'src/app/about/page.js:
export default function AboutPage() {
return (
<main style={{ padding: '24px' }}>
<h1>소개</h1>
<p>이 사이트는 Next.js 학습용 데모입니다.</p>
</main>
);
}http://localhost:3000/about 에 접속하면 새 페이지가 보입니다. 라우팅 설정 코드는 한 줄도 안 썼는데 폴더만으로 라우팅이 됐죠.
동적 경로
URL에 동적 파라미터가 들어가는 라우트는 폴더 이름을 [parameter] 형태로 지어 만듭니다.
src/app/
└── posts/
└── [slug]/
└── page.js ← '/posts/anything'src/app/posts/[slug]/page.js:
export default async function PostPage({ params }) {
const { slug } = await params;
return (
<main style={{ padding: '24px' }}>
<h1>포스트: {slug}</h1>
<p>이 페이지의 슬러그는 "{slug}"입니다.</p>
</main>
);
}/posts/hello-world, /posts/리액트-입문 같은 URL이 모두 이 파일에 매칭되고, params.slug로 동적 부분을 꺼냅니다. params는 Promise라서 await을 거쳐야 하는 점을 주의하세요 (Next.js 15부터 변경된 부분).
#15에서 다룬 React Router의 useParams와 비슷하지만, 여기서는 컴포넌트의 props로 들어옵니다. Server Component 안에서 훅을 쓸 수 없기 때문이에요 (#3에서 자세히 다룸).
링크로 이동하기 — <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까지 해서 클릭 즉시 전환되도록 만들어줍니다.
Layout — 공통 껍데기
웹사이트의 헤더, 푸터, 사이드바처럼 여러 페이지가 공유하는 부분을 어떻게 처리할까요? Next.js에서는 layout.js 파일이 그 역할을 합니다.
src/app/layout.js (이미 자동 생성돼 있음):
import './globals.css';
export const metadata = {
title: '모던 리액트 데모',
description: 'Next.js 학습용',
};
export default function RootLayout({ children }) {
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.js는 본문 부분만 작성하면 됩니다.
중첩 layout
폴더에 layout.js를 두면 그 폴더와 하위 경로에만 적용되는 layout을 추가할 수 있습니다. layout이 중첩되는 거예요.
src/app/
├── layout.js ← 모든 페이지 공통 (root layout)
├── page.js ← '/'
└── docs/
├── layout.js ← '/docs/...' 모든 페이지에 적용
├── page.js ← '/docs'
└── [slug]/
└── page.js ← '/docs/anything'src/app/docs/layout.js:
import Link from 'next/link';
export default function DocsLayout({ children }) {
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이 페이지 트리를 따라 자연스럽게 중첩됩니다.
페이지 사이를 이동할 때 layout 자체는 재마운트되지 않고, 변경된 부분만 다시 그려집니다. 그래서 사이드바의 스크롤 위치 같은 게 유지되는 부드러운 UX가 자연스럽게 나와요.
특별한 파일들 — 한눈에
App Router에는 page.js, layout.js 외에도 폴더에 두면 자동으로 동작하는 특별한 파일들이 있습니다.
| 파일 | 역할 |
|---|---|
page.js | 라우트의 화면 (필수) |
layout.js | 그 폴더 이하 공통 레이아웃 |
loading.js | Suspense fallback (#5에서 다룸) |
error.js | 에러 경계 |
not-found.js | 404 화면 |
route.js | API 라우트 (페이지가 아닌 엔드포인트) |
template.js | layout과 비슷하지만 매번 재마운트되는 버전 |
지금 외울 필요는 없고, "이런 게 있구나" 정도만 알아두면 됩니다. 시리즈 진행하면서 차례로 등장합니다.
동작 확인 — 작은 사이트 만들기
지금까지 배운 걸 종합해 작은 사이트를 만들어봅시다.
src/app/
├── layout.js ← 헤더 + 푸터
├── page.js ← '/'
├── about/page.js ← '/about'
└── posts/
├── page.js ← '/posts' (목록)
└── [slug]/page.js ← '/posts/[slug]' (상세)src/app/layout.js:
import Link from 'next/link';
import './globals.css';
export const metadata = {
title: '모던 리액트 데모',
};
export default function RootLayout({ children }) {
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.js:
export default function HomePage() {
return (
<div style={{ padding: '24px' }}>
<h1>홈</h1>
<p>Next.js로 만든 모던 리액트 데모입니다.</p>
</div>
);
}src/app/about/page.js:
export default function AboutPage() {
return (
<div style={{ padding: '24px' }}>
<h1>소개</h1>
<p>이 사이트는 학습용 데모입니다.</p>
</div>
);
}src/app/posts/page.js:
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.js:
export default async function PostPage({ params }) {
const { slug } = await params;
return (
<div style={{ padding: '24px' }}>
<h1>{slug}</h1>
<p>이 페이지는 슬러그 "{slug}"의 본문입니다.</p>
</div>
);
}저장하고 헤더의 링크들을 클릭해 이동해보세요. 화면 전환이 깜빡임 없이 부드럽게 일어나고, URL도 바르게 갱신됩니다.
마무리
이번 글에서는 Next.js의 시작과 App Router의 핵심을 다뤘습니다.
npx create-next-app으로 프로젝트 생성 (App Router 선택 필수)src/app/의 폴더 구조가 곧 라우팅page.js= 화면,layout.js= 공유 껍데기- 동적 경로는
[param]폴더 이름 <Link>로 클라이언트 사이드 전환- layout은 자연스럽게 중첩됨
지금까지 우리가 만든 페이지들은 모두 Server Components였습니다. 그런데 클릭 이벤트나 useState 같은 인터랙션이 들어가면 어떻게 될까요? 다음 글인 "모던 리액트 + Next.js #3 Server Components vs Client Components"에서는 두 종류의 컴포넌트 차이를 명확히 하고, 'use client' 디렉티브의 역할, 그리고 둘을 어떻게 섞어 써야 하는지를 배워보겠습니다.