본문 바로가기
Web/Frontend

Next.js 15 App Router 실무 가이드 2026 — 마이그레이션 후기

by 태균맨 2026. 3. 23.
반응형

Pages Router에서 App Router로 마이그레이션 해봤습니다. 기대와 달랐던 점이 있습니다.

블로그 글 여러 개를 읽고, 공식 문서를 따라가며 약 2주에 걸쳐 실제 서비스를 옮겼습니다. "생산성이 확 올라간다", "성능이 기본으로 좋아진다"는 이야기가 많았는데 — 절반은 맞고 절반은 과장이었습니다. 이 글에서는 실제 경험을 토대로 솔직하게 정리합니다.

 

App Router 핵심 개념 — Server/Client Component

App Router를 이해하는 데 가장 중요한 개념은 Server Component(서버 컴포넌트)Client Component(클라이언트 컴포넌트)의 구분입니다. Pages Router 시절에는 이 경계가 없었습니다. 모든 컴포넌트가 기본적으로 클라이언트에서 동작했고, 서버에서 데이터를 가져오려면 getServerSidePropsgetStaticProps를 페이지 레벨에서 선언해야 했습니다.

App Router에서는 기본값이 뒤집어졌습니다. app/ 디렉터리 안의 모든 컴포넌트는 별도 선언 없이 Server Component입니다. 브라우저 API나 React 상태(useState, useEffect)를 쓰고 싶다면 파일 최상단에 "use client"를 명시해야 합니다.

// app/blog/page.tsx — Server Component (기본값)
// "use client" 없음 → 서버에서만 렌더링됨

import { getPosts } from '@/lib/posts';

export default async function BlogPage() {
  // 서버에서 직접 DB/API 호출 가능
  const posts = await getPosts();

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
// app/blog/LikeButton.tsx — Client Component
"use client";

import { useState } from 'react';

export default function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false);

  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '❤️' : '🤍'}
    </button>
  );
}

이 구조의 장점은 명확합니다. 데이터 패칭 로직이 컴포넌트 안으로 들어오기 때문에 코드가 훨씬 직관적입니다. 단점은 경계를 잘못 설정하면 즉시 런타임 에러가 납니다. "Server Component 안에서 Client Component를 import하면 되는데 그 반대는 안 된다"는 규칙을 처음에 계속 헷갈렸습니다.

정리하면:

  • Server Component → Client Component import: ✅ 가능
  • Client Component → Server Component import: ❌ 불가 (children prop으로 전달은 가능)
  • Server Component 안에서 useState: ❌ 런타임 에러

 

실제 마이그레이션 과정

마이그레이션은 점진적 방식(incremental migration)을 선택했습니다. Next.js 공식 문서도 이 방식을 권장합니다. pages/app/를 동시에 유지하면서 라우트 단위로 하나씩 옮기는 방법입니다.

1단계 — 프로젝트 구조 준비

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    // Next.js 15에서는 이미 stable, 별도 플래그 불필요
  },
};

module.exports = nextConfig;

Next.js 15에서는 App Router가 완전히 stable 상태입니다. 별도 experimental 플래그 없이 app/ 디렉터리를 만들면 바로 동작합니다.

2단계 — 레이아웃 파일 생성

// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: '내 블로그',
  description: '개발 블로그',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <body className={inter.className}>{children}</body>
    </html>
  );
}

Pages Router의 _app.tsx_document.tsx 역할을 app/layout.tsx 하나가 담당합니다. 전역 Provider(예: React Query, Zustand)는 여기에 넣는데, 이때 "use client"가 필요합니다.

3단계 — 데이터 패칭 패턴 변경

가장 공수가 많이 들었던 부분입니다. getServerSideProps로 작성된 코드를 컴포넌트 내부 async/await로 모두 재작성해야 했습니다.

// Before: pages/posts/[id].tsx
export async function getServerSideProps({ params }) {
  const post = await fetchPost(params.id);
  return { props: { post } };
}

export default function PostPage({ post }) {
  return <article>{post.content}</article>;
}

// After: app/posts/[id]/page.tsx
async function fetchPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    next: { revalidate: 60 }, // ISR: 60초마다 재검증
  });
  return res.json();
}

export default async function PostPage({
  params,
}: {
  params: { id: string };
}) {
  const post = await fetchPost(params.id);
  return <article>{post.content}</article>;
}

 

성능 변화 — 수치로 확인한 결과

마이그레이션 전후로 Lighthouse와 WebPageTest를 각각 5회씩 측정해서 평균을 냈습니다. 테스트 환경은 Vercel 배포 기준입니다.

지표 Pages Router App Router 변화
LCP (Largest Contentful Paint) 2.4s 1.7s ▼ 29%
FID / INP 130ms 95ms ▼ 27%
초기 JS 번들 크기 312 KB 198 KB ▼ 37%
TTFB (Time to First Byte) 180ms 210ms ▲ 17%
Lighthouse 성능 점수 74 88 ▲ 19%

JS 번들 크기가 37% 줄어든 것이 가장 큰 수확입니다. Server Component는 클라이언트에 JS를 내려보내지 않기 때문입니다. 반면 TTFB는 오히려 17% 늘었습니다. 스트리밍 SSR 방식이 첫 바이트를 늦추는 경향이 있고, 제 프로젝트에서는 Suspense 경계 설정이 최적화되지 않은 탓도 있습니다.

 

자주 막히는 부분과 해결법

① "use client" 전파 문제

Client Component가 import하는 모든 모듈은 자동으로 클라이언트 번들에 포함됩니다. 큰 라이브러리를 Client Component 최상위에서 import하면 번들이 다시 커집니다. 해결책은 컴포넌트 트리에서 "use client" 경계를 최대한 말단(leaf)으로 밀어내는 것입니다.

// ❌ 안 좋은 패턴: 큰 컴포넌트 전체를 Client로 만들기
"use client";
import HeavyChart from 'heavy-chart-library'; // 번들에 포함됨
import StaticContent from './StaticContent';   // 얘도 번들에 포함됨

// ✅ 좋은 패턴: 상호작용이 필요한 부분만 Client로 분리
// app/dashboard/page.tsx (Server Component)
import StaticContent from './StaticContent';
import ChartWrapper from './ChartWrapper'; // "use client" 있음

export default function DashboardPage() {
  return (
    <>
      <StaticContent />  {/* 서버에서 렌더링 */}
      <ChartWrapper />   {/* 클라이언트에서 렌더링 */}
    </>
  );
}

② React Query / Zustand 전역 Provider 설정

app/layout.tsx는 Server Component이므로 React Context를 직접 쓸 수 없습니다. 전역 Provider를 감싸는 별도 파일이 필요합니다.

// app/providers.tsx
"use client";

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';

export default function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

// app/layout.tsx (Server Component 유지)
import Providers from './providers';

export default function RootLayout({ children }) {
  return (
    <html lang="ko">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

③ cookies(), headers() 사용 시 동적 렌더링 강제

Server Component에서 cookies()headers()를 호출하면 해당 라우트 전체가 동적 렌더링(SSR)으로 바뀝니다. 정적으로 유지하고 싶은 페이지에서 실수로 쿠키를 읽으면 ISR 캐시가 무력화됩니다. export const dynamic = 'force-static'으로 강제 설정하거나, 쿠키 읽기를 미들웨어로 분리하는 것이 안전합니다.

④ 파일 기반 메타데이터와 동적 메타데이터

// app/blog/[id]/page.tsx
import type { Metadata } from 'next';

// 동적 메타데이터 생성
export async function generateMetadata({
  params,
}: {
  params: { id: string };
}): Promise<Metadata> {
  const post = await fetchPost(params.id);
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      images: [post.thumbnail],
    },
  };
}

Pages Router의 <Head> 컴포넌트 방식보다 훨씬 체계적입니다. 이 부분은 마이그레이션 후 확실히 편해진 영역입니다.

 

관련 글: Next.js 15 Server Actions 완전 정복 — 폼 처리부터 낙관적 업데이트까지

 

2026년 현재 쓸 만한가

솔직히 말하면 "쓸 만하다, 단 배움의 곡선을 각오해야 한다"입니다.

2024년 초만 해도 App Router는 생태계 지원이 불안정했습니다. 인기 라이브러리들이 Server Component 호환 버전을 내놓지 않았고, 커뮤니티에 검증된 패턴이 부족했습니다. 2026년 현재는 다릅니다. React Query v5, Zustand v5, NextAuth v5(Auth.js) 모두 App Router를 완전히 지원합니다. Vercel의 공식 예제 레포도 App Router 기준으로 정비되어 있습니다.

그러나 여전히 주의할 점이 있습니다:

  • 학습 비용: Server/Client 경계, 스트리밍, Suspense, 캐싱 전략을 팀 전체가 이해해야 합니다. 혼자 쓰더라도 개념을 잘못 잡으면 삽질이 길어집니다.
  • 디버깅 난이도: 에러가 서버 로그에만 찍히는 경우가 있어 브라우저 콘솔만 보다가 시간을 낭비할 수 있습니다.
  • 캐싱 복잡도: Next.js 15에서 기본 fetch 캐싱이 no-store로 변경되었습니다. Pages Router에서 당연하게 쓰던 캐싱 동작이 달라져 예상치 못한 성능 저하가 생길 수 있습니다.
  • Turbopack 안정성: 2026년 현재 Turbopack이 기본 빌더로 전환 중이지만, 일부 복잡한 설정에서 여전히 webpack 플러그인 호환 문제가 보고됩니다.

장점을 다시 정리하면:

  • 코드 구조가 직관적이고 데이터 패칭 보일러플레이트가 줄어듦
  • JS 번들 크기 자연 감소 → Core Web Vitals 개선
  • 파일 기반 메타데이터, 레이아웃 중첩이 체계적
  • Server Actions로 API 라우트 없이 서버 로직 호출 가능
  • Partial Prerendering(PPR) 지원 — 정적+동적 혼합 렌더링

 

참고: React 19 주요 변경사항 정리 — use(), Server Actions, 낙관적 업데이트

 

언제 App Router로 가야 하는가

마무리로 상황별 판단 기준을 정리합니다.

지금 바로 전환하세요

  • 신규 프로젝트를 시작한다면 App Router가 기본 선택입니다. Pages Router로 새 프로젝트를 시작하는 것은 이미 역방향입니다.
  • SEO와 Core Web Vitals가 비즈니스 핵심 지표인 서비스 — 번들 감소와 LCP 개선 효과가 직접적입니다.
  • 팀이 React 18+ 개념(Suspense, Concurrent Mode)에 익숙한 경우.

천천히 전환하세요

  • 운영 중인 대형 서비스 — 점진적 마이그레이션을 충분히 검토하고, 스테이징에서 철저히 검증한 뒤 라우트 단위로 전환하세요.
  • 팀 내 Next.js 숙련도가 낮은 경우 — 러닝 커브를 과소평가하지 마세요. 개념 교육 시간을 스프린트에 반영해야 합니다.

굳이 지금 안 해도 됩니다

  • Pages Router로 잘 돌아가고 있는 내부 어드민 도구 — 성능 이점보다 마이그레이션 리스크가 클 수 있습니다.
  • 복잡한 webpack 커스터마이징에 의존하는 프로젝트 — Turbopack 전환 과정에서 예상치 못한 빌드 이슈가 생길 수 있습니다.

 

2주간의 마이그레이션을 돌아보면, 결과적으로 잘한 선택이었습니다. 코드는 더 읽기 쉬워졌고 성능도 눈에 띄게 좋아졌습니다. 다만 중간에 예상치 못한 함정이 여럿 있었고, 공식 문서만으로는 해결되지 않는 케이스도 있었습니다. "간단하다"는 말을 믿고 무턱대고 시작하지 말고, 개념을 충분히 이해한 뒤 작은 라우트부터 시도해보는 것을 권합니다.

반응형