Next.js 13 App Router 마이그레이션 완전 가이드: Pages에서 App으로 안전하게 전환하기

2025. 6. 5. 00:16·TypeScript
반응형

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로의 마이그레이션은 점진적으로 진행하는 것이 핵심입니다!

한 번에 모든 것을 바꾸지 말고, 중요한 페이지부터 차근차근 마이그레이션하면서 성능 개선과 개발 경험 향상을 체감해보세요.

서버 컴포넌트의 강력함과 향상된 라우팅 시스템으로 더 나은 사용자 경험을 제공할 수 있게 될 것입니다! 💪


🔗 참고 자료

  • Next.js 공식 마이그레이션 가이드
  • React 18 서버 컴포넌트 문서

#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
'TypeScript' 카테고리의 다른 글
  • TypeScript 5.0 ~ 5.8: 개발자를 위한 핵심 업데이트 총정리
  • React Hook Form + Zod 스키마 검증 완전 정복: 타입 안전한 폼 구현의 모든 것
  • Next.js의 Image 컴포넌트 가이드
  • 브레드크럼(Breadcrumb): 사용자 경험을 향상시키는 네비게이션 요소
코샵
코샵
나의 코딩 일기장
    반응형
  • 코샵
    끄적끄적 코딩 공방
    코샵
    • 분류 전체보기 (725)
      • 스마트팜 (0)
      • 상품 추천 (223)
      • MongoDB (4)
      • 하드웨어 (17)
      • 일기장 (4)
      • 파이썬 (130)
        • Basic (41)
        • OpenCV (8)
        • Pandas (15)
        • PyQT (3)
        • SBC(Single Board Computer) (1)
        • 크롤링 (14)
        • Fast API (29)
        • Package (6)
      • Unity (138)
        • Tip (41)
        • Project (1)
        • Design Pattern (8)
        • Firebase (6)
        • Asset (2)
      • Linux (4)
      • C# (97)
        • Algorithm (11)
        • Window (7)
      • TypeScript (51)
        • CSS (10)
      • Git (11)
      • SQL (5)
      • Flutter (10)
        • Tip (1)
      • System (1)
      • BaekJoon (6)
      • Portfolio (2)
      • MacOS (1)
      • 유틸리티 (1)
      • 서비스 (6)
      • 자동화 (3)
      • Hobby (10)
        • 물생활 (10)
        • 식집사 (0)
  • 인기 글

  • 태그

    카페24리뷰이관
    ipcamera
    쇼핑몰리뷰
    unity
    카페24리뷰
    리뷰이관
    셀레니움
    C#
    appdevelopment
    긴유통기한우유
    스크립트 실행 순서
    cv2
    스크립트 실행
    devlife
    믈레코비타멸균우유
    유니티
    rtsp
    상품 리뷰 크롤링
    learntocode
    programming101
    리뷰관리
    list
    파이썬
    스마트스토어리뷰
    Python
    codingcommunity
    programmerlife
    리스트
    codingtips
    라떼우유
  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
코샵
Next.js 13 App Router 마이그레이션 완전 가이드: Pages에서 App으로 안전하게 전환하기
상단으로

티스토리툴바