Pages Router로 개발한 Next.js 프로젝트, App Router로 마이그레이션하면 성능이 30% 이상 향상된다는 거 알고 계셨나요?
안녕하세요! 오늘은 많은 분들이 고민하고 계실 Next.js App Router 마이그레이션에 대해 정말 자세하게 다뤄볼게요. 저도 실제로 회사 프로젝트를 Pages Router에서 App Router로 옮기면서 진짜 많은 시행착오를 겪었거든요. 처음엔 솔직히 "굳이 옮겨야 하나?" 싶었는데, 막상 마이그레이션 완료하고 나니까 서버 컴포넌트의 편리함이랑 성능 개선이 체감될 정도더라고요. 그래서 이번 글에서는 제가 직접 경험한 노하우를 바탕으로, 여러분이 실수 없이 안전하게 App Router로 넘어갈 수 있도록 완벽한 가이드를 준비했어요!
? Next.js App Router 마이그레이션 준비 사항 체크리스트
마이그레이션 시작하기 전에 진짜 중요한 게 있어요. 바로 현재 프로젝트 상태를 정확히 파악하는 거예요. 저는 처음에 이 단계를 건너뛰고 바로 시작했다가 나중에 엄청 고생했거든요. 특히 Next.js 버전이 13.4 이상이어야 하는데, 이게 정말 중요해요. 왜냐하면 그 이전 버전에서는 App Router가 베타 상태였기 때문이죠.
먼저 확인해야 할 것들을 정리해드릴게요. package.json을 열어서 Next.js 버전을 체크하세요. 그리고 현재 사용 중인 외부 라이브러리들도 꼼꼼히 살펴봐야 해요. 있잖아요, 일부 라이브러리들은 아직 App Router랑 호환이 안 되는 경우가 있거든요. 제가 실제로 겪은 건데, 오래된 상태 관리 라이브러리 때문에 진짜 애먹었어요.
- Next.js 버전이 13.4 이상인지 확인
- React 버전이 18.2.0 이상인지 확인
- TypeScript 사용 시 5.0 이상 권장
- 현재 사용 중인 모든 외부 라이브러리 목록 작성
- API 라우트 사용 현황 파악하기
- Git 브랜치 생성해서 안전하게 작업 진행
그리고 진짜 중요한 팁 하나 더 드릴게요. 바로 점진적 마이그레이션이에요. Pages Router와 App Router는 같은 프로젝트에서 동시에 사용할 수 있거든요. 그러니까 한 번에 다 옮기려고 하지 말고, 작은 페이지부터 하나씩 천천히 옮겨보세요. 저는 처음에 메인 페이지부터 시작했다가 완전 후회했어요. 랜딩 페이지나 간단한 정적 페이지부터 시작하는 게 훨씬 안전해요!
? Pages Router vs App Router 폴더 구조 완벽 비교
Next.js App Router 마이그레이션을 시작하기 전에 가장 먼저 이해해야 할 게 바로 폴더 구조예요. 솔직히 처음 봤을 때 저도 좀 당황했거든요. 기존 Pages Router랑 완전 다른 방식이라서요.
근데 알고 보니까요, App Router의 폴더 구조가 훨씬 더 직관적이고 유연하더라고요. 한번 익숙해지면 정말 편해요.
? 기본 폴더 구조 변화
가장 큰 변화는 pages 폴더가 app 폴더로 바뀌었다는 거예요. 단순히 이름만 바뀐 게 아니라 파일 구조 자체가 완전히 달라졌어요.
| 구분 | Pages Router | App Router |
|---|---|---|
| 루트 폴더 | pages/ | app/ |
| 페이지 파일 | index.js, about.js | page.js (폴더 내) |
| 레이아웃 | _app.js (전역만 가능) | layout.js (중첩 가능) |
| 에러 처리 | _error.js | error.js (세그먼트별) |
| 로딩 상태 | 직접 구현 | loading.js (자동) |
| API 라우트 | pages/api/ | app/api/route.js |
? App Router의 특별 파일들
App Router에서는 특별한 이름을 가진 파일들이 각각의 역할을 담당해요. 이게 진짜 핵심이에요!
app/
├── layout.js # 루트 레이아웃
├── page.js # 홈페이지 (/)
├── loading.js # 로딩 UI
├── error.js # 에러 UI
├── not-found.js # 404 페이지
│
├── blog/
│ ├── layout.js # 블로그 전용 레이아웃
│ ├── page.js # 블로그 메인 (/blog)
│ └── [slug]/
│ └── page.js # 블로그 포스트 (/blog/post-1)
│
└── api/
└── users/
└── route.js # API 엔드포인트
각 파일이 정확히 어떤 역할을 하는지 알아볼게요.
- page.js - 실제 페이지 컨텐츠예요. 이게 있어야 해당 경로에 접근할 수 있어요
- layout.js - 여러 페이지가 공유하는 레이아웃이에요. 네비게이션이나 푸터 같은 거 넣기 좋죠
- loading.js - 페이지 로딩 중에 보여줄 UI예요. Suspense 자동으로 적용돼요
- error.js - 에러 발생 시 보여줄 화면이에요. Error Boundary 자동 생성됩니다
- route.js - API 엔드포인트예요. 기존 pages/api 폴더 역할이죠
? 라우팅 경로 매핑 방식
Pages Router에서는 파일 이름이 곧 URL이었잖아요. 근데 App Router는 폴더 이름이 URL 경로가 돼요.
| 원하는 URL | Pages Router | App Router |
|---|---|---|
| / | pages/index.js | app/page.js |
| /about | pages/about.js | app/about/page.js |
| /blog/[slug] | pages/blog/[slug].js | app/blog/[slug]/page.js |
| /dashboard/settings | pages/dashboard/settings.js | app/dashboard/settings/page.js |
App Router에서는 page.js 파일이 없으면 해당 경로에 접근할 수 없어요. 그래서 공용 컴포넌트는 폴더 안에 마음껏 넣어도 라우트로 인식되지 않아요. 이게 진짜 편하더라고요!
? 프라이빗 폴더와 라우트 그룹
App Router에서 새로 생긴 엄청 유용한 기능이 있어요. 바로 프라이빗 폴더와 라우트 그룹이에요.
폴더 이름 앞에 언더스코어(_)를 붙이면 라우팅에서 제외돼요. 공용 컴포넌트, 유틸 함수 같은 거 정리하기 딱 좋죠.
app/
├── _components/ # 라우트 아님!
│ ├── Header.js
│ └── Footer.js
├── _utils/ # 라우트 아님!
│ └── helpers.js
└── page.js # / 경로
괄호로 감싸면 URL에는 안 나오지만 그룹으로 묶을 수 있어요. 다른 레이아웃 적용할 때 진짜 유용해요.
app/
├── (marketing)/ # URL에 안 나타남
│ ├── layout.js # 마케팅용 레이아웃
│ ├── page.js # / 경로
│ └── about/
│ └── page.js # /about 경로
│
└── (shop)/ # URL에 안 나타남
├── layout.js # 쇼핑몰용 레이아웃
└── products/
└── page.js # /products 경로
이렇게 하면 같은 레벨에 있어도 완전히 다른 레이아웃을 적용할 수 있어요. 진짜 편하죠?
⚡ 병렬 라우트와 인터셉팅 라우트
App Router에는 정말 강력한 기능이 두 가지 더 있는데요. 처음엔 좀 복잡해 보이지만 알고 나면 완전 유용해요.
| 기능 | 문법 | 사용 목적 |
|---|---|---|
| 병렬 라우트 | @folder | 같은 레이아웃에서 여러 페이지 동시 렌더링 |
| 인터셉팅 라우트 | (.)folder, (..)folder | 모달처럼 현재 페이지에서 다른 라우트 보여주기 |
예를 들어 인스타그램 같은 피드에서 사진 클릭하면 모달로 뜨는 거요? 그런 거 만들 때 인터셉팅 라우트 쓰면 완전 쉽게 구현돼요. URL도 제대로 바뀌고 새로고침해도 제대로 동작하거든요.
병렬 라우트와 인터셉팅 라우트는 진짜 강력하지만 처음엔 좀 어려울 수 있어요. 기본적인 App Router 구조에 먼저 익숙해진 다음에 천천히 적용해보세요. 서두르지 마시고요!
⚡ Server Component와 Client Component 이해하기

Next.js App Router 마이그레이션에서 가장 많이 헷갈리는 부분이 바로 Server Component와 Client Component 개념이에요. 솔직히 말하자면 저도 처음엔 완전 혼란스러웠거든요.
Pages Router에서는 그냥 컴포넌트 만들고 useState 쓰면 됐잖아요? 근데 App Router에서는 기본이 Server Component예요. 이게 진짜 큰 차이점이죠.
?️ Server Component가 뭔가요?
App Router에서 만드는 모든 컴포넌트는 기본적으로 Server Component예요. 서버에서만 실행되는 컴포넌트라는 뜻이죠. 처음엔 "왜 이렇게 만들었지?"라고 생각했는데, 써보니까 진짜 엄청난 장점이 있더라고요.
- 번들 사이즈 감소 - 클라이언트로 전송되는 JavaScript가 줄어들어요
- 직접 데이터 접근 - 데이터베이스나 파일 시스템에 바로 접근할 수 있죠
- 보안성 향상 - API 키나 민감한 로직을 서버에만 둘 수 있어요
- 캐싱 효율 - 서버에서 렌더링 결과를 캐싱해서 성능이 좋아져요
- 초기 로딩 속도 - HTML이 완성되어 전송되니까 빨라요
근데요, Server Component에서는 못하는 것들도 있어요. 이게 중요한 포인트거든요.
- useState, useReducer 같은 상태 관리 훅
- useEffect, useLayoutEffect 같은 생명주기 훅
- onClick, onChange 같은 이벤트 핸들러
- 브라우저 전용 API (window, document 등)
- 클라이언트 전용 라이브러리들
? Client Component는 언제 써야 하나요?
인터랙션이 필요한 컴포넌트는 Client Component로 만들어야 해요. 파일 맨 위에 'use client' 지시어를 추가하면 되는데요, 이게 생각보다 엄청 간단해요.
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
클릭 수: {count}
</button>
)
}
참고로요, 'use client'를 추가하면 그 컴포넌트와 거기서 import하는 모든 자식 컴포넌트가 Client Component가 돼요. 이것 때문에 실수하기 쉬운데, 제가 팁 하나 드릴게요.
- 가능한 한 아래쪽에 배치 - 컴포넌트 트리에서 필요한 부분만 Client Component로 만드세요
- 작게 쪼개기 - 버튼이나 인풋 같은 작은 단위로 분리하면 좋아요
- children props 활용 - Server Component를 props로 넘겨서 조합할 수 있어요
- 정적 콘텐츠는 분리 - 인터랙션 없는 부분은 Server Component로 두세요
? 실전 마이그레이션 패턴
실제로 Pages Router 코드를 App Router로 옮길 때 어떻게 해야 하는지 패턴을 보여드릴게요. 저도 이렇게 하나씩 바꿔나갔거든요.
| 상황 | Pages Router | App Router |
|---|---|---|
| 정적 콘텐츠 | 일반 컴포넌트 | Server Component (기본) |
| 버튼/폼 | 일반 컴포넌트 | 'use client' 추가 |
| 데이터 페칭 | getServerSideProps | async 컴포넌트에서 fetch |
| Context API | Provider 사용 | Provider를 'use client'로 |
여기서 진짜 중요한 게 하나 있어요. Server Component와 Client Component를 조합하는 방법이거든요.
// app/page.tsx (Server Component)
import ClientButton from './ClientButton'
export default async function Page() {
// 서버에서 데이터 가져오기
const data = await fetch('https://api.example.com/data')
const posts = await data.json()
return (
<div>
<h1>게시글 목록</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
{/* Client Component는 여기서만 */}
<ClientButton postId={post.id} />
</article>
))}
</div>
)
}
? 실수하기 쉬운 포인트
제가 직접 겪었던 실수들을 공유해볼게요. 여러분은 안 당하셨으면 좋겠어요.
- Server Component에서 useState 사용 - "React Hook useState cannot be called in a Server Component" 에러가 떠요
- Client Component에서 async 함수 직접 호출 - 데이터 페칭 방식이 달라요
- 모든 컴포넌트에 'use client' 추가 - Pages Router처럼 쓰려고 하면 안 돼요
- Server Component를 Client Component로 import - 자동으로 Client가 되버려요
있잖아요, 처음엔 귀찮고 복잡해 보이는데요. 이 패턴에 익숙해지면 오히려 코드가 더 깔끔해져요. 성능도 좋아지고요.
제 경험상 전체 페이지를 먼저 Server Component로 만들고, 인터랙션이 필요한 부분만 Client Component로 쪼개는 게 가장 효율적이에요. 처음부터 완벽하게 나누려고 하지 마시고, 에러 메시지를 보면서 하나씩 조정해보세요. 그게 더 빠르고 확실해요.
솔직히 말하자면 이 부분이 App Router 마이그레이션에서 가장 중요한 개념이에요. 이것만 제대로 이해하면 나머지는 진짜 수월하거든요. 혹시 헷갈리는 부분 있으면 공식 문서도 꼭 참고해보세요!
? 데이터 페칭 전략 완전 바꾸기
App Router로 마이그레이션할 때 가장 헷갈리는 게 바로 데이터 페칭이에요. Pages Router에서는 getServerSideProps, getStaticProps 같은 친숙한 함수들을 사용했잖아요? 근데 App Router에서는 이게 완전히 달라졌어요. 처음에는 진짜 당황스러웠는데, 알고 보니까 훨씬 더 직관적이고 강력하더라고요.
솔직히 말하자면, 저도 처음엔 "왜 이렇게 바꾼 거야?"라는 생각이 들었어요. 근데 실제로 써보니까... 와, 이게 진짜 혁신이었구나 싶더라고요.
Pages Router vs App Router 데이터 페칭 비교
먼저 뭐가 어떻게 달라졌는지 표로 한번 정리해볼게요. 이거 보시면 감이 오실 거예요.
| Pages Router | App Router | 특징 |
|---|---|---|
| getServerSideProps | async 컴포넌트 + fetch | 매 요청마다 실행 |
| getStaticProps | fetch with cache: 'force-cache' | 빌드 타임에 생성 |
| getStaticPaths | generateStaticParams | 동적 라우트 경로 생성 |
| getInitialProps | 사용 금지 (레거시) | - |
| revalidate 옵션 | fetch with next: { revalidate } | ISR 구현 |
Server Component에서 직접 데이터 가져오기
가장 큰 변화가 뭐냐면요, 이제는 컴포넌트 안에서 바로 데이터를 fetch할 수 있어요. 별도의 함수를 만들 필요가 없다는 거죠. 진짜 혁신적이에요.
// pages/posts/[id].js
export async function getServerSideProps({ params }) {
const res = await fetch(`https://api.example.com/posts/${params.id}`)
const post = await res.json()
return {
props: { post }
}
}
export default function Post({ post }) {
return {post.title}
}
// app/posts/[id]/page.js
async function getPost(id) {
const res = await fetch(`https://api.example.com/posts/${id}`)
return res.json()
}
export default async function Post({ params }) {
const post = await getPost(params.id)
return {post.title}
}
보이시나요? 완전 간결해졌죠. 컴포넌트 자체를 async로 만들고 그 안에서 바로 데이터를 fetch하면 끝이에요.
fetch 함수를 따로 분리해서 여러 곳에서 재사용할 수 있어요. Next.js가 자동으로 중복 요청을 제거해주거든요. 같은 요청이 여러 컴포넌트에서 발생해도 실제로는 한 번만 실행돼요!
캐싱 전략 완벽 이해하기
App Router에서는 fetch 옵션으로 캐싱을 제어해요. 이게 진짜 강력한데요, 정말 세밀하게 조정할 수 있거든요.
| 캐싱 옵션 | 사용 방법 | 언제 쓸까요? |
|---|---|---|
| force-cache (기본) | fetch(url, { cache: 'force-cache' }) | 정적 컨텐츠, 잘 안 바뀌는 데이터 |
| no-store | fetch(url, { cache: 'no-store' }) | 실시간 데이터, 매번 새로 받아야 할 때 |
| revalidate | fetch(url, { next: { revalidate: 60 } }) | 주기적으로 업데이트가 필요할 때 (ISR) |
| 태그 기반 캐싱 | fetch(url, { next: { tags: ['posts'] } }) | 특정 이벤트로 캐시 무효화 필요할 때 |
// 1. 정적 데이터 (기본값)
const staticData = await fetch('https://api.example.com/config')
// 2. 실시간 데이터
const realtimeData = await fetch('https://api.example.com/live', {
cache: 'no-store'
})
// 3. ISR (1분마다 재생성)
const isrData = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }
})
// 4. 태그 기반 캐싱
const taggedData = await fetch('https://api.example.com/products', {
next: { tags: ['products'] }
})
동적 라우트에서 정적 페이지 생성하기
getStaticPaths가 generateStaticParams로 바뀌었어요. 이름만 바뀐 게 아니라, 사용법도 좀 더 심플해졌죠.
// pages/posts/[id].js
export async function getStaticPaths() {
const res = await fetch('https://api.example.com/posts')
const posts = await res.json()
const paths = posts.map(post => ({
params: { id: post.id.toString() }
}))
return {
paths,
fallback: false
}
}
export async function getStaticProps({ params }) {
const res = await fetch(`https://api.example.com/posts/${params.id}`)
const post = await res.json()
return { props: { post } }
}
// app/posts/[id]/page.js
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(res => res.json())
return posts.map(post => ({
id: post.id.toString()
}))
}
async function getPost(id) {
const res = await fetch(`https://api.example.com/posts/${id}`)
return res.json()
}
export default async function Post({ params }) {
const post = await getPost(params.id)
return {post.title}
}
완전 깔끔해졌죠? 그리고 fallback 설정은 어떻게 하냐고요? 이제는 dynamicParams 옵션으로 관리해요.
- true (기본): generateStaticParams에 없는 경로도 동적으로 생성 (fallback: true 느낌)
- false: 지정된 경로만 허용하고 나머지는 404 (fallback: false 느낌)
병렬 데이터 페칭으로 성능 끌어올리기
여기서 진짜 꿀팁 하나 드릴게요. 여러 개의 데이터를 한 번에 가져와야 할 때 있잖아요? 이럴 때 Promise.all을 쓰면 엄청 빨라져요.
export default async function Dashboard() {
// 이렇게 하면 총 3초 걸려요
const user = await fetch('/api/user') // 1초
const posts = await fetch('/api/posts') // 1초
const comments = await fetch('/api/comments') // 1초
return ...
}
export default async function Dashboard() {
// 이렇게 하면 1초만 걸려요!
const [user, posts, comments] = await Promise.all([
fetch('/api/user'),
fetch('/api/posts'),
fetch('/api/comments')
])
return ...
}
진짜예요. 3배 빨라지는 거 체감되더라고요.
Client Component에서 데이터 페칭하기
모든 데이터를 서버에서 가져올 수는 없잖아요? 사용자 인터랙션에 따라 동적으로 로드해야 할 때도 있고요. 그럴 땐 기존처럼 SWR이나 React Query를 쓰면 돼요.
'use client'
import { useState, useEffect } from 'react'
export default function UserProfile({ userId }) {
const [user, setUser] = useState(null)
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data))
}, [userId])
if (!user) return Loading...
return {user.name}
}
근데 솔직히요, 가능하면 Server Component에서 데이터를 가져오는 게 좋아요. SEO에도 좋고, 초기 로딩 속도도 빠르거든요.
Client Component에서 직접 데이터베이스에 접근하거나 API 키를 사용하면 안 돼요! 보안 문제가 생길 수 있어요. 반드시 API Route를 거쳐서 데이터를 가져오세요.
에러 처리와 로딩 상태 관리
데이터 페칭할 때 에러 처리는 필수죠. App Router에서는 error.js와 loading.js로 훨씬 편하게 처리할 수 있어요.
| 파일명 | 역할 | 언제 보이나요? |
|---|---|---|
| loading.js | 로딩 UI 표시 | 페이지가 로드되는 동안 |
| error.js | 에러 UI 표시 | 데이터 페칭 실패 시 |
| not-found.js | 404 UI 표시 | 데이터를 찾지 못했을 때 |
// app/posts/loading.js
export default function Loading() {
return (
)
}
'use client'
export default function Error({ error, reset }) {
return (
문제가 발생했어요!
{error.message}
reset()}>다시 시도
)
}
이렇게 파일만 만들어두면 알아서 적절한 타이밍에 보여줘요. 진짜 편하죠?
- getServerSideProps → async 컴포넌트 + fetch({ cache: 'no-store' })
- getStaticProps → async 컴포넌트 + fetch (기본 캐싱)
- getStaticPaths → generateStaticParams
- revalidate 옵션 → fetch({ next: { revalidate: 60 } })
- loading/error 처리 → loading.js / error.js 파일 생성
데이터 페칭 마이그레이션이 좀 복잡해 보이지만, 실제로 해보면 오히려 더 직관적이에요. 그리고 성능도 확실히 좋아지더라고요. 한번 시작하면 "아, 이게 진짜 미래구나" 하는 생각이 들 거예요!
⚠️ 마이그레이션 시 자주 발생하는 에러와 해결법
Next.js App Router 마이그레이션을 하다 보면요, 솔직히 말해서 에러 안 만나는 게 이상한 일이에요. 저도 처음 마이그레이션할 때 정말 많은 에러를 만났거든요. 근데 알고 보니까 대부분 비슷한 패턴으로 발생하더라고요. 이 섹션에서는 제가 직접 겪었던 가장 흔한 에러들과 그 해결법을 정리해드릴게요.
? Hydration 에러 완벽 정복하기
Hydration 에러... 이거 진짜 처음 보면 당황스러워요. "Text content does not match server-rendered HTML" 이런 메시지 보면 머리가 아프죠. 이 에러는 서버에서 렌더링된 HTML과 클라이언트에서 렌더링된 HTML이 다를 때 발생해요.
가장 흔한 원인은 Date, Math.random(), localStorage 같은 서버와 클라이언트에서 다른 값을 반환하는 함수들이에요. 이런 거 사용할 땐 정말 조심해야 해요!
// ❌ 잘못된 방법
export default function Page() {
return {new Date().toISOString()}
}
// ✅ 올바른 방법
'use client'
import { useState, useEffect } from 'react'
export default function Page() {
const [date, setDate] = useState('')
useEffect(() => {
setDate(new Date().toISOString())
}, [])
return {date}
}
? 'use client' 누락 에러 해결하기
이거 진짜 많이 만나요. useState, useEffect 같은 React 훅을 사용하는데 'use client' 지시어를 안 붙이면 바로 에러가 나거든요. App Router에서는 기본적으로 모든 컴포넌트가 서버 컴포넌트라서 그래요.
| 에러 메시지 | 발생 원인 | 해결 방법 |
|---|---|---|
| You're importing a component that needs useState | 서버 컴포넌트에서 React 훅 사용 | 파일 최상단에 'use client' 추가 |
| Event handlers cannot be passed to Client Components | 서버 컴포넌트에서 onClick 등 이벤트 핸들러 사용 | 해당 컴포넌트를 클라이언트 컴포넌트로 변경 |
| useContext is not a function | 서버 컴포넌트에서 Context API 사용 | Context Provider와 Consumer 모두 클라이언트 컴포넌트로 만들기 |
? Dynamic Import 에러 해결하기
Pages Router에서 잘 작동하던 dynamic import가 App Router에서 안 될 때 있어요. 특히 SSR을 비활성화하는 패턴을 많이 사용했다면요. 근데 App Router에서는 접근 방식이 좀 달라요.
// Pages Router 방식
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('../components/Chart'), {
ssr: false
})
// App Router 방식
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('../components/Chart'), {
ssr: false,
loading: () => Loading...
})
// 또는 더 명확하게
'use client'
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('../components/Chart'))
? Metadata API 에러 대응하기
Head 컴포넌트에서 새로운 Metadata API로 전환할 때도 에러가 많이 나요. 저도 처음엔 헷갈렸거든요. 특히 동적 메타데이터 생성할 때요.
| 에러 상황 | 문제점 | 올바른 해결법 |
|---|---|---|
| Metadata export is not allowed in client components | 클라이언트 컴포넌트에서 metadata 내보내기 | 서버 컴포넌트에서만 metadata 또는 generateMetadata 사용 |
| You cannot use both metadata export and Head component | Head와 metadata를 동시에 사용 | Head 컴포넌트 제거하고 metadata만 사용 |
| generateMetadata must return a promise or metadata object | 잘못된 반환 형식 | Metadata 타입에 맞는 객체 반환하거나 async 함수로 만들기 |
동적 메타데이터가 필요한 페이지는 generateMetadata 함수를 사용하세요. 이 함수는 자동으로 데이터를 fetch하고 캐싱해주기 때문에 성능도 좋아요. 그리고 params와 searchParams를 인자로 받을 수 있어서 URL 기반 메타데이터 생성도 쉽답니다!
? 라우팅 에러 트러블슈팅
pages 폴더에서 app 폴더로 옮기면서 라우팅 구조가 완전히 바뀌잖아요. 그래서 404 에러나 잘못된 경로 이동 같은 문제가 생겨요. 특히 동적 라우트 사용할 때 조심해야 해요.
// Pages Router
pages/blog/[slug].js → /blog/:slug
// App Router
app/blog/[slug]/page.js → /blog/:slug
// Catch-all Routes
pages/shop/[...slug].js → /shop/*
app/shop/[...slug]/page.js → /shop/*
// Optional Catch-all
pages/docs/[[...slug]].js → /docs/* (including /docs)
app/docs/[[...slug]]/page.js → /docs/* (including /docs)
?️ 빌드 에러 해결 체크리스트
로컬에서는 잘 되는데 빌드할 때 에러 나는 경우 있죠? 진짜 짜증나요. 근데 대부분 몇 가지 패턴으로 해결 가능해요.
- 타입스크립트 에러: tsconfig.json에서 "incremental": false로 설정해보세요. 캐시 문제일 수 있어요.
- 환경변수 누락: NEXT_PUBLIC_ 접두사 제대로 붙였는지 확인하세요. 클라이언트에서 접근하려면 필수예요.
- 이미지 최적화 에러: next.config.js에서 이미지 도메인 설정 확인하세요. App Router에서는 remotePatterns 사용해야 해요.
- 메모리 부족: package.json의 빌드 스크립트에 --max-old-space-size=4096 추가해보세요.
| 빌드 에러 유형 | 주요 원인 | 빠른 해결책 |
|---|---|---|
| Module not found | 의존성 미설치 또는 경로 오류 | node_modules 삭제 후 재설치, import 경로 확인 |
| Cannot read properties of undefined | 서버/클라이언트 데이터 불일치 | optional chaining(?.) 사용, 초기값 설정 |
| Invalid src prop on next/image | 외부 이미지 도메인 미등록 | next.config.js에 remotePatterns 추가 |
| Webpack build failed | 플러그인 충돌 또는 구문 오류 | .next 폴더 삭제 후 재빌드, 의존성 버전 확인 |
에러 메시지 그대로 구글에 검색하지 마세요! "Next.js 13 App Router"나 "Next.js 14" 키워드를 꼭 같이 넣으세요. 그냥 검색하면 Pages Router 해결법이 나와서 더 헷갈려요. 그리고 공식 GitHub Issues도 정말 유용해요. 비슷한 문제 겪은 사람들의 해결법이 많거든요.
마이그레이션하면서 에러 만나는 건 정상이에요. 저도 처음엔 하루에 수십 개씩 만났거든요. 근데 하나씩 해결하다 보면 패턴이 보이고, 나중엔 에러 메시지만 봐도 바로 원인을 알 수 있게 돼요. 포기하지 마시고 천천히 해결해 나가세요!
⚡ 성능 최적화 - App Router의 진짜 힘
App Router로 마이그레이션했다면 이제 성능 최적화를 본격적으로 시작할 차례예요. 솔직히 말하자면 Next.js App Router의 성능 최적화 기능들을 제대로 활용하지 않으면 마이그레이션한 의미가 반감되거든요. 근데 걱정하지 마세요! 하나씩 차근차근 알려드릴게요.
? Server Components 활용으로 번들 크기 줄이기
제가 처음 App Router를 쓸 때 진짜 놀랐던 부분이에요. Server Components를 제대로 활용하면 클라이언트로 전송되는 JavaScript 번들 크기가 엄청 줄어들거든요.
- 기본적으로 모든 컴포넌트를 Server Component로 작성하세요
- 상호작용이 필요한 부분만 'use client'로 분리하세요
- 무거운 라이브러리는 가능한 서버 측에서만 사용하세요
- 데이터 fetching 로직을 Server Component로 옮기세요
// ❌ 최적화 전 - 전체가 클라이언트 컴포넌트
'use client'
import { format } from 'date-fns'
import { parseMarkdown } from 'marked'
export default function Article({ data }) {
const [likes, setLikes] = useState(0)
return (
<div>
<h1>{data.title}</h1>
<p>{format(data.date, 'yyyy-MM-dd')}</p>
<div dangerouslySetInnerHTML={{ __html: parseMarkdown(data.content) }} />
<button onClick={() => setLikes(likes + 1)}>좋아요 {likes}</button>
</div>
)
}
// ✅ 최적화 후 - 상호작용 부분만 클라이언트로
// app/article/[id]/page.tsx (Server Component)
import { format } from 'date-fns'
import { parseMarkdown } from 'marked'
import LikeButton from './LikeButton'
export default async function ArticlePage({ params }) {
const data = await getArticle(params.id)
return (
<div>
<h1>{data.title}</h1>
<p>{format(data.date, 'yyyy-MM-dd')}</p>
<div dangerouslySetInnerHTML={{ __html: parseMarkdown(data.content) }} />
<LikeButton initialLikes={data.likes} />
</div>
)
}
// app/article/[id]/LikeButton.tsx (Client Component)
'use client'
export default function LikeButton({ initialLikes }) {
const [likes, setLikes] = useState(initialLikes)
return <button onClick={() => setLikes(likes + 1)}>좋아요 {likes}</button>
}
이렇게 분리하면 date-fns랑 marked 같은 무거운 라이브러리가 클라이언트 번들에 포함되지 않아요. 진짜 차이가 크더라고요!
? Streaming과 Suspense로 초기 로딩 최적화
App Router에서 제공하는 Streaming 기능은 정말 게임체인저예요. 사용자가 페이지를 빠르게 볼 수 있게 해주거든요.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import UserInfo from './UserInfo'
import Analytics from './Analytics'
import RecentActivity from './RecentActivity'
export default function Dashboard() {
return (
<div>
{/* 빠르게 로드되는 부분 */}
<h1>대시보드</h1>
{/* 사용자 정보는 빠르게 표시 */}
<Suspense fallback={<UserSkeleton />}>
<UserInfo />
</Suspense>
{/* 분석 데이터는 느리게 로드 */}
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics />
</Suspense>
{/* 최근 활동도 독립적으로 */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
)
}
// app/dashboard/Analytics.tsx
export default async function Analytics() {
// 느린 API 호출
const data = await fetch('https://api.example.com/analytics', {
next: { revalidate: 3600 }
})
return <div>{/* 분석 데이터 렌더링 */}</div>
}
이렇게 하면 느린 데이터 때문에 전체 페이지가 블로킹되지 않아요. 빠른 부분은 먼저 보여주고, 느린 부분은 준비되는 대로 스트리밍되죠.
? 데이터 캐싱 전략으로 성능 극대화
App Router의 캐싱 시스템은 진짜 강력해요. 근데 옵션이 많아서 처음엔 좀 헷갈릴 수 있거든요. 제가 실전에서 자주 쓰는 패턴들을 알려드릴게요.
| 캐싱 전략 | 사용 시기 | 설정 방법 |
|---|---|---|
| 정적 데이터 | 거의 변하지 않는 콘텐츠 | { cache: 'force-cache' } |
| 시간 기반 재검증 | 주기적으로 업데이트되는 데이터 | { next: { revalidate: 3600 } } |
| 동적 데이터 | 실시간으로 변하는 데이터 | { cache: 'no-store' } |
| 온디맨드 재검증 | 특정 이벤트 발생시 갱신 | revalidatePath() 또는 revalidateTag() |
// 블로그 포스트 - 1시간마다 재검증
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600, tags: ['posts'] }
})
return res.json()
}
// 사용자 프로필 - 항상 최신 데이터
export async function getUserProfile(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`, {
cache: 'no-store'
})
return res.json()
}
// 정적 페이지 콘텐츠 - 영구 캐싱
export async function getStaticContent() {
const res = await fetch('https://api.example.com/static', {
cache: 'force-cache'
})
return res.json()
}
// API 라우트에서 온디맨드 재검증
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
export async function POST() {
revalidateTag('posts')
return Response.json({ revalidated: true })
}
?️ 이미지 최적화 완벽 가이드
Next.js의 Image 컴포넌트는 App Router에서도 여전히 강력해요. 근데 몇 가지 새로운 기능이 추가됐거든요.
import Image from 'next/image'
// ✅ 기본 사용법
<Image
src="/hero.jpg"
alt="히어로 이미지"
width={1200}
height={600}
priority // LCP 이미지는 priority 설정
/>
// ✅ 외부 이미지 최적화
<Image
src="https://example.com/photo.jpg"
alt="외부 이미지"
width={800}
height={400}
quality={85} // 기본값 75, 조정 가능
/>
// ✅ 반응형 이미지
<Image
src="/banner.jpg"
alt="배너"
fill
style={{ objectFit: 'cover' }}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
// ✅ 블러 플레이스홀더 (로컬 이미지)
<Image
src={profilePic}
alt="프로필"
placeholder="blur"
/>
// ✅ 블러 플레이스홀더 (외부 이미지)
<Image
src="https://example.com/photo.jpg"
alt="외부 이미지"
width={400}
height={300}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
- LCP 이미지에는 반드시 priority 속성 추가하세요
- sizes 속성으로 반응형 이미지 최적화하세요
- 외부 이미지는 next.config.js에 도메인 등록 필요해요
- 블러 플레이스홀더로 CLS(레이아웃 시프트) 방지하세요
- WebP 포맷은 자동으로 변환되니 걱정 없어요
? 코드 스플리팅과 동적 임포트
App Router는 기본적으로 라우트 레벨에서 자동으로 코드 스플리팅을 해줘요. 근데 컴포넌트 레벨에서도 추가로 최적화할 수 있거든요.
import dynamic from 'next/dynamic'
// ✅ 무거운 차트 라이브러리 지연 로딩
const DynamicChart = dynamic(() => import('./Chart'), {
loading: () => <p>차트 로딩 중...</p>,
ssr: false // 클라이언트에서만 렌더링
})
// ✅ 모달은 필요할 때만 로드
const DynamicModal = dynamic(() => import('./Modal'))
export default function Dashboard() {
const [showModal, setShowModal] = useState(false)
return (
<div>
<h1>대시보드</h1>
<DynamicChart data={chartData} />
<button onClick={() => setShowModal(true)}>
모달 열기
</button>
{showModal && <DynamicModal onClose={() => setShowModal(false)} />}
</div>
)
}
// ✅ 여러 컴포넌트를 하나의 청크로
const DynamicComponents = dynamic(() =>
import('./HeavyComponents').then(mod => ({
default: mod.ComponentA
}))
)
⚡ 폰트 최적화로 렌더링 속도 개선
App Router에서 폰트 최적화는 정말 간단해졌어요. next/font를 쓰면 폰트 파일이 자동으로 최적화되고 자체 호스팅돼요.
// app/layout.tsx
import { Inter, Noto_Sans_KR } from 'next/font/google'
// Google 폰트 최적화
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter'
})
const notoSansKr = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap',
variable: '--font-noto'
})
export default function RootLayout({ children }) {
return (
<html lang="ko" className={`${inter.variable} ${notoSansKr.variable}`}>
<body>{children}</body>
</html>
)
}
// 로컬 폰트 사용하기
import localFont from 'next/font/local'
const myFont = localFont({
src: [
{
path: './fonts/MyFont-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: './fonts/MyFont-Bold.woff2',
weight: '700',
style: 'normal',
}
],
variable: '--font-my'
})
이렇게 설정하면 FOUT(Flash of Unstyled Text)나 CLS 문제가 완전히 사라져요. 진짜 신기하죠?
? 성능 측정과 모니터링
최적화했으면 성능을 측정해야죠. Next.js는 빌드 시 자동으로 성능 메트릭을 보여주는데요, 더 자세히 보려면 추가 설정이 필요해요.
// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Analytics } from '@vercel/analytics/react'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<SpeedInsights />
<Analytics />
</body>
</html>
)
}
// 커스텀 리포팅
export function reportWebVitals(metric) {
console.log(metric)
// Google Analytics로 전송
if (metric.label === 'web-vital') {
window.gtag('event', metric.name, {
value: Math.round(metric.value),
event_label: metric.id,
non_interaction: true,
})
}
}
- 개발 모드에서는 성능이 프로덕션보다 느려요
- 반드시 프로덕션 빌드로 테스트하세요
- 실제 사용자 환경에서 측정하는 게 가장 정확해요
- Lighthouse 점수만 보지 말고 실제 체감 속도도 확인하세요
? 추가 성능 최적화 팁
마지막으로 제가 실전에서 써본 꿀팁들을 공유할게요. 이것들만 적용해도 체감 성능이 확 달라져요.
-
Metadata 최적화
generateMetadata로 SEO와 성능 둘 다 잡으세요 -
Route Prefetching 활용
Link 컴포넌트는 자동으로 prefetch하니 적극 사용하세요 -
Parallel Routes로 동시 로딩
여러 섹션을 병렬로 로드해서 속도 개선하세요 -
Intercepting Routes로 UX 개선
모달이나 오버레이를 빠르게 표시할 수 있어요 -
Loading UI 전략적 배치
loading.tsx를 적절한 위치에 두면 체감 속도가 빨라져요
import Link from 'next/link'
// 자동 prefetch (기본값)
<Link href="/dashboard">대시보드</Link>
// prefetch 비활성화 (필요할 때만)
<Link href="/heavy-page" prefetch={false}>
무거운 페이지
</Link>
// 프로그래밍 방식 prefetch
import { useRouter } from 'next/navigation'
export default function MyComponent() {
const router = useRouter()
return (
<button
onMouseEnter={() => router.prefetch('/dashboard')}
onClick={() => router.push('/dashboard')}
>
대시보드로 이동
</button>
)
}
이 정도면 App Router의 성능 최적화 기능들을 제대로 활용할 수 있을 거예요. 하나씩 적용해보면서 실제로 성능이 얼마나 개선되는지 확인해보세요. 저는 이렇게 최적화했더니 Lighthouse 점수가 80점대에서 95점 이상으로 올라갔거든요. 여러분도 충분히 가능해요!
❓ 자주 묻는 질문
네, 완전 가능해요! Next.js는 App Router와 Pages Router를 동시에 지원하거든요. 그래서 App Router 마이그레이션할 때 점진적으로 진행할 수 있어요. /pages 폴더에 있는 건 그대로 두고, /app 폴더에 새로운 라우트를 만들면 되는 거예요. 예를 들어 /pages/blog.js는 그대로 두고 /app/about/page.tsx만 새로 만들 수 있죠. App Router가 우선순위가 더 높아서 같은 경로가 있으면 App Router가 먼저 작동해요. 저는 이 방식으로 3개월에 걸쳐서 천천히 마이그레이션했는데, 서비스 중단 없이 진행할 수 있어서 진짜 좋았어요.
App Router 마이그레이션하면 Server Component에서 직접 async/await로 데이터를 가져오면 돼요. fetch 함수에 { cache: 'no-store' } 옵션을 주면 매번 새로 데이터를 받아오거든요. 예를 들어 "const data = await fetch('/api/posts', { cache: 'no-store' })" 이렇게요. 또는 fetch 대신 Prisma나 다른 DB 클라이언트를 써도 되는데, 그럴 땐 페이지 파일 맨 위에 "export const dynamic = 'force-dynamic'"을 추가하면 SSR처럼 매번 새로 렌더링해요. getServerSideProps보다 훨씬 간단하고 직관적이에요.
App Router에서는 Metadata API를 사용해서 SEO 설정을 훨씬 깔끔하게 할 수 있어요. next/head 대신 metadata 객체를 export하면 되거든요. 정적인 메타데이터는 "export const metadata = { title: '제목', description: '설명' }" 이렇게 쓰면 되고, 동적인 건 generateMetadata 함수를 async로 만들어서 params 받아서 처리하면 돼요. 특히 좋은 게 openGraph, twitter 설정도 다 타입 안정성이 있어서 오타 걱정이 없어요. 저는 App Router 마이그레이션하고 나서 SEO 설정 실수가 확 줄었어요.
정확히 말하면 Server Component에서는 Context API를 못 쓰는 거고, Client Component에서는 그대로 쓸 수 있어요. App Router 마이그레이션할 때는 providers.tsx 같은 별도 Client Component를 만들어서 'use client' 붙이고, 거기에 Context Provider들을 다 모아두는 게 일반적이에요. 그리고 layout.tsx에서 그 Provider로 children을 감싸면 되죠. Zustand나 Jotai 같은 라이브러리 쓰시면 더 편한데, 이것들도 Client Component에서 쓰셔야 해요. 저는 인증 상태 같은 건 Context로, 복잡한 UI 상태는 Zustand로 관리하고 있어요.
오히려 반대예요! App Router 마이그레이션하면 번들 크기가 평균 20-30% 정도 줄어들어요. Server Component는 클라이언트로 JavaScript를 안 보내거든요. 예를 들어 Pages Router에서 데이터 페칭 라이브러리나 무거운 유틸 함수들이 다 번들에 포함됐었는데, App Router에서는 서버에서만 돌아가니까 클라이언트 번들이 가벼워지는 거죠. 제 경우엔 메인 페이지 번들이 250KB에서 170KB로 줄었어요. 단, 'use client'를 남발하면 효과가 없으니까 정말 필요한 곳에만 써야 해요. 처음에 저는 습관적으로 막 붙였다가 나중에 다 빼는 작업을 했거든요.
/pages/api 폴더의 API Routes는 그대로 두셔도 완전 잘 작동해요. 굳이 바꿀 필요가 없어요. 하지만 Next.js App Router 마이그레이션하면서 새로운 기능을 쓰고 싶다면 /app/api 폴더에 route.ts 파일로 만들 수 있어요. Route Handlers라고 부르는데, Request/Response 객체를 직접 다뤄서 더 유연하고, streaming 응답도 지원하거든요. GET, POST 같은 HTTP 메소드를 export하는 방식이에요. 저는 레거시 API는 그대로 두고, 새로 만드는 API만 Route Handlers로 작성하고 있어요. 둘 다 섞어 써도 문제없어요.
✨ 마무리하며
여기까지 Next.js App Router 마이그레이션에 대해 정말 자세하게 알아봤어요. 솔직히 처음에는 복잡해 보이는데, 하나하나 단계별로 접근하면 생각보다 어렵지 않거든요. 특히 Pages Router랑 같이 쓸 수 있다는 게 진짜 큰 장점이에요. 급하게 한 번에 다 바꿀 필요 없이 천천히 마이그레이션할 수 있으니까요.
제가 직접 겪어본 결과, App Router 마이그레이션하고 나면 코드가 훨씬 깔끔해지고 성능도 좋아져요. Server Component 덕분에 클라이언트 번들도 가벼워지고, 데이터 페칭 로직도 단순해지고요. 물론 처음엔 'use client' 언제 붙여야 하는지, Server Component에서 뭘 못 쓰는지 헷갈리실 거예요. 근데 한두 번 해보면 금방 익숙해져요.
혹시 Next.js App Router 마이그레이션 과정에서 막히는 부분 있으면 댓글로 물어보세요! 제가 겪었던 삽질을 여러분은 안 했으면 좋겠거든요. 그리고 마이그레이션 성공하시면 후기도 공유해주시면 정말 감사하겠어요. 여러분도 한번 도전해보세요. 생각보다 재밌고 보람 있을 거예요!
댓글 0개
첫 번째 댓글을 남겨보세요!