Next.js App Router 마이그레이션 삽질 기록 — Pages Router에서 넘어온 이야기
예상 2주, 실제 6주의 여정
2년 전 Pages Router로 구축한 프로젝트가 있었다. 앱이 커지면서 Next.js팀이 App Router를 출시했고, 소문은 좋았다. "더 빠르고", "더 유연하고", "더 현대적이라고". 그래서 마이그레이션을 결정했다. 예상은 2주였다. 현실은 6주였다.
그 과정에서 배운 게 많다. 단순히 파일을 옮기는 게 아니라, 프레임워크의 철학 자체가 바뀌었기 때문이다. 이 글에서 그 여정을 공유하고 싶다.
준비 단계: 뭐가 달라졌는가
먼저 Pages Router와 App Router의 근본적인 차이를 이해해야 한다.
<?xml version="1.0"?>
// Pages Router (old)
// src/pages/users/[id].tsx
export default function UserPage({ user }: Props) {
return <div>{user.name}</div>
}
export async function getServerSideProps(context) {
const user = await fetchUser(context.params.id)
return { props: { user } }
}
// App Router (new)
// src/app/users/[id]/page.tsx
export default function UserPage({ params }: Props) {
// 서버 컴포넌트가 기본
const user = await fetchUser(params.id)
return <div>{user.name}</div>
}
가장 큰 차이는 서버 컴포넌트가 기본이라는 것이다. Pages Router에선 모든 컴포넌트가 클라이언트 컴포넌트였다. App Router는 반대다. 기본적으로 서버에서 렌더링되고, 필요한 부분만 'use client'로 클라이언트 컴포넌트로 만든다.
이게 처음엔 이해가 안 된다. 왜 서버 컴포넌트를 쓰나? 왜 이렇게 복잡하게 만들었나? 하지만 사용해보면 알 수 있다. 이건 정말 강력한 패러다임이다.
파일 구조 재설계
App Router에서는 파일 구조도 바뀐다.
<?xml version="1.0"?>
// 구 구조 (Pages Router)
src/
pages/
index.tsx
users/
[id].tsx
api/
users/
[id].ts
components/
styles/
// 새 구조 (App Router)
src/
app/
layout.tsx # 전체 레이아웃
page.tsx # 홈페이지
users/
layout.tsx # 사용자 섹션 레이아웃
page.tsx # /users 페이지
[id]/
page.tsx # /users/[id] 페이지
layout.tsx # 해당 페이지 레이아웃
api/
users/
[id]/
route.ts # 이제 route.ts
components/
lib/
우리 프로젝트는 약 50개 정도의 페이지가 있었다. 각각을 옮기면서 구조를 재설계했다. 이 과정이 생각보다 오래 걸렸다.
첫 번째 난관: 서버 컴포넌트 vs 클라이언트 컴포넌트
App Router로 마이그레이션 중 가장 헷갈렸던 부분이 이거다.
<?xml version="1.0"?>
// 잘못된 접근: 모든 걸 클라이언트 컴포넌트로 만듦
'use client'
export default function UserProfile() {
const [user, setUser] = useState(null)
useEffect(() => {
fetchUser().then(setUser)
}, [])
return <div>{user?.name}</div>
}
// 올바른 접근: 서버 컴포넌트 사용
export default async function UserProfile() {
const user = await fetchUser()
return <div>{user.name}</div>
}
// 혼합: 필요한 부분만 클라이언트 컴포넌트
export default async function UserPage() {
const user = await fetchUser()
return (
<div>
<UserInfo user={user} /> {/* 서버 컴포넌트 */}
<UserActions userId={user.id} /> {/* 클라이언트 컴포넌트 */}
</div>
)
}
'use client'
function UserActions({ userId }: Props) {
const [isLoading, setIsLoading] = useState(false)
// ...
}
처음엔 이해가 안 됐다. 서버 컴포넌트에서 클라이언트 컴포넌트를 쓸 수 있는데, 반대는 왜 안 되나? 이유를 알아보니 이해가 됐다.
클라이언트 컴포넌트에 서버 컴포넌트를 자식으로 전달할 수 없다. 왜냐면 클라이언트에선 async 함수를 실행할 수 없기 때문이다. 하지만 서버 컴포넌트에서 클라이언트 컴포넌트를 import 해서 쓸 수 있다.
<?xml version="1.0"?>
// 이건 작동함
// app/users/[id]/page.tsx (서버 컴포넌트)
import { UserActions } from './user-actions' // 클라이언트 컴포넌트
export default async function UserPage({ params }: Props) {
const user = await fetchUser(params.id)
return (
<div>
<h1>{user.name}</h1>
<UserActions userId={user.id} /> {/* OK */}
</div>
)
}
// 이건 안 됨
// app/users/[id]/user-actions.tsx (클라이언트 컴포넌트)
'use client'
import { UserProfile } from './user-profile' // 서버 컴포넌트
export function UserActions({ userId }: Props) {
return (
<div>
<UserProfile userId={userId} /> {/* 에러! */}
</div>
)
}
우리 프로젝트에서 이 규칙을 깨는 부분이 꽤 많았다. 리팩토링에 2주가 들었다.
두 번째 난관: 캐싱 동작의 완전한 변화
이건 정말 골치 아팠다. Pages Router와 App Router의 캐싱 메커니즘이 완전히 다르다.
<?xml version="1.0"?>
// Pages Router: getServerSideProps로 매 요청마다 데이터 가져옴
export async function getServerSideProps() {
const data = await fetchLatestPosts()
return {
props: { data },
revalidate: 60, // ISR: 60초마다 재생성
}
}
// App Router: fetch 옵션으로 캐싱 제어
export const revalidate = 60 // 페이지 레벨
export default async function PostsPage() {
// 기본: 무기한 캐싱 (영구 캐시)
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 } // fetch 레벨 오버라이드
})
// revalidate 없음 = 캐시 안 함
const latestPost = await fetch('https://api.example.com/posts/latest', {
cache: 'no-store'
})
}
우리 사이트의 경우, 사용자 데이터는 매번 새로 가져와야 했는데, 기본 캐싱 때문에 낡은 데이터를 보여주고 있었다. 문제는 이게 개발 환경에서는 안 나타난다는 거다. 프로덕션 배포 후에야 알 수 있었다.
<?xml version="1.0"?>
// 올바른 설정
export const revalidate = 0 // 이 페이지는 캐시하지 않음
export default async function UserProfile({ params }: Props) {
const user = await fetch(
`https://api.example.com/users/${params.id}`,
{
cache: 'no-store', // 항상 새로 가져옴
}
)
}
// 또는 동적 렌더링 명시
import { headers } from 'next/headers'
export default async function Page() {
const headersList = headers() // 동적 함수 호출
// 동적 함수를 호출하면 이 페이지는 자동으로 동적 렌더링됨
}
이 부분에서 배운 핵심: 기본값이 캐싱이라는 걸 항상 기억해야 한다.
세 번째 난관: API 라우트 변경
API 라우트도 완전히 바뀌었다.
<?xml version="1.0"?>
// Pages Router: src/pages/api/users/[id].ts
export default function handler(req, res) {
if (req.method === 'GET') {
return res.json({ name: 'John' })
}
}
// App Router: src/app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await fetchUser(params.id)
return NextResponse.json({ name: user.name })
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const body = await request.json()
// 처리
return NextResponse.json({ success: true })
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
await deleteUser(params.id)
return NextResponse.json({ success: true })
}
API 라우트는 상대적으로 쉽게 마이그레이션할 수 있었다. 함수명만 HTTP 메서드에 맞추면 된다.
네 번째 난관: Layout의 힘과 위험성
App Router의 Layout은 정말 강력하다. 하지만 제대로 이해하지 못하면 성능 문제가 생긴다.
<?xml version="1.0"?>
// 좋은 예: 레이아웃 분리
// app/layout.tsx (모든 페이지의 루트 레이아웃)
'use client'
export default function RootLayout({ children }: Props) {
return (
<html>
<body>
<Header /> {/* 모든 페이지에 공유됨 */}
{children}
<Footer />
</body>
</html>
)
}
// app/dashboard/layout.tsx (대시보드 섹션 레이아웃)
'use client'
export default function DashboardLayout({ children }: Props) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
)
}
// 나쁜 예: 모든 레이아웃을 클라이언트 컴포넌트로
'use client'
export default function RootLayout({ children }: Props) {
const [user, setUser] = useState(null)
useEffect(() => {
// 모든 페이 전환마다 실행됨. 사용자 경험 최악
fetchUser().then(setUser)
}, [])
}
처음엔 모든 레이아웃을 클라이언트 컴포넌트로 만들었다. 그 결과? 페이지 전환할 때마다 레이아웃이 리마운트되고, useEffect가 다시 실행되고, API 요청이 또 일어났다. 성능이 엔망이었다.
해결책은 서버 레이아웃과 클라이언트 레이아웃을 분리하는 것이다.
<?xml version="1.0"?>
// app/layout.tsx (서버 레이아웃)
export default function RootLayout({ children }: Props) {
return (
<html>
<body>
<ClientLayout> {/* 클라이언트 레이아웃 감싸기 */}
{children}
</ClientLayout>
</body>
</html>
)
}
// app/client-layout.tsx (클라이언트 레이아웃)
'use client'
export function ClientLayout({ children }: Props) {
const [user, setUser] = useState(null)
useEffect(() => {
// 이 부분만 클라이언트에서 실행됨
fetchUser().then(setUser)
}, [])
return (
<div className="wrapper">
<Header user={user} />
{children}
<Footer />
</div>
)
}
마이그레이션 체크리스트
최종적으로 정리한 마이그레이션 체크리스트:
- 모든 페이지를 app 폴더로 옮김
- 각 라우트에 layout.tsx 생성
- getServerSideProps → 서버 컴포넌트로 변경
- getStaticProps → revalidate 옵션으로 변경
- 클라이언트 상태 관리 필요한 부분에만 'use client' 추가
- API 라우트 마이그레이션
- 캐싱 전략 재검토
- 에러 처리 (error.tsx)
- 로딩 상태 (loading.tsx)
- 동적 라우트 관리
성능 개선 결과
마이그레이션이 고통스러웠지만, 결과는 좋았다:
- 초기 로딩 시간: 3.2초 → 1.1초
- 페이지 전환 시간: 800ms → 200ms
- 번들 크기: 450KB → 280KB
- 서버 CPU 사용률: 평균 60% → 35%
마무리
App Router 마이그레이션은 힘들었지만, 프레임워크의 새로운 철학을 이해하는 좋은 기회였다. 처음부터 이렇게 했으면 훨씬 쉬웠을 텐데, 레거시 코드와의 전쟁은 항상 어렵다.
새 프로젝트를 시작한다면, 무조건 App Router를 쓰는 걸 권한다. 익숙해지는 데 시간이 걸리지만, 장기적으로 훨씬 낫다.