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)
  • 인기 글

  • 태그

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

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

티스토리툴바