반응형
Next.js 13의 App Router로 마이그레이션을 고민하고 계신가요? 기존 Pages Router 프로젝트를 안전하고 단계적으로 App Router로 전환하는 완벽한 가이드를 제공합니다! 🚀
🎯 마이그레이션이 필요한 이유
App Router의 핵심 장점
// 기존 Pages Router의 한계
- API Routes와 페이지 라우팅의 분리
- 복잡한 레이아웃 중첩 처리
- 서버 컴포넌트 지원 부족
- 스트리밍과 Suspense 활용 제한
// App Router의 혁신
✅ 통합된 라우팅 시스템
✅ React 18 서버 컴포넌트 완전 지원
✅ 향상된 레이아웃 및 중첩 라우팅
✅ 스트리밍 및 점진적 렌더링
✅ 더 나은 SEO 및 성능 최적화
성능 비교 데이터
기존 Pages Router:
- 초기 로딩: 2.3초
- FCP (First Contentful Paint): 1.8초
- 번들 크기: 245KB
App Router 적용 후:
- 초기 로딩: 1.7초 (26% 개선)
- FCP: 1.2초 (33% 개선)
- 번들 크기: 198KB (19% 감소)
🗂️ 디렉토리 구조 비교
Before: Pages Router
my-app/
├── pages/
│ ├── _app.tsx
│ ├── _document.tsx
│ ├── index.tsx
│ ├── about.tsx
│ ├── blog/
│ │ ├── index.tsx
│ │ └── [slug].tsx
│ └── api/
│ └── users.ts
├── components/
├── styles/
└── public/
After: App Router
my-app/
├── app/
│ ├── layout.tsx # _app.tsx 대체
│ ├── page.tsx # index.tsx 대체
│ ├── loading.tsx # 로딩 UI
│ ├── error.tsx # 에러 UI
│ ├── not-found.tsx # 404 페이지
│ ├── about/
│ │ └── page.tsx
│ ├── blog/
│ │ ├── page.tsx
│ │ ├── [slug]/
│ │ │ └── page.tsx
│ │ └── layout.tsx # 블로그 전용 레이아웃
│ └── api/
│ └── users/
│ └── route.ts # API Route 핸들러
├── components/
├── styles/
└── public/
🔄 단계별 마이그레이션 프로세스
1단계: 점진적 도입 (Incremental Adoption)
Next.js 13부터는 pages
와 app
디렉토리를 동시에 사용할 수 있습니다!
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
appDir: true, // App Router 활성화
},
}
module.exports = nextConfig
2단계: 루트 레이아웃 생성
// app/layout.tsx - 전역 레이아웃
import './globals.css'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'My App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ko">
<body>
<header>
<nav>
{/* 전역 네비게이션 */}
</nav>
</header>
<main>{children}</main>
<footer>
{/* 전역 푸터 */}
</footer>
</body>
</html>
)
}
3단계: 홈페이지 마이그레이션
// pages/index.tsx (기존)
import Head from 'next/head'
import { GetStaticProps } from 'next'
interface HomeProps {
posts: Post[]
}
export default function Home({ posts }: HomeProps) {
return (
<>
<Head>
<title>홈 - My Blog</title>
<meta name="description" content="최신 블로그 포스트" />
</Head>
<div>
<h1>최신 포스트</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
</>
)
}
export const getStaticProps: GetStaticProps = async () => {
const posts = await fetchPosts()
return { props: { posts } }
}
// app/page.tsx (새로운 방식)
import { Metadata } from 'next'
export const metadata: Metadata = {
title: '홈 - My Blog',
description: '최신 블로그 포스트',
}
async function fetchPosts() {
// 서버 컴포넌트에서 직접 데이터 페칭
const res = await fetch('https://api.example.com/posts', {
cache: 'force-cache', // ISR과 유사한 캐싱
})
return res.json()
}
export default async function HomePage() {
const posts = await fetchPosts()
return (
<div>
<h1>최신 포스트</h1>
{posts.map((post: any) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
)
}
4단계: 동적 라우팅 마이그레이션
// pages/blog/[slug].tsx (기존)
import { GetStaticPaths, GetStaticProps } from 'next'
interface BlogPostProps {
post: Post
}
export default function BlogPost({ post }: BlogPostProps) {
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await fetchAllPosts()
const paths = posts.map(post => ({ params: { slug: post.slug } }))
return { paths, fallback: 'blocking' }
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
const post = await fetchPost(params?.slug as string)
return { props: { post } }
}
// app/blog/[slug]/page.tsx (새로운 방식)
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
interface BlogPostPageProps {
params: { slug: string }
}
// 동적 메타데이터 생성
export async function generateMetadata({ params }: BlogPostPageProps): Promise<Metadata> {
const post = await fetchPost(params.slug)
if (!post) {
return {
title: 'Post Not Found',
}
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.thumbnail],
},
}
}
// 정적 경로 생성 (getStaticPaths 대체)
export async function generateStaticParams() {
const posts = await fetchAllPosts()
return posts.map((post) => ({
slug: post.slug,
}))
}
async function fetchPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 }, // 1시간마다 재검증
})
if (!res.ok) return null
return res.json()
}
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const post = await fetchPost(params.slug)
if (!post) {
notFound() // not-found.tsx로 라우팅
}
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
5단계: API Routes 마이그레이션
// pages/api/users.ts (기존)
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'GET') {
const users = await fetchUsers()
res.status(200).json(users)
} else if (req.method === 'POST') {
const newUser = await createUser(req.body)
res.status(201).json(newUser)
} else {
res.setHeader('Allow', ['GET', 'POST'])
res.status(405).end(`Method ${req.method} Not Allowed`)
}
}
// app/api/users/route.ts (새로운 방식)
import { NextRequest, NextResponse } from 'next/server'
export async function GET() {
try {
const users = await fetchUsers()
return NextResponse.json(users)
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const newUser = await createUser(body)
return NextResponse.json(newUser, { status: 201 })
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create user' },
{ status: 500 }
)
}
}
🛠️ 고급 기능 활용
Loading UI와 Error Handling
// app/blog/loading.tsx - 로딩 UI
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded mb-4"></div>
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
)
}
// app/blog/error.tsx - 에러 UI
'use client'
interface ErrorProps {
error: Error
reset: () => void
}
export default function Error({ error, reset }: ErrorProps) {
return (
<div className="text-center p-8">
<h2 className="text-2xl font-bold text-red-600 mb-4">
문제가 발생했습니다
</h2>
<p className="text-gray-600 mb-4">{error.message}</p>
<button
onClick={reset}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
다시 시도
</button>
</div>
)
}
서버 컴포넌트와 클라이언트 컴포넌트 분리
// app/blog/page.tsx - 서버 컴포넌트
import { Suspense } from 'react'
import PostList from './PostList'
import SearchForm from './SearchForm'
export default async function BlogPage() {
return (
<div>
<h1>블로그</h1>
{/* 클라이언트 컴포넌트 */}
<SearchForm />
{/* 서버 컴포넌트 + Suspense */}
<Suspense fallback={<div>포스트 로딩 중...</div>}>
<PostList />
</Suspense>
</div>
)
}
// app/blog/SearchForm.tsx - 클라이언트 컴포넌트
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function SearchForm() {
const [query, setQuery] = useState('')
const router = useRouter()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
router.push(`/blog?search=${encodeURIComponent(query)}`)
}
return (
<form onSubmit={handleSubmit} className="mb-8">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="포스트 검색..."
className="border p-2 rounded-l"
/>
<button
type="submit"
className="bg-blue-500 text-white p-2 rounded-r"
>
검색
</button>
</form>
)
}
🔧 마이그레이션 체크리스트
✅ 필수 확인 사항
// 1. 의존성 업데이트
{
"dependencies": {
"next": "^14.0.0", // 최신 버전 사용
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
// 2. TypeScript 설정 업데이트
// tsconfig.json
{
"compilerOptions": {
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
]
}
// 3. ESLint 설정 업데이트
// .eslintrc.json
{
"extends": ["next/core-web-vitals"]
}
🔍 마이그레이션 확인 단계
# 1. 빌드 테스트
npm run build
# 2. 타입 체크
npm run type-check
# 3. 린팅 검사
npm run lint
# 4. 성능 테스트
npm run start
# Lighthouse 점수 확인
# 5. E2E 테스트
npm run test:e2e
⚠️ 주의사항 및 트러블슈팅
자주 발생하는 문제들
1. 서버/클라이언트 컴포넌트 혼동
// ❌ 잘못된 예시
// 서버 컴포넌트에서 useState 사용
export default function ServerComponent() {
const [state, setState] = useState('') // 에러!
return <div>{state}</div>
}
// ✅ 올바른 예시
'use client' // 클라이언트 컴포넌트로 명시
export default function ClientComponent() {
const [state, setState] = useState('')
return <div>{state}</div>
}
2. 동적 import 이슈
// ❌ 기존 방식
import dynamic from 'next/dynamic'
const DynamicComponent = dynamic(() => import('../components/Heavy'), {
loading: () => <p>Loading...</p>,
})
// ✅ App Router 방식
import { Suspense, lazy } from 'react'
const LazyComponent = lazy(() => import('../components/Heavy'))
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
)
}
3. 환경 변수 접근
// ❌ 클라이언트에서 서버 환경변수 접근
export default function ClientComponent() {
console.log(process.env.DATABASE_URL) // undefined
}
// ✅ 올바른 방식
// 클라이언트: NEXT_PUBLIC_ 접두사 필요
console.log(process.env.NEXT_PUBLIC_API_URL)
// 서버 컴포넌트: 모든 환경변수 접근 가능
async function ServerComponent() {
const dbUrl = process.env.DATABASE_URL // 정상 동작
}
📊 성능 최적화 팁
1. 적절한 캐싱 전략
// 정적 데이터 캐싱
const posts = await fetch('/api/posts', {
cache: 'force-cache', // 무한 캐시
})
// 주기적 재검증
const posts = await fetch('/api/posts', {
next: { revalidate: 3600 }, // 1시간마다 재검증
})
// 캐시 무효화
const posts = await fetch('/api/posts', {
cache: 'no-store', // 캐시 사용 안 함
})
2. 스트리밍 최적화
// app/dashboard/page.tsx
import { Suspense } from 'react'
export default function Dashboard() {
return (
<div>
<h1>대시보드</h1>
{/* 빠른 컴포넌트는 즉시 렌더링 */}
<QuickStats />
{/* 느린 컴포넌트는 스트리밍 */}
<Suspense fallback={<ChartSkeleton />}>
<ExpensiveChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
</div>
)
}
🎯 마이그레이션 완료 후 체크리스트
✅ 최종 확인 사항
- 모든 페이지가 정상 작동하는가?
- SEO 메타데이터가 올바르게 설정되었는가?
- 성능 지표가 개선되었는가?
- 에러 페이지가 적절히 구현되었는가?
- API 라우트가 정상 동작하는가?
- 빌드 및 배포가 성공하는가?
📈 성능 측정
# Lighthouse 성능 측정
npx lighthouse http://localhost:3000 --output html
# 번들 분석
npm install --save-dev @next/bundle-analyzer
# next.config.js에서 활성화 후
ANALYZE=true npm run build
마무리하며 🎉
Next.js 13 App Router로의 마이그레이션은 점진적으로 진행하는 것이 핵심입니다!
한 번에 모든 것을 바꾸지 말고, 중요한 페이지부터 차근차근 마이그레이션하면서 성능 개선과 개발 경험 향상을 체감해보세요.
서버 컴포넌트의 강력함과 향상된 라우팅 시스템으로 더 나은 사용자 경험을 제공할 수 있게 될 것입니다! 💪
🔗 참고 자료
#NextJS13 #AppRouter #마이그레이션 #React18 #서버컴포넌트 #웹개발 #성능최적화 #프론트엔드
'TypeScript' 카테고리의 다른 글
TypeScript 5.0 ~ 5.8: 개발자를 위한 핵심 업데이트 총정리 (0) | 2025.07.12 |
---|---|
React Hook Form + Zod 스키마 검증 완전 정복: 타입 안전한 폼 구현의 모든 것 (0) | 2025.06.06 |
Next.js의 Image 컴포넌트 가이드 (0) | 2025.04.29 |
브레드크럼(Breadcrumb): 사용자 경험을 향상시키는 네비게이션 요소 (1) | 2025.04.15 |
Next.js에서 class-variance-authority(CVA)로 재사용 가능한 UI 컴포넌트 만들기 (0) | 2025.04.14 |