모던 리액트 + Next.js #2 Next.js 시작과 App Router

13 분 소요

지난 시간에는 왜 Server Components가 필요한지 그 배경을 다뤘습니다. 이번에는 실제로 손에 잡히는 코드로 들어갑니다. Next.js 프로젝트를 만들고 App Router의 파일 기반 라우팅을 익히는 게 목표입니다.

Next.js 프로젝트 생성

새 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에서만 제대로 동작합니다.

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

dev 서버 실행
cd modern-react-demo
npm run dev

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

프로젝트 구조 살펴보기

처음 본 분에게 익숙한 부분과 낯선 부분이 섞여 있을 거예요. 핵심만 추려보면:

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

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

가장 단순한 페이지

src/app/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:

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:

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로 동적 부분을 꺼냅니다. paramsPromise라서 await을 거쳐야 하는 점을 주의하세요 (Next.js 15부터 변경된 부분).

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

링크로 이동하기 — <Link>

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

src/app/page.js
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 (이미 자동 생성돼 있음):

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이 중첩되는 거예요.

중첩 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:

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.jsSuspense fallback (#5에서 다룸)
error.js에러 경계
not-found.js404 화면
route.jsAPI 라우트 (페이지가 아닌 엔드포인트)
template.jslayout과 비슷하지만 매번 재마운트되는 버전

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

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

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

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

src/app/layout.js:

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:

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:

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

src/app/posts/page.js:

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:

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' 디렉티브의 역할, 그리고 둘을 어떻게 섞어 써야 하는지를 배워보겠습니다.