Prologue: "왜 이제야?"
솔직히 App Router는 Next.js 13에서 나왔다. 벌써 꽤 됐다. 근데 왜 이제야 마이그레이션을 했냐고?
Pages Router가 동작하고 있었다. 배포 안정적으로 되고 있었다. "고장 안 났으면 고치지 마라"는 원칙이 있었다.
근데 Next.js 15, 16으로 올라오면서 Pages Router 관련 경고가 늘어났다. 공식 문서에서 App Router가 기본이 됐다. 팀이 React Server Components를 써보고 싶어했다. 그리고 결정적으로 번들 크기가 너무 커졌다. 클라이언트에 불필요하게 내려가는 JavaScript가 많았다.
그래서 했다. 이 글은 그 과정에서 배운 것들이다.
Pages Router vs App Router: 뭐가 달라졌나
한 줄 요약: Pages Router는 "요청이 오면 서버에서 데이터 가져와서 컴포넌트 렌더링". App Router는 "컴포넌트가 서버에서 직접 데이터 가져옴".
라우팅 구조
Pages Router:
pages/
index.tsx
blog/
index.tsx
[slug].tsx
api/
posts.ts
App Router:
app/
page.tsx
blog/
page.tsx
[slug]/
page.tsx
api/
posts/
route.ts
데이터 페칭 패러다임 변화
// Pages Router - getServerSideProps / getStaticProps
export async function getServerSideProps(context) {
const { params } = context;
const post = await getPost(params.slug);
return { props: { post } };
}
export default function PostPage({ post }) { // props로 받음
return <article>{post.title}</article>;
}
// App Router - 컴포넌트에서 직접 async/await
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug); // 컴포넌트 내부에서 직접
return <article>{post.title}</article>;
}
개념적으로 훨씬 깔끔하다. 데이터와 렌더링이 같은 곳에 있다.
마이그레이션 전략: 점진적 vs 빅뱅
Next.js는 두 라우터를 동시에 지원한다. pages/와 app/ 디렉토리를 함께 쓸 수 있다.
점진적 마이그레이션 (권장):
1단계: app/ 디렉토리 생성, 레이아웃 설정
2단계: 덜 복잡한 페이지부터 하나씩 이전
3단계: 복잡한 페이지 (많은 클라이언트 상태, 서드파티) 이전
4단계: pages/ 디렉토리 완전 제거
우리 팀은 이렇게 했다:
Week 1: app/ 기반 설정 (레이아웃, 미들웨어, 글로벌 스타일)
Week 2-3: 정적 페이지들 이전 (About, Privacy, Terms)
Week 4-5: 동적 페이지들 이전 (Blog, Projects)
Week 6: 복잡한 페이지들 이전 + 테스트
Week 7: pages/ 제거, 클린업
한 번에 다 바꾸면 롤백이 너무 어렵다. 점진적으로 가는 게 훨씬 안전하다.
Server Components: 가장 큰 개념 전환
App Router의 핵심은 React Server Components(RSC)다. 이게 익숙해지는 데 시간이 걸렸다.
기본 규칙
서버 컴포넌트 (기본값):
- 서버에서만 실행됨
- 브라우저에 JavaScript가 내려가지 않음
- DB, API, 파일시스템 직접 접근 가능
- useState, useEffect, 브라우저 API 사용 불가
클라이언트 컴포넌트 ("use client"):
- 브라우저에서도 실행됨 (hydration)
- useState, useEffect, 이벤트 핸들러 사용 가능
- 서버 전용 작업 불가 (DB 직접 접근 등)
처음에 가장 헷갈렸던 것
// 이건 서버 컴포넌트 — 괜찮음
export default async function PostList() {
const posts = await db.posts.findAll(); // DB 직접 접근
return (
<ul>
{posts.map(post => <PostCard key={post.id} post={post} />)}
</ul>
);
}
// 이건 클라이언트 컴포넌트 — "use client" 필요
"use client";
export default function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? "❤️" : "🤍"}
</button>
);
}
함정 1: 직렬화 불가 props
서버 컴포넌트에서 클라이언트 컴포넌트로 props를 넘길 때 직렬화 가능한 값만 가능하다.
// 안 됨 - 함수는 직렬화 불가
<ClientComponent onClick={() => console.log("clicked")} />
// 안 됨 - 클래스 인스턴스는 직렬화 불가
<ClientComponent date={new Date()} />
// 됨 - 직렬화 가능한 값
<ClientComponent timestamp={Date.now()} />
<ClientComponent onClick={undefined} /> // 이벤트 핸들러는 클라이언트 컴포넌트 내에서 정의
함정 2: Context API 제한
Context는 클라이언트 컴포넌트에서만 쓸 수 있다.
// 안 됨
export default async function ServerComponent() {
const theme = useContext(ThemeContext); // 에러!
return <div className={theme}>...</div>;
}
// 해결책 1: 클라이언트 컴포넌트로 분리
"use client";
export default function ThemeWrapper({ children }) {
const theme = useContext(ThemeContext);
return <div className={theme}>{children}</div>;
}
// 해결책 2: CSS 변수 + Server Component
export default async function ServerComponent() {
const settings = await getUserSettings();
return (
<div style={{ "--theme": settings.theme } as CSSProperties}>
...
</div>
);
}
함정 3: 컴포넌트 분리 전략
"모르겠으면 클라이언트 컴포넌트로 하면 되지"라고 생각하기 쉬운데, 그러면 App Router의 장점(번들 크기 감소)을 잃는다.
올바른 접근: 최대한 서버 컴포넌트로 유지하고, 인터랙션이 필요한 최소한의 부분만 클라이언트 컴포넌트로 분리.
// 나쁜 패턴: 전체를 클라이언트 컴포넌트로
"use client";
export default function PostPage({ params }) {
const [post, setPost] = useState(null);
useEffect(() => {
fetchPost(params.slug).then(setPost);
}, [params.slug]);
if (!post) return <Loading />;
return (
<article>
<h1>{post.title}</h1>
<LikeButton postId={post.id} /> {/* 이것만 클라이언트 필요 */}
</article>
);
}
// 좋은 패턴: 인터랙션 부분만 분리
// app/blog/[slug]/page.tsx
export default async function PostPage({ params }) {
const post = await getPost(params.slug); // 서버에서 직접
return (
<article>
<h1>{post.title}</h1>
<LikeButton postId={post.id} /> {/* 클라이언트 컴포넌트 */}
</article>
);
}
데이터 페칭 변화
getServerSideProps → Server Component
// Before (Pages Router)
export async function getServerSideProps({ params, req }) {
const session = await getSession(req);
if (!session) return { redirect: { destination: "/login" } };
const user = await getUser(session.userId);
const posts = await getUserPosts(session.userId);
return { props: { user, posts } };
}
export default function DashboardPage({ user, posts }) {
return <Dashboard user={user} posts={posts} />;
}
// After (App Router)
import { redirect } from "next/navigation";
import { getServerSession } from "@/lib/auth";
export default async function DashboardPage() {
const session = await getServerSession();
if (!session) redirect("/login");
// 병렬 데이터 페칭
const [user, posts] = await Promise.all([
getUser(session.userId),
getUserPosts(session.userId),
]);
return <Dashboard user={user} posts={posts} />;
}
getStaticProps + getStaticPaths → generateStaticParams + fetch cache
// Before (Pages Router)
export async function getStaticPaths() {
const posts = await getAllPosts();
return {
paths: posts.map(p => ({ params: { slug: p.slug } })),
fallback: "blocking",
};
}
export async function getStaticProps({ params }) {
const post = await getPost(params.slug);
return {
props: { post },
revalidate: 60, // ISR
};
}
// After (App Router)
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(p => ({ slug: p.slug }));
}
export const revalidate = 60; // ISR — 모듈 레벨에서 선언
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return <PostDetail post={post} />;
}
fetch 캐시 제어
App Router에서 fetch는 기본적으로 캐싱된다. 이 동작을 세밀하게 제어할 수 있다.
// 기본: 무기한 캐시 (정적 데이터)
const data = await fetch("https://api.example.com/static-data");
// no-store: 캐시 없음 (매 요청마다 새로 가져옴)
const data = await fetch("https://api.example.com/live-data", {
cache: "no-store",
});
// revalidate: 시간 기반 캐시 (ISR 동작)
const data = await fetch("https://api.example.com/data", {
next: { revalidate: 60 }, // 60초마다 갱신
});
// 태그 기반 재검증
const data = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
// 특정 태그 무효화 (Server Action 등에서)
import { revalidateTag } from "next/cache";
revalidateTag("posts"); // "posts" 태그를 가진 모든 캐시 무효화
Metadata API
Pages Router에서는 <Head> 컴포넌트를 썼다. App Router에는 타입 안전한 Metadata API가 있다.
// Before (Pages Router)
import Head from "next/head";
export default function PostPage({ post }) {
return (
<>
<Head>
<title>{post.title}</title>
<meta name="description" content={post.description} />
<meta property="og:title" content={post.title} />
<meta property="og:image" content={post.coverImage} />
</Head>
<article>...</article>
</>
);
}
// After (App Router)
import { Metadata } from "next";
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
images: [{ url: post.coverImage, width: 1200, height: 630 }],
type: "article",
publishedTime: post.date,
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.description,
images: [post.coverImage],
},
};
}
export default async function PostPage({ params }) {
const post = await getPost(params.slug);
return <PostDetail post={post} />;
}
훨씬 명시적이고 타입 안전하다. <Head> 중복 선언으로 발생하던 버그도 없어졌다.
미들웨어 변화
// Before (pages/_middleware.ts 또는 middleware.ts - Next.js 12+)
// 비슷하지만 일부 API 차이
// After (src/middleware.ts 또는 middleware.ts)
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// 인증 체크
const token = request.cookies.get("token")?.value;
const isProtectedRoute = pathname.startsWith("/dashboard");
if (isProtectedRoute && !token) {
return NextResponse.redirect(new URL("/login", request.url));
}
// 지역화 처리 (next-intl과 함께)
return NextResponse.next();
}
export const config = {
matcher: [
// 정적 파일 제외
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};
Server Actions: API Route 없이 서버 함수 호출
App Router의 보너스 기능. 클라이언트에서 서버 함수를 직접 호출한다.
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const createPostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(10),
});
export async function createPost(formData: FormData) {
const validated = createPostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!validated.success) {
return { error: validated.error.flatten() };
}
const session = await getServerSession();
if (!session) return { error: "Unauthorized" };
const post = await db.posts.create({
...validated.data,
authorId: session.userId,
});
revalidatePath("/blog");
return { success: true, postId: post.id };
}
// 클라이언트 컴포넌트에서 사용
"use client";
import { createPost } from "@/app/actions";
export default function CreatePostForm() {
const [state, formAction] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" placeholder="제목" />
<textarea name="content" placeholder="내용" />
{state?.error && <p className="text-red-500">오류 발생</p>}
<button type="submit">작성</button>
</form>
);
}
별도 API Route 없이 폼 처리가 끝난다. 클라이언트-서버 통신 코드가 눈에 띄게 줄었다.
성능 결과 (실제 수치)
마이그레이션 전후를 Lighthouse와 Next.js 빌드 출력으로 비교했다.
빌드 출력 비교:
Pages Router:
Route Size First Load JS
/blog 12.4 kB 98.7 kB
/blog/[slug] 8.2 kB 94.5 kB
/_app - 86.3 kB (공유)
App Router:
Route Size First Load JS
/blog 3.1 kB 78.4 kB (-20%)
/blog/[slug] 2.8 kB 74.2 kB (-21%)
(shared) - 71.2 kB
Lighthouse (모바일) 비교:
Before After
Performance 72 89
FCP (First CP) 2.4s 1.6s (-33%)
LCP (Largest CP) 3.8s 2.1s (-45%)
TBT (Total Block) 380ms 120ms (-68%)
CLS 0.12 0.04
번들 크기가 줄어든 게 직접적인 원인이다. 서버에서 처리하던 컴포넌트들이 클라이언트 번들에서 빠졌다.
배운 것들
1. "use client" 경계를 신중하게 잡아라
컴포넌트 트리에서 "use client"를 선언하면 그 아래 전체가 클라이언트 컴포넌트가 된다. 가능하면 리프 노드(말단 컴포넌트)에만 붙이자.
// 나쁨: 큰 컴포넌트에 use client
"use client";
export default function ProductPage({ product }) {
// 이 컴포넌트 트리 전체가 클라이언트 번들에 포함됨
return (
<div>
<ProductImages images={product.images} />
<ProductDescription content={product.description} />
<AddToCartButton productId={product.id} /> {/* 이것만 클라이언트 필요 */}
</div>
);
}
// 좋음: 인터랙션 부분만 분리
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return (
<div>
<ProductImages images={product.images} /> {/* 서버 컴포넌트 */}
<ProductDescription content={product.description} /> {/* 서버 컴포넌트 */}
<AddToCartButton productId={product.id} /> {/* 클라이언트 컴포넌트 */}
</div>
);
}
2. Suspense로 스트리밍 활용
import { Suspense } from "react";
export default async function DashboardPage() {
return (
<div>
<h1>대시보드</h1>
{/* 빠른 데이터 - 즉시 렌더링 */}
<UserProfile />
{/* 느린 데이터 - 스트리밍으로 나중에 */}
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsWidget /> {/* 느린 DB 쿼리가 있어도 페이지가 먼저 나옴 */}
</Suspense>
<Suspense fallback={<RecentActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
페이지가 느린 데이터를 기다리지 않고 먼저 렌더링된다. UX가 크게 개선됐다.
3. 에러 바운더리
// app/blog/[slug]/error.tsx
"use client";
import { useEffect } from "react";
export default function ErrorBoundary({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div>
<h2>포스트를 불러오는 데 실패했습니다</h2>
<button onClick={reset}>다시 시도</button>
</div>
);
}
라우트 레벨 에러 처리가 파일 기반으로 명시적이다. 이건 Pages Router보다 훨씬 나아졌다.
4. 마이그레이션 중 주의할 것들
// router.push → useRouter import 경로 변경
// Before
import { useRouter } from "next/router";
// After
import { useRouter } from "next/navigation";
// redirect, usePathname, useSearchParams 등도 next/navigation에서
// params 처리
// Before (Pages Router - 동기)
const { slug } = router.query;
// After (App Router - 서버 컴포넌트)
export default async function Page({ params }: { params: { slug: string } }) {
// params를 직접 받음
}
// After (App Router - 클라이언트 컴포넌트)
"use client";
import { useParams } from "next/navigation";
const params = useParams();
const slug = params.slug as string;
남은 과제
완전히 끝난 건 아니다. 아직 남아있는 것들:
- Server Actions 전면 도입: 아직 일부 폼은 API Route를 쓰고 있다.
- Parallel Routes / Intercepting Routes: 모달 기반 라우팅에 써보고 싶은데 복잡도가 높다.
- PPR (Partial Pre-rendering): Next.js 15/16에서 실험적인 기능. 정적+동적 컨텐츠를 하나의 페이지에서 혼합.
결론
마이그레이션은 생각보다 오래 걸렸다. 7주 계획이 10주가 됐다. 하지만 결과는 만족스럽다.
좋아진 것들:
- First Load JS 20% 감소
- LCP 45% 개선
- 코드가 훨씬 직관적 (데이터 + 렌더링이 같은 파일에)
- Metadata API로 SEO 관리가 명시적
- Server Actions으로 API 보일러플레이트 감소
어려웠던 것들:
- "use client" 경계 잡기 — 팀 전체가 이 개념에 익숙해지는 데 시간이 걸렸다
- 라이브러리 호환성 — 일부 라이브러리가 App Router를 아직 완전히 지원 안 했다
- Hydration 불일치 디버깅 — 서버/클라이언트 렌더링 결과가 다를 때 찾기 어려웠다
이미 App Router로 시작하는 프로젝트라면 고민할 게 없다. Pages Router에서 마이그레이션을 고민 중이라면: 점진적으로 해라, 이해도가 높은 팀원부터 해라, 그리고 useRouter를 next/navigation에서 임포트하는 거 잊지 마라.