Next.js App Router 입문 가이드: 핵심 개념과 기본 사용법 완벽 정리

처음 Next.js 공식 문서를 열었을 때는 막막했습니다. 왼쪽 사이드바에 “Pages Router”와 “App Router”가 나란히 있어 마치 “아무거나 고르세요”라고 말하는 듯했습니다. 하지만 어떤 것을 골라야 할까요? 둘의 차이는 무엇일까요? 문서는 답을 바로 알려 주기는커녕 읽을수록 더 혼란스러웠습니다. 어떤 튜토리얼은 pages 폴더를 쓰고, 다른 튜토리얼은 app 폴더를 쓰며 코드 작성 방식도 완전히 달랐습니다.
나중에서야 Next.js에는 사실 서로 완전히 다른 두 가지 라우팅 시스템이 있다는 것을 알게 됐습니다. 기존 시스템은 Pages Router라고 하며 안정적이고 신뢰할 만하지만 일부 새로운 기능을 사용할 수 없습니다. 새로운 시스템은 App Router로, v13에서 처음 도입되고 v13.4에서 정식 안정화되었으며 이제는 공식적으로 권장되는 방향입니다.
이런 의문이 들 수도 있습니다. “그래도 App Router를 배워야 할까? 또 번거롭기만 한 새 기술은 아닐까?”
이 글은 바로 그 혼란을 해결하기 위해 썼습니다. Server Components가 무엇인지, 특수 파일은 어떻게 사용하는지, Pages Router와 정확히 무엇이 다른지 등 App Router의 핵심 개념을 최대한 쉽게 설명하겠습니다. 끝까지 읽으면 시행착오를 줄이고 빠르게 시작할 수 있습니다.
App Router란 무엇이며 왜 사용해야 할까요?
간단히 말하면 App Router는 Next.js가 v13에서 도입한 새로운 라우팅 시스템입니다. React의 최신 기능인 Server Components(서버 컴포넌트)를 기반으로 하며 더 현대적이고 유연하게 설계되었습니다.
기존 Pages Router와 비교하면 세 가지 뚜렷한 장점이 있습니다.
1. 더 좋은 성능
App Router는 기본적으로 서버 컴포넌트를 사용합니다. 이는 대부분의 코드가 서버에서 실행되어 사용자의 브라우저가 다운로드하는 JavaScript가 줄고 페이지가 자연스럽게 더 빨리 로드된다는 뜻입니다. Vercel의 2024년 보고서에 따르면 상위 Next.js 애플리케이션의 60% 이상이 이미 App Router로 전환했습니다.
"상위 Next.js 애플리케이션의 60% 이상이 이미 App Router로 전환했습니다."
2. 더 유연한 레이아웃 시스템
Pages Router에서는 중첩 레이아웃을 구현하기가 다소 번거롭습니다. App Router에서는 layout.js 파일 하나로 해결할 수 있으며 페이지를 전환해도 레이아웃이 다시 렌더링되지 않아 훨씬 매끄럽습니다.
3. 더 강력한 오류 처리와 로딩 상태
loading.js 파일로 로딩 애니메이션을 정의하고 error.js로 오류를 잡아 대체 UI를 표시할 수 있습니다. Pages Router에서는 직접 구현해야 했지만 App Router는 이러한 규칙을 기본으로 제공합니다.
그렇다면 Pages Router도 계속 사용할 수 있을까요? 물론입니다.
두 시스템은 함께 사용할 수 있습니다. 하지만 지금 Next.js를 배운다면 App Router부터 시작하는 것을 권합니다. 공식적으로 권장되는 방향이며 새 프로젝트 스캐폴딩도 v14.1.4부터 App Router를 기본값으로 사용합니다.
파일 시스템 라우팅: 디렉터리에서 페이지로
App Router의 가장 핵심적인 개념은 폴더 구조가 곧 라우트 구조라는 것입니다.
조금 추상적으로 들릴 수 있지만 예제를 보면 바로 이해할 수 있습니다.
app/
├── page.js # 홈 페이지, /에 해당
├── about/
│ └── page.js # 소개 페이지, /about에 해당
└── blog/
├── page.js # 블로그 목록, /blog에 해당
└── [slug]/
└── page.js # 블로그 상세, /blog/:slug에 해당
몇 가지 핵심 사항을 기억해 두세요.
1. page.js는 라우트의 진입점입니다
이름이 page.js인 파일만 접근 가능한 페이지가 됩니다. layout.js, loading.js 같은 다른 파일은 기능을 지원하는 파일이며 직접 접근할 수 없습니다.
2. 동적 라우트에는 대괄호를 사용합니다
/blog/hello-world 같은 동적 라우트가 필요하다면 app/blog/[slug]/page.js를 만드세요. slug 매개변수가 컴포넌트에 자동으로 전달됩니다.
// app/blog/[slug]/page.js
export default function BlogPost({ params }) {
return <h1>文章:{params.slug}</h1>
}
3. 모든 라우트를 포착하려면 [...slug]를 사용합니다
때로는 /docs/a/b/c처럼 여러 단계의 경로를 일치시켜야 합니다. app/docs/[...slug]/page.js를 사용하면 되고, params.slug는 ['a', 'b', 'c'] 배열이 됩니다.
Pages Router와 비교하면 다음과 같습니다.
Pages Router를 사용해 본 적이 있다면 pages/blog/[id].js를 썼다는 것을 알 것입니다. App Router에서는 app/blog/[id]/page.js로 바뀌어 폴더가 한 단계 더 생겼습니다. 왜 그럴까요? 각 라우트에 layout.js, loading.js 같은 특수 파일을 둘 공간을 확보하기 위해서입니다.
처음에는 번거롭게 느껴질 수 있지만 익숙해지면 프로젝트 전체 구조가 훨씬 명확해집니다.
Server Components와 Client Components: 핵심 개념
App Router에서 가장 혼란스러운 개념일 수 있습니다. 저도 처음 배울 때 꽤 오래 헤맸습니다.
한 문장으로 정리하면 App Router의 컴포넌트는 기본적으로 서버에서 실행되며 상호작용이 필요할 때만 브라우저에서 실행됩니다.
기본값은 Server Component입니다
app/ 디렉터리에서 만든 컴포넌트는 기본적으로 모두 Server Component(서버 컴포넌트)입니다. 서버에서 렌더링을 마친 뒤 HTML을 브라우저로 바로 보냅니다.
장점은 분명합니다.
- 작은 JavaScript 용량: 코드를 브라우저로 보낼 필요가 없어 사용자가 다운로드하는 JS 파일이 더 작습니다.
- 백엔드 리소스에 직접 접근: 데이터베이스 쿼리나 API 키 같은 민감한 정보를 안전하게 사용할 수 있습니다.
- 빠른 초기 화면 로딩: 서버에서 렌더링을 마친 결과를 바로 전달하므로 FCP(First Contentful Paint) 시간이 짧습니다.
다음은 전형적인 Server Component 예제입니다.
// app/products/page.js
// 这是 Server Component,在服务器端运行
async function getProducts() {
const res = await fetch('https://api.example.com/products')
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return (
<div>
<h1>产品列表</h1>
{products.map(p => (
<div key={p.id}>{p.name}</div>
))}
</div>
)
}
보셨나요? useEffect나 getServerSideProps 없이 async/await로 직접 데이터를 가져올 수 있습니다.
Client Component는 언제 사용할까요?
다음처럼 반드시 브라우저에서 실행해야 하는 상황도 있습니다.
- React hooks(
useState,useEffect)를 사용할 때 - 사용자 상호작용(
onClick,onChange)을 처리할 때 - 브라우저 API(
localStorage,window)를 사용할 때
이럴 때 Client Component(클라이언트 컴포넌트)가 필요합니다. 표시 방법은 간단합니다. 파일 맨 위에 'use client' 한 줄을 추가하면 됩니다.
// components/AddToCartButton.js
'use client' // 标记为 Client Component
import { useState } from 'react'
export default function AddToCartButton({ productId }) {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
加入购物车 ({count})
</button>
)
}
함께 사용하기: 모범 사례
두 가지 컴포넌트를 함께 사용할 수 있다는 점이 진짜 강점입니다.
예를 들어 상품 페이지라면 다음처럼 구성할 수 있습니다.
- 상품 목록은 Server Component 사용(서버에서 데이터를 가져와 JS 용량 절감)
- 장바구니 추가 버튼은 Client Component 사용(클릭 상호작용 처리 필요)
// app/products/page.js (Server Component)
import AddToCartButton from '@/components/AddToCartButton' // Client Component
async function getProducts() {
// 服务器端获取数据
}
export default async function ProductsPage() {
const products = await getProducts()
return (
<div>
<h1>产品列表</h1>
{products.map(p => (
<div key={p.id}>
{p.name}
<AddToCartButton productId={p.id} />
</div>
))}
</div>
)
}
한 가지 원칙을 기억하세요. 기본적으로 Server Component를 사용하면 충분합니다. 실제로 상호작용이 필요할 때만 'use client'를 사용하세요.
처음부터 모든 컴포넌트에 'use client'를 붙인다면 App Router를 쓰지 않는 것과 다를 바가 없습니다.
특수 파일: 프로젝트를 더 탄탄하게 만들기
App Router에는 layout.js, loading.js, error.js 같은 여러 특수 파일명이 정의되어 있습니다. 처음 보면 번거로워 보이지만 실제로 사용해 보면 매우 편리합니다.
layout.js: 공유 레이아웃
가장 자주 사용하는 특수 파일입니다. 하나의 라우트 세그먼트에 대한 레이아웃을 정의하고, 같은 계층과 하위 계층의 모든 페이지를 감쌉니다.
예를 들어 애플리케이션 전체에 내비게이션 바와 푸터를 추가하고 싶다면 다음과 같이 작성합니다.
// app/layout.js (根布局)
export default function RootLayout({ children }) {
return (
<html lang="zh">
<body>
<nav>导航栏</nav>
<main>{children}</main>
<footer>页脚</footer>
</body>
</html>
)
}
더 좋은 점은 레이아웃을 중첩할 수 있다는 것입니다.
app/
├── layout.js # 전역 레이아웃(내비게이션+푸터)
├── page.js # 홈 페이지
└── dashboard/
├── layout.js # 대시보드 레이아웃(사이드바)
├── page.js # /dashboard
└── settings/
└── page.js # /dashboard/settings
/dashboard에서 /dashboard/settings로 이동할 때 전역 레이아웃과 대시보드 레이아웃은 모두 다시 렌더링되지 않고 page.js만 업데이트됩니다. 매우 매끄럽습니다.
loading.js: 로딩 상태
더 이상 useState로 loading 상태를 직접 관리할 필요가 없습니다. loading.js를 만들면 App Router가 페이지를 자동으로 Suspense로 감쌉니다.
// app/dashboard/loading.js
export default function Loading() {
return <div>加载中...</div>
}
페이지가 데이터를 가져오는 동안 loading.js의 내용이 자동으로 표시됩니다. 이게 전부입니다.
error.js: 오류 경계
페이지 오류를 잡아 대체 UI를 표시할 때 사용합니다.
// app/dashboard/error.js
'use client' // Error boundaries 必须是 Client Component
export default function Error({ error, reset }) {
return (
<div>
<h2>出错了:{error.message}</h2>
<button onClick={reset}>重试</button>
</div>
)
}
주의할 점이 하나 있습니다. error.js는 같은 계층의 layout.js에서 발생한 오류를 잡을 수 없습니다. React Error Boundary의 제약 때문입니다. 하위 컴포넌트의 오류만 잡을 수 있고 자기 자신이나 상위 컴포넌트의 오류는 잡을 수 없습니다.
layout.js의 오류를 잡으려면 상위 디렉터리에 error.js를 두거나 루트 디렉터리의 global-error.js를 사용해야 합니다.
not-found.js: 404 페이지
라우트가 존재하지 않을 때 표시됩니다.
// app/not-found.js
export default function NotFound() {
return <h1>页面不存在</h1>
}
코드에서 404를 직접 발생시킬 수도 있습니다.
import { notFound } from 'next/navigation'
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
if (!post) notFound() // 触发 not-found.js
return <article>{post.title}</article>
}
파일 계층 관계
이러한 특수 파일에는 고정된 계층 관계가 있습니다.
layout.js
├── loading.js (Suspense 경계)
│ └── page.js
└── error.js (Error 경계)
layout은 가장 바깥쪽에 있으므로 error.js가 감쌀 수 없습니다. loading.js는 로딩 상태를, error.js는 오류 처리를 담당합니다.
이 계층 관계를 이해하면 불필요한 시행착오를 피할 수 있습니다.
데이터 가져오기: getServerSideProps와 작별하기
Pages Router를 사용해 본 적이 있다면 getServerSideProps나 getStaticProps를 작성해 봤을 것입니다. 솔직히 그 API는 꽤 불편합니다. 함수를 별도로 내보내야 하고 데이터 전달도 직관적이지 않습니다.
App Router는 이 모든 과정을 단순하게 만들었습니다.
async/await 직접 사용하기
Server Component에서는 컴포넌트 함수 안에서 데이터를 직접 가져올 수 있습니다.
// app/posts/page.js
async function getPosts() {
const res = await fetch('https://api.example.com/posts')
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
보셨나요? 특별한 API 없이 평범한 async/await만 사용하면 됩니다.
병렬로 데이터 가져오기
여러 데이터 소스에서 동시에 데이터를 가져올 수도 있습니다.
export default async function Dashboard() {
// 并行获取,不会阻塞
const [user, posts, stats] = await Promise.all([
getUser(),
getPosts(),
getStats()
])
return (
<div>
<h1>{user.name}</h1>
<Posts data={posts} />
<Stats data={stats} />
</div>
)
}
데이터 캐시와 재검증
Next.js는 fetch 요청을 자동으로 캐시합니다. 캐시 전략은 다음과 같이 제어할 수 있습니다.
// 缓存 60 秒后重新验证
fetch('https://api.example.com/data', {
next: { revalidate: 60 }
})
// 不缓存,每次都获取最新数据
fetch('https://api.example.com/data', {
cache: 'no-store'
})
Pages Router와 비교하면 다음과 같습니다.
- Pages Router:
getServerSideProps+getStaticProps, 별도의 함수를 내보내야 함 - App Router:
async/await직접 사용, 컴포넌트 안에서 데이터 가져오기
훨씬 간단하지 않나요?
초보자가 자주 겪는 문제와 해결 방법
App Router를 배울 때 저도 적지 않은 문제를 겪었습니다. 시행착오를 줄이는 데 도움이 되도록 가장 흔한 문제 몇 가지를 정리했습니다.
문제 1: 언제 ‘use client’를 사용해야 하나요?
헷갈리는 점: 튜토리얼 곳곳에 'use client'가 있는데 언제 추가해야 할지 모르겠습니다.
해결 방법:
한 가지 원칙만 기억하세요. 기본적으로 추가하지 말고 필요할 때만 추가합니다.
다음과 같은 경우에만 'use client'를 추가하세요.
- React hooks(
useState,useEffect,useContext)를 사용할 때 - 사용자 상호작용(
onClick,onChange)이 있을 때 - 브라우저 API(
window,localStorage)를 사용할 때
그 외에는 추가하지 마세요. Server Component는 성능이 더 좋고 백엔드 리소스에 직접 접근할 수 있습니다.
문제 2: layout.js와 page.js는 어떤 관계인가요?
헷갈리는 점: 두 파일이 같은 폴더에 있는데 어느 파일이 다른 파일을 감싸는지 모르겠습니다.
해결 방법:
layout.js가 page.js와 하위 라우트를 감쌉니다.
app/
├── layout.js # 아래의 모든 페이지를 감쌈
├── page.js # 홈 페이지, 위 layout에 감싸짐
└── about/
└── page.js # 소개 페이지, 역시 위 layout에 감싸짐
페이지를 전환할 때 layout.js는 다시 렌더링되지 않고 page.js만 업데이트됩니다. 그래서 내비게이션 바가 깜빡이지 않습니다.
문제 3: 동적 라우트 매개변수는 어떻게 가져오나요?
헷갈리는 점: [slug]/page.js를 만들었지만 slug 값을 가져오는 방법을 모르겠습니다.
해결 방법:
params 속성으로 가져옵니다.
// app/blog/[slug]/page.js
export default function BlogPost({ params }) {
console.log(params.slug) // 就是 URL 里的那个值
return <h1>文章:{params.slug}</h1>
}
app/blog/[category]/[slug]/page.js 같은 중첩 동적 라우트라면 다음과 같이 작성합니다.
export default function Post({ params }) {
console.log(params.category, params.slug)
return <h1>{params.category} - {params.slug}</h1>
}
문제 4: error.js가 작동하지 않나요?
헷갈리는 점: error.js를 만들었지만 레이아웃에서 오류가 발생해도 잡히지 않습니다.
해결 방법:
error.js는 같은 계층의 layout.js에서 발생한 오류를 잡을 수 없습니다. React Error Boundary의 제약 때문입니다.
레이아웃 오류를 잡는 방법은 두 가지입니다.
- 상위 디렉터리에
error.js배치 - 루트 디렉터리에서
global-error.js사용(<html>과<body>태그를 포함해야 함)
// app/global-error.js
'use client'
export default function GlobalError({ error, reset }) {
return (
<html>
<body>
<h2>全局错误:{error.message}</h2>
<button onClick={reset}>重试</button>
</body>
</html>
)
}
문제 5: 기존 프로젝트를 마이그레이션해야 하나요?
헷갈리는 점: App Router에 새로운 요소가 많아 기존 프로젝트 전체를 다시 작성해야 할까 봐 걱정됩니다.
해결 방법:
서두를 필요가 없습니다.
Pages Router와 App Router는 함께 사용할 수 있습니다.
- 기존 기능은
pages/를 계속 사용 - 새 기능은
app/사용
Vercel도 Pages Router를 장기적으로 지원하며 폐기하지 않겠다고 밝혔습니다.
다만 새 프로젝트라면 App Router를 바로 사용하세요. 앞으로의 방향이며 생태계도 계속 좋아질 것입니다.
결론
지금까지 살펴본 App Router의 다섯 가지 핵심 개념을 빠르게 정리해 보겠습니다.
- 파일 시스템 라우팅: 폴더 구조가 곧 라우트 구조이며
page.js가 진입점입니다. - Server Components: 기본적으로 서버에서 실행되며 성능이 더 좋습니다.
- Client Components: 상호작용이 필요할 때
'use client'로 표시합니다. - 특수 파일:
layout.js,loading.js,error.js로 프로젝트를 더 탄탄하게 구성합니다. - 데이터 가져오기:
async/await를 직접 사용하고getServerSideProps와 작별합니다.
App Router는 분명 Next.js가 나아가는 방향입니다. Vercel이 꾸준히 투자하고 있고 커뮤니티도 적극적으로 따라가고 있습니다. 지금 Next.js를 배우기 시작한다면 App Router부터 시작해도 좋습니다.
이제 무엇을 해야 할까요?
직접 만들어 보세요. 작은 프로젝트를 하나 만들고 App Router로 블로그나 할 일 애플리케이션을 구현해 보세요. 아무리 많은 개념을 읽어도 직접 코드를 작성해 보는 것만큼 확실한 방법은 없습니다.
문제가 생겨도 당황하지 마세요. Pages Router와 App Router는 함께 사용할 수 있으므로 막히면 우선 Pages Router를 쓰고 천천히 마이그레이션해도 됩니다.
마지막으로 Next.js 공식 문서가 다소 복잡하게 느껴질 수 있지만 App Router 부분은 비교적 자세히 설명되어 있습니다. 구체적인 문제가 생기면 문서를 다시 살펴보거나 GitHub Discussions에서 검색해 보세요.
즐거운 학습 되시길 바랍니다!
FAQ
언제 'use client'를 사용해야 하나요?
layout.js와 page.js는 어떤 관계인가요?
동적 라우트 매개변수는 어떻게 가져오나요?
error.js가 layout.js의 오류를 잡지 못하는 이유는 무엇인가요?
기존 프로젝트를 App Router로 마이그레이션해야 하나요?
App Router와 Pages Router의 주요 차이점은 무엇인가요?
4분 읽기 · 게시일: 2025년 12월 18일 · 수정일: 2026년 9월 4일



댓글
GitHub로 로그인하여 댓글을 남기세요