Next.js App Router 마이그레이션 삽질 기록 — Pages Router에서 넘어온 이야기

게시일: 2025년 6월 3일 · 16분 읽기

예상 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 Router 마이그레이션은 힘들었지만, 프레임워크의 새로운 철학을 이해하는 좋은 기회였다. 처음부터 이렇게 했으면 훨씬 쉬웠을 텐데, 레거시 코드와의 전쟁은 항상 어렵다.

새 프로젝트를 시작한다면, 무조건 App Router를 쓰는 걸 권한다. 익숙해지는 데 시간이 걸리지만, 장기적으로 훨씬 낫다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.