
Next.js 16 마이그레이션: App Router 완전 전환 후기
Pages Router에서 App Router로 전환하면서 겪은 것들 — 마이그레이션 전략, Server Components 함정, 데이터 페칭 변화, 그리고 성능 결과까지 솔직하게 정리했다.

Pages Router에서 App Router로 전환하면서 겪은 것들 — 마이그레이션 전략, Server Components 함정, 데이터 페칭 변화, 그리고 성능 결과까지 솔직하게 정리했다.
서버 컴포넌트를 클라이언트 컴포넌트 안에 import 했더니, DB 연결이 끊기고 에러가 폭발했습니다. Next.js App Router의 핵심인 'Composition Pattern'을 구멍 뚫린 도넛에 비유해 설명하고, Context Provider를 올바르게 분리하는 방법을 정리해봤습니다.

게시판에 달린 댓글 하나 때문에 관리자 계정이 탈취당했습니다. XSS(Cross-Site Scripting)의 3가지 유형(Stored, Reflected, DOM)과 React/Next.js 환경에서의 구체적인 방어법(HTML 이스케이프, CSP, 쿠키 보안)을 예제와 함께 깊이 있게 다룹니다.

Next.js 빌드 로그에 나오는 동그라미(○)와 람다(λ) 기호의 의미를 아시나요? 실수로 모든 페이지를 동적으로 만들어버리지 않는 방법을 확인하세요.

서비스를 만들었는데 아무도 오지 않았다. 마케팅 예산 없이 검색 유입을 만드는 기술적 SEO를 Next.js에서 직접 구현한 경험.

솔직히 App Router는 Next.js 13에서 나왔다. 벌써 꽤 됐다. 근데 왜 이제야 마이그레이션을 했냐고?
Pages Router가 동작하고 있었다. 배포 안정적으로 되고 있었다. "고장 안 났으면 고치지 마라"는 원칙이 있었다.
근데 Next.js 15, 16으로 올라오면서 Pages Router 관련 경고가 늘어났다. 공식 문서에서 App Router가 기본이 됐다. 팀이 React Server Components를 써보고 싶어했다. 그리고 결정적으로 번들 크기가 너무 커졌다. 클라이언트에 불필요하게 내려가는 JavaScript가 많았다.
그래서 했다. 이 글은 그 과정에서 배운 것들이다.
한 줄 요약: 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>;
}
개념적으로 훨씬 깔끔하다. 데이터와 렌더링이 같은 곳에 있다.
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/ 제거, 클린업
한 번에 다 바꾸면 롤백이 너무 어렵다. 점진적으로 가는 게 훨씬 안전하다.
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>
);
}
서버 컴포넌트에서 클라이언트 컴포넌트로 props를 넘길 때 직렬화 가능한 값만 가능하다.
// 안 됨 - 함수는 직렬화 불가
<ClientComponent onClick={() => console.log("clicked")} />
// 안 됨 - 클래스 인스턴스는 직렬화 불가
<ClientComponent date={new Date()} />
// 됨 - 직렬화 가능한 값
<ClientComponent timestamp={Date.now()} />
<ClientComponent onClick={undefined} /> // 이벤트 핸들러는 클라이언트 컴포넌트 내에서 정의
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>
);
}
"모르겠으면 클라이언트 컴포넌트로 하면 되지"라고 생각하기 쉬운데, 그러면 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>
);
}
// 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} />;
}
// 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} />;
}
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" 태그를 가진 모든 캐시 무효화
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).*)",
],
};
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
번들 크기가 줄어든 게 직접적인 원인이다. 서버에서 처리하던 컴포넌트들이 클라이언트 번들에서 빠졌다.
컴포넌트 트리에서 "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>
);
}
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가 크게 개선됐다.
// 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보다 훨씬 나아졌다.
// 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;
완전히 끝난 건 아니다. 아직 남아있는 것들:
마이그레이션은 생각보다 오래 걸렸다. 7주 계획이 10주가 됐다. 하지만 결과는 만족스럽다.
좋아진 것들:이미 App Router로 시작하는 프로젝트라면 고민할 게 없다. Pages Router에서 마이그레이션을 고민 중이라면: 점진적으로 해라, 이해도가 높은 팀원부터 해라, 그리고 useRouter를 next/navigation에서 임포트하는 거 잊지 마라.