Next.js Pages Router에서 App Router로 마이그레이션하는 실전 가이드: 점진적 전략과 함정 체크리스트

기술 총괄이 회의실에서 질문 하나를 던졌습니다. “우리 Next.js 12 프로젝트를 14로 업그레이드할 수 있을까요?”
화면에는 2년 동안 운영해 온 오래된 프로젝트가 떠 있었습니다. 지난번 React 17로 업그레이드했을 때는 버그를 고치는 데 꼬박 일주일이 걸렸고, 고객센터 전화가 폭주했습니다.
그런데 이번에는 왠지 조금 달라 보였습니다. 저녁에 집으로 돌아와 공식 문서를 살펴보다가 App Router의 새로운 기능인 Server Components, 중첩 레이아웃, 더 나은 성능 등을 보니 또 마음이 동했습니다. 하지만 마이그레이션 가이드 페이지를 펼치자 다시 고민이 시작됐습니다. 화면 가득 API 대응표가 있었고, getServerSideProps는 무엇으로 바꿔야 하는지, _app.js는 어떻게 나눠야 하는지 생각하니 머리가 아팠습니다.
더 큰 문제는 공식 문서가 권하는 ‘점진적 마이그레이션’이 듣기에는 훌륭했지만 실제로 해 보니 /pages와 /app 사이를 전환할 때 사용자가 loading 스피너를 보게 되어 오히려 경험이 나빠졌다는 점입니다.
저는 꼬박 2주 동안 시행착오를 겪고, 커뮤니티 토론을 찾아보고, 여러 방식을 시도한 끝에 비교적 믿을 만한 마이그레이션 전략을 정리했습니다. 이 글에서는 다음과 같은 실전 경험을 공유합니다.
- 프로젝트가 마이그레이션할 가치가 있는지 판단하는 방법
- 두 가지 마이그레이션 전략의 실제 장단점(공식 문서의 이론이 아닙니다)
- getServerSideProps 마이그레이션의 자세한 단계와 코드 예제
- 제가 직접 겪은 7가지 큰 함정과 해결 방법
업그레이드할지 고민 중이거나 이미 마이그레이션을 시작했지만 문제를 만났다면, 이 글이 시행착오를 줄이는 데 도움이 되길 바랍니다.
왜 마이그레이션해야 할까? 먼저 손익부터 계산하자
마이그레이션 이야기를 시작하며 먼저 찬물을 끼얹어야겠습니다. 모든 프로젝트가 이런 수고를 들일 만한 것은 아닙니다.
지난달 한 친구가 회사에서 곧 종료할 이벤트 페이지를 App Router로 업그레이드해야 하는지 물었습니다. 저는 바로 말렸습니다. 6개월 뒤면 삭제할 코드를 위해 시간을 낭비할 이유가 없기 때문입니다.
그렇다면 어떤 프로젝트가 마이그레이션할 가치가 있을까요? 몇 가지 판단 기준을 정리해 봤습니다.
중첩 레이아웃 필요성
이것이 우리 팀이 마이그레이션한 가장 큰 이유였습니다. 우리 SaaS 백오피스에는 사이드바, 상단 내비게이션, 콘텐츠 영역으로 이어지는 3단 레이아웃이 있었는데, Pages Router를 사용할 때는 페이지를 전환할 때마다 사이드바 전체가 다시 렌더링됐습니다.
사용자가 새 페이지를 열면 화면 전체가 한 번 깜빡이는 것이 분명히 느껴졌습니다. 네트워크가 느린 것이 아니라 레이아웃을 다시 그리는 문제였습니다.
App Router의 중첩 레이아웃은 이 문제를 완벽하게 해결했습니다. 마이그레이션 후 WorkOS 팀은 “로딩 상태나 레이아웃 흔들림 없이 로그인 경험이 크게 개선됐다”고 보고했습니다. 우리도 직접 테스트해 보니 같았습니다. 사용자가 페이지를 전환할 때 콘텐츠 영역만 업데이트되고 내비게이션 바는 흔들림 없이 그대로 유지됐습니다.
성능 개선 여지
프로젝트의 첫 화면 로딩이 3초를 넘는다면 App Router가 도움이 될 수 있습니다.
우리에게는 상품 목록 페이지가 하나 있었습니다. 이전에는 getServerSideProps로 데이터를 가져왔기 때문에 새로고침할 때마다 서버가 HTML 전체를 렌더링할 때까지 기다려야 했습니다. Server Components로 바꾼 뒤에는 서버에서 목록 데이터를 미리 가져와 클라이언트로 바로 stream할 수 있었고, 첫 화면 시간은 3.2초에서 1.8초로 줄었습니다.
다만 여기에도 함정이 있습니다. 모든 페이지가 빨라지는 것은 아닙니다. 캔버스 편집기처럼 클라이언트 상호작용만으로 이루어진 페이지는 마이그레이션해도 거의 차이가 없고, 추상화 계층이 하나 늘어 오히려 느려질 수도 있습니다.
장기 유지보수 프로젝트
이 프로젝트를 3년 이상 유지해야 한다면 일찍 마이그레이션해 일찍 혜택을 누리는 편이 좋습니다. Vercel은 앞으로 새로운 기능을 App Router에 우선 지원하고 Pages Router는 ‘유지보수 모드’에 들어갔다고 분명히 밝혔습니다.
저는 2년 뒤 또 마이그레이션하고 싶지 않았습니다. 그때는 API가 다시 바뀌어 있을 수 있고, 겪어야 할 문제는 더 많아질 뿐입니다.
마이그레이션을 권하지 않는 경우
다만 다음과 같은 경우라면 서둘러 마이그레이션하지 않는 편을 권합니다.
- 프로젝트가 곧 종료되는 경우 — 필요 없습니다.
- 소규모 정적 사이트(5페이지 이하) — 얻는 것이 너무 적어 수고할 가치가 없습니다.
- 팀이 React 18에 익숙하지 않은 경우 — 먼저 Suspense와 Server Components부터 이해하세요.
- 오래된 서드파티 라이브러리에 크게 의존하는 경우 — 수많은 라이브러리가 호환되지 않는다는 사실을 발견할 수 있습니다.
핵심은 한 문장입니다. 마이그레이션 자체를 목적으로 마이그레이션하지 마세요. 먼저 마이그레이션이 어떤 실제 문제를 해결할 수 있는지 자문해야 합니다. 답이 “없지만 새로운 것을 써 보고 싶다”라면 하지 않는 편이 낫습니다.
우리 팀은 계산해 봤습니다. 2주간의 인력을 투자해 사용자 경험을 개선하고 향후 3년의 기술 부채를 줄일 수 있다면 가치가 있었습니다. 여러분의 프로젝트는 어떤가요?
두 가지 마이그레이션 전략 선택하기
공식 문서는 ‘점진적 마이그레이션’을 권합니다. 천천히 페이지를 하나씩 옮긴다는 점에서 매우 안전해 보입니다.
하지만 직접 시도해 보니 치명적인 문제가 하나 있었습니다.
점진적 마이그레이션의 함정
이런 상황을 상상해 보세요. 홈페이지는 /app 디렉터리로 옮겼지만 상품 상세 페이지는 아직 /pages에 있습니다. 사용자가 홈페이지에서 상품 페이지를 클릭하자 갑자기 흰 화면과 스피너가 나타나고, 한참 뒤에야 콘텐츠가 표시됩니다.
왜 그럴까요? 페이지가 App Router에서 Pages Router로 이동하면 Next.js는 둘을 독립적인 애플리케이션으로 취급하므로 JavaScript bundle 전체를 다시 로드해야 합니다. 사용자 경험은 순식간에 2010년으로 돌아갑니다.
WorkOS 팀도 블로그에서 이 문제를 불평했습니다. “서로 다른 라우터 사이를 이동하는 것은 관련 없는 두 애플리케이션 사이를 오가는 것과 같다.” 그들도 원래 점진적 마이그레이션을 계획했지만 결국 포기했습니다.
그렇다면 점진적 마이그레이션은 전혀 쓸 수 없을까요? 그렇지는 않습니다.
점진적 방식이 적합한 경우:
- 페이지 간 결합도가 매우 낮음(예: 블로그처럼 글 사이의 연관이 크지 않은 경우)
- 모듈 단위로 완전히 마이그레이션할 수 있음(예: 사용자 센터 모듈 전체를 먼저 옮긴 뒤 상품 모듈을 옮기는 방식)
- 전환 시 loading 상태를 허용할 수 있음
기술 블로그가 이 방식으로 무리 없이 마이그레이션하는 사례를 본 적은 있습니다. 하지만 SaaS 제품이나 전자상거래 사이트라면 기대하지 않는 편이 좋습니다.
WorkOS의 무중단 방식
그렇다면 복잡한 프로젝트는 어떻게 해야 할까요? WorkOS는 영리한 방법을 제시했습니다.
그들은 /app 아래에 임시 디렉터리 /app/new를 만들고 모든 페이지를 그곳에서 다시 작성한 뒤, 쿼리 매개변수로 어느 버전에 접근할지 제어했습니다.
다소 복잡하게 들리지만 코드를 보면 바로 이해할 수 있습니다.
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/:path*',
destination: '/new/:path*',
has: [
{
type: 'query',
key: 'new',
value: 'true',
},
],
},
]
},
}
이렇게 하면 일반 사용자가 /dashboard에 접근할 때는 여전히 이전 버전을 사용하지만 ?new=true를 붙이면 새 버전을 볼 수 있습니다.
테스트, 제품, 디자인 담당자는 프로덕션 환경에서 미리 새 버전을 검증할 수 있고 사용자는 아무런 변화도 느끼지 못합니다. 새 버전 테스트가 끝나 문제가 없으면 /app/new 전체를 /app으로 바꾸고 /pages를 삭제하면 끝입니다.
우리 팀도 이 방식을 사용했습니다. 마이그레이션 과정에서 사용자는 버그를 하나도 겪지 않았습니다. 정식 출시 전에 실제 데이터로 일주일 동안 테스트했기 때문입니다.
구체적인 단계:
- Next.js를 14로 업그레이드 — /pages는 그대로 둔 채 프레임워크 버전만 먼저 올립니다.
- 라우팅 hook 마이그레이션 —
next/router를next/navigation으로 바꿔 코드가 두 라우터 모두와 호환되게 합니다. - /app/new 디렉터리 생성 — 이곳에서 페이지 구조를 다시 만듭니다.
- 기존 컴포넌트 재사용 — /pages의 React 컴포넌트를 직접 import하므로 다시 작성할 필요가 없습니다.
- rewrites 구성 — 앞의 설정을 추가해
?new=true로 전환합니다. - 내부 테스트와 단계적 적용 — 팀이 새 버전을 사용하며 문제를 발견하는 즉시 수정합니다.
- 정식 출시 — /app/new를 /app으로 옮기고 rewrites와 /pages를 삭제합니다.
우리 팀은 1단계부터 7단계까지 총 10영업일이 걸렸습니다. 페이지를 다시 작성하는 데 6일, 버그 수정에 3일, 마지막 출시에 1일을 썼습니다.
제안
프로젝트 조건에 따라 다음처럼 선택하세요.
- 10페이지 미만이고 각 페이지가 독립적임 → 점진적 마이그레이션
- 10페이지가 넘고 사용자 경험 요구가 높음 → 무중단 방식
- 신규 프로젝트 → 처음부터 App Router를 사용하세요.
새 기능을 개발하면서 동시에 마이그레이션하려고 하지 마세요. 저도 시도해 봤지만 결국 양쪽 코드 스타일이 완전히 달라져 보기 불편했습니다. 2주를 집중해 끝내거나, 아니면 당분간 손대지 않는 편이 낫습니다.
getServerSideProps 마이그레이션 실전
가장 많이 받은 질문은 이것입니다. “getServerSideProps를 쓸 수 없다면 데이터는 어떻게 가져오나요?”
사실 App Router의 데이터 조회 방식이 더 단순합니다. 다만 사고방식을 바꿔야 합니다.
‘분리’에서 ‘통합’으로
Pages Router의 로직은 데이터 조회(getServerSideProps)와 UI(컴포넌트)를 따로 작성하고, Next.js가 서버에서 데이터 함수를 호출한 뒤 결과를 컴포넌트에 전달하는 방식입니다.
App Router는 이 방식을 사용하지 않습니다. 페이지 컴포넌트 자체가 async 함수이므로 그 안에서 데이터를 직접 가져옵니다.
// ❌ 이전 방식: pages/project/[id].tsx
export async function getServerSideProps(context) {
const { id } = context.params
const res = await fetch(`https://api.example.com/projects/${id}`)
const project = await res.json()
return {
props: { project }
}
}
export default function ProjectPage({ project }) {
return <h1>{project.title}</h1>
}
// ✅ 새 방식: app/project/[id]/page.tsx
export default async function ProjectPage({ params }) {
const { id } = params
const res = await fetch(`https://api.example.com/projects/${id}`, {
cache: 'no-store' // 핵심! getServerSideProps와 같은 동작
})
const project = await res.json()
return <h1>{project.title}</h1>
}
훨씬 간단해 보이지 않나요? 하지만 아직 안심하면 안 됩니다. 여기에는 두 가지 큰 함정이 있습니다.
함정 1: 잘못된 cache 설정
기본적으로 App Router의 fetch는 캐시됩니다(getStaticProps와 같음). 요청할 때마다 새 데이터를 가져오는 것이 아닙니다.
처음 마이그레이션할 때 이 점을 놓쳤습니다. 상품 가격 페이지를 옮겼는데 가격이 전혀 업데이트되지 않았습니다. 사용자가 “분명 가격이 내려갔는데 페이지에는 아직 원래 가격이 나온다”고 불만을 제기했고, 한참을 조사한 끝에 캐시가 원인임을 발견했습니다.
다음 대응표를 기억하세요:
getServerSideProps→cache: 'no-store'getStaticProps→cache: 'force-cache'(기본 동작)getStaticProps + revalidate→next: { revalidate: 60 }
함정 2: 클라이언트 상태는 어떻게 할까
기존에 getServerSideProps를 사용하던 페이지에는 필터링이나 정렬 같은 클라이언트 상호작용이 함께 있는 경우가 많습니다.
App Router로 옮기고 나면 async Server Component에서는 useState, useEffect 같은 hook을 사용할 수 없다는 사실을 알게 됩니다.
어떻게 해야 할까요? 컴포넌트를 분리하면 됩니다.
// app/products/page.tsx (Server Component)
export default async function ProductsPage() {
const products = await fetchProducts() // 서버에서 데이터 조회
return <ProductList initialData={products} /> // Client Component에 전달
}
// components/ProductList.tsx (Client Component)
'use client' // 이 줄에 주의!
import { useState } from 'react'
export function ProductList({ initialData }) {
const [products, setProducts] = useState(initialData)
const [filter, setFilter] = useState('')
// 클라이언트 필터링 로직
const filtered = products.filter(p => p.name.includes(filter))
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
{filtered.map(p => <ProductCard key={p.id} product={p} />)}
</div>
)
}
이렇게 하면 서버는 데이터 조회를, 클라이언트는 상호작용을 맡아 역할이 명확해집니다.
하지만 ‘use client’를 남용하지 마세요. 페이지 전체에 ‘use client’를 표시한 사례를 본 적이 있는데, 그렇게 하면 Server Components를 사용하는 의미가 사라집니다.
실제 마이그레이션 단계
저는 2단계 과정을 정리했습니다.
1단계: 컴포넌트 분리
먼저 기존 pages 디렉터리에서 컴포넌트를 ‘순수 표시 영역’과 ‘상태가 있는 영역’으로 나누고 테스트를 통과시킵니다.
2단계: app 디렉터리로 이동
- 순수 표시 영역을 app/[route]/page.tsx에 넣고 async로 표시한 뒤 그 안에서 데이터를 가져옵니다.
- 상태가 있는 영역을 별도 파일로 추출하고 ‘use client’를 추가합니다.
- getServerSideProps 코드를 삭제합니다.
이 방식의 장점은 문제가 생겨도 빠르게 롤백할 수 있고 양쪽 코드가 모두 엉키지 않는다는 것입니다.
한 가지 세부 사항
기존에 context.req.cookies로 사용자 신원을 읽었다면 이제 다음처럼 바꿉니다.
import { cookies } from 'next/headers'
export default async function Page() {
const cookieStore = cookies()
const token = cookieStore.get('auth-token')
// token으로 사용자 데이터 요청...
}
이와 비슷하게 headers(), redirect() 등도 next/headers나 next/navigation에서 import합니다. 공식 문서에 전체 목록이 있으므로 여기서는 반복하지 않겠습니다.
자주 만나는 7가지 큰 함정과 해결 방법
이제 핵심 내용입니다. 다음 7가지 함정을 저는 모두 겪었고, 하나하나 디버깅하는 데 최소 한 시간씩 썼습니다.
함정 1: 서버 오류가 가려짐
증상: 페이지가 렌더링되지 않고 오류도 표시되지 않은 채 loading 스켈레톤 화면이나 빈 화면만 나옵니다.
제 경험: API 호출을 한번 수정했는데 페이지가 완전히 비어 버린 적이 있습니다. 콘솔을 열어도 오류가 하나도 없었습니다. 데이터가 반환되지 않았나 싶어 console.log를 여러 개 추가했지만 문제를 찾지 못했습니다.
결국 서버에서 예외가 발생했지만 error.tsx를 구성하지 않아 Next.js가 오류를 가리고 Suspense의 fallback만 표시한다는 사실을 발견했습니다.
해결 방법: 각 라우트 디렉터리에 error.tsx를 추가합니다.
// app/dashboard/error.tsx
'use client'
export default function Error({ error, reset }) {
return (
<div>
<h2>오류가 발생했습니다: {error.message}</h2>
<button onClick={reset}>다시 시도</button>
</div>
)
}
이 파일을 추가하면 적어도 오류 메시지를 볼 수 있습니다. 개발 환경에서는 Next.js가 자세한 스택을 표시하고 프로덕션 환경에서는 친절한 오류 안내를 표시합니다.
함정 2: useRouter가 동작하지 않음
증상: useRouter().push()로 이동하지 않거나 특정 메서드가 없다는 오류가 발생합니다.
원인: next/router와 next/navigation은 서로 호환되지 않는 별개의 API입니다.
처음에는 import 경로만 바꾸면 된다고 생각했습니다.
// ❌ 잘못된 방식
import { useRouter } from 'next/navigation'
const router = useRouter()
router.push('/dashboard') // push 메서드가 없음!
나중에야 next/navigation의 useRouter에는 push 메서드가 없고 별도의 함수를 써야 한다는 것을 알았습니다.
// ✅ 올바른 방식
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
const router = useRouter()
router.push('/dashboard') // 실제로는 있지만 동작이 다름
// 또는 Link 컴포넌트를 직접 사용
import Link from 'next/link'
<Link href="/dashboard">이동</Link>
대응표(마이그레이션할 때 계속 보려고 컴퓨터 옆에 붙여 뒀습니다):
| Pages Router | App Router |
|---|---|
useRouter().push(url) | useRouter().push(url) (있지만 권장하지 않음) |
useRouter().pathname | usePathname() |
useRouter().query | useSearchParams() |
useRouter().asPath | usePathname() + useSearchParams() |
함정 3: 동적 import 실패
증상: next/dynamic으로 import한 컴포넌트가 렌더링되지 않고 콘솔에 “You’re importing a component that needs useState. It only works in a Client Component…”라는 오류가 나타납니다.
원인: Server Component는 기본적으로 서버에서 렌더링되므로 차트 라이브러리 같은 일부 client-only 라이브러리에서 오류가 발생합니다.
이전에 ECharts를 사용한 차트 페이지가 있었습니다.
// ❌ 이렇게 하면 오류 발생
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), { ssr: false })
export default function Page() {
return <Chart data={data} />
}
Chart 컴포넌트에는 window 객체가 필요하지만 서버에는 없다는 오류가 발생했습니다.
해결 방법: page.tsx에 ‘use client’를 추가하거나 Chart를 별도의 Client Component로 추출합니다.
// app/charts/page.tsx
import { ClientChart } from './ClientChart'
export default function Page() {
return <ClientChart />
}
// app/charts/ClientChart.tsx
'use client'
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), { ssr: false })
export function ClientChart() {
return <Chart data={data} />
}
함정 4: 페이지 전환 시 깜빡임
증상: 링크를 클릭해 이동하면 페이지 전체가 다시 렌더링되며 상단 내비게이션 바와 사이드바까지 깜빡입니다.
원인: layout.tsx 설정이 잘못됐거나 layout을 아예 사용하지 않았기 때문입니다.
App Router의 핵심 장점은 중첩 레이아웃인데, 처음에는 이를 제대로 활용하지 않고 내비게이션 바를 각 page.tsx에 직접 작성했습니다. 당연히 깜빡일 수밖에 없었습니다.
올바른 방식:
// app/layout.tsx (모든 페이지가 공유하는 루트 레이아웃)
export default function RootLayout({ children }) {
return (
<html>
<body>
<Header /> {/* 상단 내비게이션은 절대 다시 렌더링되지 않음 */}
{children}
</body>
</html>
)
}
// app/dashboard/layout.tsx (대시보드 레이아웃)
export default function DashboardLayout({ children }) {
return (
<div className="flex">
<Sidebar /> {/* 대시보드 안의 페이지를 전환할 때 사이드바는 다시 렌더링되지 않음 */}
<main>{children}</main>
</div>
)
}
이렇게 하면 사용자가 /dashboard/analytics와 /dashboard/settings 사이를 전환할 때 main 영역만 업데이트되고 사이드바는 전혀 움직이지 않습니다.
함정 5: 404 페이지가 동작하지 않음
증상: 사용자 정의 404 페이지가 표시되지 않고 Next.js의 기본 페이지가 계속 나옵니다.
원인: Pages Router의 404.js와 App Router의 not-found.tsx가 충돌했기 때문입니다.
마이그레이션할 때 /pages/404.js가 남아 있어 /app/not-found.tsx가 동작하지 않았습니다.
해결 방법: /pages/404.js와 /pages/500.js를 삭제하고 App Router의 규칙을 사용합니다.
// app/not-found.tsx
export default function NotFound() {
return <h1>페이지가 존재하지 않습니다</h1>
}
page.tsx에서 직접 404를 발생시키는 방법은 다음과 같습니다.
import { notFound } from 'next/navigation'
export default async function Page({ params }) {
const data = await fetchData(params.id)
if (!data) {
notFound() // 404 발생
}
return <div>{data.title}</div>
}
함정 6: 개발 서버가 갈수록 느려짐
증상: 처음 시작할 때는 괜찮지만 코드를 몇 번 수정하고 나면 hot reload에 10초가 걸리고, 나중에는 그대로 충돌합니다.
솔직히 말하면: 저도 이 문제를 완벽하게 해결하지 못했습니다.
이는 Next.js 14의 알려진 문제입니다. FlightControl 팀은 블로그에서 “dev server 성능이 너무 나빠서 이를 피할 수만 있다면 모든 새 기능을 포기할 수도 있다”고 불평했습니다. 그들은 20분마다 개발 서버를 재시작해야 한다고 했습니다.
우리 팀의 경험도 비슷했습니다.
임시 해결책:
- dev server를 주기적으로 재시작합니다(저는 15분 알림을 설정했습니다).
next dev --turbo로 실험적인 Turbopack을 활성화합니다(조금 빠르지만 가끔 버그가 있습니다).- 불필요한 Server Component를 줄입니다. 일부 페이지에는 Client Component만으로도 충분합니다.
Next.js 15에서는 이 문제가 개선됐다고 하지만 아직 사용해 보지는 않았습니다.
함정 7: 서드파티 라이브러리 비호환
증상: 일부 애니메이션 라이브러리(Framer Motion, Lottie)에서 window나 document를 찾을 수 없다는 오류가 발생합니다.
원인: 이 라이브러리들은 클라이언트 전용이므로 Server Component에서 사용할 수 없습니다.
이전에 Framer Motion으로 페이지 전환 애니메이션을 구현했는데 마이그레이션 후 모두 망가졌습니다.
해결 방법:
- 이 라이브러리를 사용하는 컴포넌트를 ‘use client’로 감쌉니다.
- 라이브러리의 새 버전이 React 18을 지원하는지 확인합니다(이미 호환된 라이브러리도 있습니다).
- 그래도 안 되면 SSR을 지원하는 라이브러리로 바꿉니다.
다음과 같은 대표적인 client-only 라이브러리는 특히 주의하세요.
- Framer Motion(App Router에서 페이지 이탈 애니메이션에 문제가 있으며 공식 issue에서 아직 논의 중)
- swiper, slick-carousel 등의 캐러셀 라이브러리
- 각종 차트 라이브러리(ECharts, Chart.js 등)
- 드래그 앤 드롭 라이브러리(react-dnd, dnd-kit 등)
프로젝트가 이런 라이브러리에 크게 의존한다면 마이그레이션 전에 GitHub issue를 확인해 호환성을 점검하세요.
마이그레이션 후 최적화 제안
마이그레이션 완료가 끝은 아닙니다. 아직 최적화할 여지가 많습니다.
클라이언트 JavaScript 줄이기
이는 Server Components의 가장 큰 장점입니다.
기존 상품 목록 페이지는 React 컴포넌트만 gzip 후 120KB였습니다. 마이그레이션 후 데이터 표시 영역을 Server Component로 바꾸고 필터링과 정렬만 Client Component로 만들자 bundle size가 45KB로 줄었습니다.
확인 방법:
npm run build
출력에서 어떤 페이지에 (Static) 또는 (SSR)이 표시되는지, 어떤 페이지가 ○(Client Component를 사용한다는 의미)로 표시되는지 확인하세요. 화면이 온통 ○라면 ‘use client’를 너무 많이 사용했을 가능성이 있습니다.
최적화 팁:
- 정적 콘텐츠(텍스트, 이미지)는 Server Component에 둡니다.
- 상호작용 컴포넌트(폼, 버튼)는 Client Component로 만듭니다.
- 페이지 전체에 ‘use client’를 표시하지 말고 필요한 하위 컴포넌트에만 표시합니다.
캐시를 적절히 사용하기
App Router의 캐시 전략은 Pages Router보다 훨씬 복잡합니다.
// 캐시하지 않고 요청할 때마다 새 데이터 조회(실시간 데이터에 적합)
fetch(url, { cache: 'no-store' })
// 60초 동안 캐시한 뒤 재검증(자주 업데이트되지만 실시간일 필요는 없는 데이터에 적합)
fetch(url, { next: { revalidate: 60 } })
// 영구 캐시(변하지 않는 정적 데이터에 적합)
fetch(url, { cache: 'force-cache' })
우리 상품 목록에는 60초 revalidate를 사용했습니다. 데이터가 너무 오래되지 않게 하면서 서버 부담도 줄였습니다. 출시 후 API 호출량은 60% 감소했습니다.
성능 모니터링
마이그레이션 전후에 다음 지표를 비교하세요.
- 첫 콘텐츠 표시 시간(FCP) - 사용자가 첫 번째 콘텐츠를 보는 데 걸리는 시간
- 상호작용 가능 시간(TTI) - 페이지가 완전히 상호작용 가능한 상태가 되는 데 걸리는 시간
- 누적 레이아웃 이동(CLS) - 페이지 로딩 중 레이아웃이 흔들리는 정도
우리는 Vercel Analytics로 모니터링했고, 마이그레이션 후 FCP가 3.2초에서 1.8초로, TTI가 5.1초에서 3.3초로 줄어든 것을 확인했습니다.
하지만 모든 페이지가 빨라지는 것은 아닙니다. 우리 캔버스 편집기처럼 클라이언트 상호작용만으로 이루어진 페이지는 마이그레이션 후에도 거의 차이가 없었습니다.
과도한 최적화에 주의하기
Server Component를 사용하려고 억지로 컴포넌트를 나누지 마세요.
이전에 “더 세밀하게 나눌수록 Server Component의 비중이 커질 것”이라고 생각해 폼 하나를 20개의 작은 컴포넌트로 나눈 적이 있습니다. 그 결과 코드 가독성이 나빠지고 동료들이 이해하지 못해 유지보수 비용이 오히려 늘었습니다.
경험 법칙: 컴포넌트에 useState/useEffect가 필요하다면 고민하지 말고 바로 ‘use client’를 표시하세요. Server Components는 도구이지 KPI가 아닙니다.
결론
지금까지의 핵심을 정리해 보겠습니다.
유행을 따라가기 위해 마이그레이션하는 것이 아닙니다. 실제 문제를 해결하기 위한 일입니다. 프로젝트에 중첩 레이아웃이 필요하거나 클라이언트 JS를 줄이고 싶거나 장기 유지보수해야 한다면 App Router에 시간을 투자할 가치가 있습니다.
올바른 마이그레이션 전략을 선택하세요. 소규모 프로젝트는 점진적으로 옮기고, 대규모 프로젝트는 무중단 방식으로 한 번에 전환하세요. 개발과 마이그레이션을 동시에 진행하면 매우 혼란스러워집니다.
문제를 겪는 것은 정상입니다. 제가 소개한 7가지 함정은 빙산의 일각일 뿐입니다. 문제가 생기면 먼저 GitHub에서 issue를 검색해 보세요. 90%의 문제는 이미 누군가 겪었습니다.
과도하게 최적화하지 마세요. Server Components는 도구이지 목적이 아닙니다. 코드 가독성과 팀 효율이 bundle size보다 중요합니다.
마이그레이션에 관한 제안은 이렇습니다. 먼저 1~2개 페이지로 시범 적용해 전체 과정을 검증하고 경험을 정리한 뒤 전면적으로 진행하세요. 우리 팀도 그렇게 했습니다. 첫 페이지에는 3일이 걸렸지만 방법을 익힌 뒤 나머지 10페이지는 5일 만에 끝냈습니다.
마지막으로 Next.js App Router에는 분명 문제가 적지 않으며, 특히 dev server 성능 문제가 큽니다. 하지만 전체적인 방향은 옳습니다. 생태계가 성숙하면 이런 문제도 차차 해결될 것입니다.
마이그레이션 과정에서 문제를 만났다면 댓글로 이야기해 주세요. 저도 같은 문제를 겪었을지 모릅니다.
관련 자료:
마이그레이션이 순조롭게 진행되길 바랍니다!
Next.js Pages Router에서 App Router로 마이그레이션하는 전체 과정
평가부터 출시까지 이어지는 전체 마이그레이션 단계와 두 가지 전략 선택법, 자주 발생하는 문제의 해결법
⏱️ Estimated time: 80 hr
- 1
Step 1: 프로젝트가 마이그레이션할 가치가 있는지 평가하기
판단 기준:
• 중첩 레이아웃 필요성: 여러 단계의 레이아웃이 필요하고 페이지 전환 시 다시 렌더링하고 싶지 않은가
• 성능 개선 여지: 첫 화면 로딩이 3초를 넘어 개선할 여지가 있는가
• 장기 유지보수: 프로젝트를 3년 이상 유지할 예정이라면 일찍 마이그레이션해 혜택을 누릴 수 있는가
마이그레이션을 권하지 않는 경우:
• 프로젝트가 곧 종료됨
• 소규모 정적 사이트(5페이지 이하)
• 팀이 React 18에 익숙하지 않음
• 오래된 서드파티 라이브러리에 크게 의존함 - 2
Step 2: 마이그레이션 전략 선택하기
프로젝트 규모에 따라 선택합니다.
소규모 프로젝트(<10페이지, 각 페이지가 독립적) → 점진적 마이그레이션:
• 페이지를 하나씩 마이그레이션
• 전환 시 loading 상태를 허용할 수 있음
• 블로그처럼 페이지 간 결합도가 낮은 프로젝트에 적합
대규모 프로젝트(>10페이지, 높은 사용자 경험 요구) → 무중단 방식:
• /app/new 디렉터리에서 모든 페이지를 다시 구축
• rewrites와 쿼리 매개변수로 버전 전환 제어
• 내부 테스트 통과 후 정식 출시 - 3
Step 3: getServerSideProps 마이그레이션하기
단계:
1. 페이지 컴포넌트를 async 함수로 변경
2. 컴포넌트 안에서 직접 fetch로 데이터 요청
3. 올바른 cache 옵션 설정:
• getServerSideProps → cache: 'no-store'
• getStaticProps → cache: 'force-cache'
• getStaticProps + revalidate → next: { revalidate: 60 }
4. 클라이언트 상호작용 부분을 Client Component로 분리:
• 서버에서 데이터를 가져와 Client Component에 전달
• Client Component가 useState, useEffect 등의 상호작용 로직을 처리 - 4
Step 4: 라우팅과 내비게이션 처리하기
라우팅 관련 코드를 업데이트합니다.
• next/router → next/navigation
• useRouter().pathname → usePathname()
• useRouter().query → useSearchParams()
• router.push() 대신 Link 컴포넌트 사용
주의: next/navigation의 useRouter는 Pages Router와 동작이 다르므로 Link 컴포넌트를 직접 사용하는 편을 권합니다. - 5
Step 5: 레이아웃 시스템 구성하기
중첩 레이아웃을 활용해 페이지 전환 시 깜빡임을 방지합니다.
• app/layout.tsx를 루트 레이아웃(Header, Footer)으로 생성
• 기능 영역별 하위 레이아웃 생성(예: app/dashboard/layout.tsx)
• 각 layout에는 해당 계층에만 필요한 UI 요소 추가
• 하위 레이아웃은 상위 레이아웃을 자동으로 상속하며 전환 시 다시 렌더링되지 않음 - 6
Step 6: 오류와 404 처리하기
오류 처리:
• error.tsx를 생성해 오류를 포착하고 친절한 안내 표시
• error 컴포넌트에 'use client' 표시
404 처리:
• /pages/404.js 삭제
• app/not-found.tsx 생성
• page.tsx에서 notFound() 함수로 404 발생 - 7
Step 7: 테스트와 최적화하기
테스트 항목:
• 모든 페이지 라우트가 정상인지 테스트
• 데이터 조회가 올바른지 확인
• 클라이언트 상호작용이 정상인지 점검
• 레이아웃 전환 시 깜빡임이 없는지 확인
성능 최적화:
• 불필요한 'use client' 표시 줄이기
• 캐시 전략을 적절히 사용
• FCP, TTI, CLS 등의 지표 모니터링
• 마이그레이션 전후의 성능 데이터 비교
FAQ
점진적 마이그레이션과 무중단 마이그레이션은 무엇이 다른가요?
무중단 마이그레이션은 /app/new 디렉터리에서 모든 페이지를 다시 구축하고 쿼리 매개변수로 버전 전환을 제어한 뒤, 테스트를 통과하면 정식 출시하는 방식입니다. 대규모 프로젝트이면서 사용자 경험 요구가 높은 경우에 적합합니다.
getServerSideProps를 마이그레이션한 뒤 데이터는 어떻게 가져오나요?
cache 옵션 설정에 주의하세요.
• getServerSideProps는 cache: 'no-store'에 해당합니다.
• getStaticProps는 cache: 'force-cache'에 해당합니다.
페이지에 클라이언트 상호작용이 있다면 Server Component(데이터 조회)와 Client Component(상호작용 처리)로 분리해야 합니다.
마이그레이션 후 페이지를 전환할 때 왜 깜빡이나요?
App Router에서 useRouter는 어떻게 사용하나요?
마이그레이션 후 서드파티 라이브러리가 호환되지 않으면 어떻게 하나요?
자주 호환 문제가 생기는 라이브러리는 다음과 같습니다.
• Framer Motion
• 차트 라이브러리(ECharts, Chart.js)
• 캐러셀 라이브러리 등
마이그레이션 전에 라이브러리의 GitHub issue를 확인해 호환 여부를 점검하는 편이 좋습니다.
개발 서버가 느려지는 문제는 어떻게 해결하나요?
임시 해결책:
• dev server를 주기적으로 재시작(15분 권장)
• next dev --turbo로 Turbopack 활성화
• 불필요한 Server Component 줄이기
Next.js 15에서는 이 문제가 개선되었다고 합니다.
마이그레이션에는 시간이 얼마나 걸리나요?
• 소규모 프로젝트(<10페이지)는 3~5일 정도 걸릴 수 있습니다.
• 대규모 프로젝트는 2~3주가 걸릴 수 있습니다.
먼저 1~2개 페이지로 시범 적용해 전체 과정을 검증한 뒤 전면적으로 진행하는 편을 권합니다. 우리 팀은 첫 페이지에 3일이 걸렸지만 이후 10페이지는 5일 만에 끝냈습니다.
4분 읽기 · 게시일: 2025년 12월 18일 · 수정일: 2026년 9월 4일
Next.js 완전 가이드
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Next.js 15 실전: 주말 하나로 프로덕션급 블로그 시스템을 만든 과정
Next.js 15, Server Actions, Prisma 실전 사례를 통해 주말 동안 프로덕션급 풀스택 블로그 시스템을 구축하는 방법을 단계별로 알아봅니다. 전체 코드와 시행착오, 성능 최적화 전략도 함께 다룹니다.
45편 중 2편
다음
Next.js 고급 라우팅 실전: 라우트 그룹, 중첩 레이아웃, 병렬 라우트, 인터셉팅 라우트 완벽 가이드
Next.js의 네 가지 고급 라우팅 기능을 깊이 있게 설명합니다. 라우트 그룹으로 디렉터리를 정리하고, 중첩 레이아웃을 유연하게 재사용하며, 병렬 라우트로 여러 페이지를 동시에 표시하고, 인터셉팅 라우트로 모달을 우아하게 구현하는 방법을 다룹니다. 완전한 코드 예제와 실수 방지 가이드를 통해 프로젝트가 커진 뒤 발생하는 라우팅 혼란과 협업 충돌 문제를 해결합니다.
45편 중 4편



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