Next.js App Router 마이그레이션 실전 가이드 — 서버 컴포넌트 전환 시 마주치는 함정과 해법
솔직히 말하면, Pages Router를 운영하던 팀에서 App Router 전환을 결정했다는 소식을 들었을 때 한숨부터 나왔습니다. 잘 돌아가는 코드를 왜 건드려야 하나 싶었거든요. 그런데 실제로 작업을 마치고 돌아보니, 단순한 기술 교체가 아니었습니다. 로딩 스피너가 곳곳에서 사라졌고, 페이지마다 만들어두던 API 엔드포인트 상당수가 필요 없어졌으며, 민감한 DB 로직이 클라이언트 응답에 섞여 나갈 위험도 줄었습니다. 번들 크기도 프로젝트 구성에 따라 다르지만 눈에 띄게 줄었고요.
이 글은 Next.js Pages Router를 운영해본 경험이 있는 개발자를 대상으로 합니다. 새 프로젝트를 고민 중이신 분께도 도움이 되겠지만, 기존 코드베이스를 들고 이전하는 과정에서 생기는 현실적인 마찰을 주로 다룹니다. 본문에서 getServerSideProps가 등장하는데, 이는 "서버에서 데이터를 미리 불러와 페이지 컴포넌트에 주입하는 Pages Router 전용 함수"로 이해하시면 됩니다.
렌더링 경계를 어디에 그을지 명확히 설계하는 것이 서버 컴포넌트 마이그레이션의 핵심입니다. 클라이언트 컴포넌트 경계를 잘못 설정해서 번들 크기가 Pages Router 시절보다 오히려 늘어나거나, Context API가 서버에서 동작하지 않아 에러가 연쇄 발생하는 상황 — 직접 겪으면서 정리한 그 함정들을 공유합니다.
목차
핵심 개념
렌더링 경계 — 가장 먼저 이해해야 할 것
기존 Pages Router에서는 모든 페이지 컴포넌트가 사실상 클라이언트 코드였습니다. getServerSideProps로 서버에서 데이터를 가져오더라도, 컴포넌트 자체는 클라이언트 JS 번들에 포함됐죠. App Router는 이 기본값을 뒤집습니다. app/ 디렉터리 안의 파일은 기본적으로 서버 컴포넌트이고, 'use client'를 선언한 파일만 클라이언트 컴포넌트가 됩니다.
// app/products/page.tsx — 서버 컴포넌트 (기본값)
// 이 파일의 코드는 클라이언트 번들에 포함되지 않습니다
export default async function ProductsPage() {
// 실제 운영 코드에서는 세션 확인 등 인증/권한 검증 후 접근하는 것을 권장합니다
const products = await db.product.findMany();
return <ProductList products={products} />;
}
// components/AddToCartButton.tsx — 클라이언트 컴포넌트
'use client';
export function AddToCartButton({ productId }: { productId: string }) {
const [added, setAdded] = useState(false);
return (
<button onClick={() => setAdded(true)}>
{added ? '추가됨' : '장바구니 담기'}
</button>
);
}서버 컴포넌트(Server Component): 서버에서만 실행되고 결과 HTML만 클라이언트로 전달되는 React 컴포넌트입니다.
useState,useEffect같은 클라이언트 훅을 사용할 수 없는 대신, DB 직접 접근, 비밀 키 사용, 파일 시스템 접근이 가능합니다.
컴포넌트 합성 — 초반에 가장 많이 막히는 규칙
저도 이 규칙 때문에 처음에 반나절을 날렸는데요. 'use client'가 붙은 파일에서 서버 컴포넌트를 import했을 때 에러가 항상 나는 것도 아니라서 더 혼란스러웠습니다.
정확히 말하자면, import 자체는 기술적으로 가능하지만, 해당 모듈이 클라이언트 번들에 포함되면서 서버 컴포넌트로서의 속성을 잃게 됩니다. headers(), cookies() 같은 서버 전용 API를 쓰는 순간에서야 런타임 에러가 터지죠. 이 지점을 모르고 넘어가면 "가끔씩 나는 에러"처럼 보여서 디버깅이 더 까다로워집니다.
해법은 children prop을 통해 서버 컴포넌트를 슬롯처럼 주입하는 합성 패턴을 사용하는 것입니다.
// ❌ 이렇게 하면 서버 컴포넌트 속성이 사라집니다
'use client';
import { ServerOnlyComponent } from './ServerOnlyComponent';
// ServerOnlyComponent가 headers()나 cookies()를 쓴다면 런타임 에러 발생
// ✅ children으로 주입하는 올바른 패턴
'use client';
export function ClientWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>토글</button>
{/* 서버에서 미리 렌더링된 콘텐츠가 여기 위치합니다 */}
{isOpen && children}
</div>
);
}
// app/page.tsx — 서버 컴포넌트
import { ClientWrapper } from './ClientWrapper';
import { ServerData } from './ServerData'; // 서버 컴포넌트
export default function Page() {
return (
<ClientWrapper>
<ServerData /> {/* 서버 컴포넌트를 children으로 전달 */}
</ClientWrapper>
);
}이 패턴을 처음 접하면 "왜 이렇게 복잡하게 써야 하지?" 싶은데, 익숙해지고 나면 서버/클라이언트 경계가 코드에서 훨씬 선명하게 보이기 시작합니다.
Streaming + Suspense — 서버 컴포넌트의 숨은 강점
장점 표에 한 줄로만 넣기엔 아까운 기능입니다. 기존 방식에서는 페이지의 모든 데이터가 준비되어야 첫 화면이 표시됐는데, Streaming을 사용하면 빠른 데이터부터 순서대로 보여줄 수 있습니다.
App Router에서는 loading.tsx 파일 하나로 Suspense 경계를 자동으로 만들 수 있습니다.
// app/dashboard/loading.tsx — Suspense 폴백 자동 처리
export default function Loading() {
return <DashboardSkeleton />;
}
// app/dashboard/page.tsx
export default async function Dashboard() {
// 이 await가 완료되는 동안 loading.tsx의 스켈레톤이 표시됩니다
const analytics = await fetchAnalytics();
return <AnalyticsView data={analytics} />;
}섹션별로 세밀하게 제어하고 싶다면 <Suspense> 컴포넌트를 직접 감싸는 방식도 가능합니다. 느린 쿼리가 있는 대시보드 페이지라면, 전체가 준비될 때까지 기다리지 않고 빠른 섹션부터 보여주는 것만으로 체감 성능 개선이 꽤 극적으로 나타납니다.
실전 적용
예시 1: useEffect 데이터 페칭을 서버 컴포넌트로 교체
마이그레이션에서 효과가 가장 즉각적으로 나타나는 변환입니다. useEffect + fetch 조합은 컴포넌트가 마운트된 이후에야 데이터를 요청하기 때문에 로딩 스피너를 필연적으로 보여주게 됩니다. 서버 컴포넌트에서는 이 단계 자체가 사라집니다.
// Before: 클라이언트 페칭 — 번들에 포함, 마운트 후 추가 요청 발생
'use client';
export function UserProfile({ userId }: { userId: string }) {
// useState<User | null>의 꺾쇠 괄호는 TypeScript 제네릭 — User 또는 null을 담는 상태임을 명시합니다
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, [userId]);
if (!user) return <Spinner />;
return <div>{user.name}</div>;
}
// After: 서버 컴포넌트 — DB 직접 접근, 번들 비용 0, 스피너 없음
export async function UserProfile({ userId }: { userId: string }) {
const user = await db.user.findUnique({ where: { id: userId } });
// DB에 해당 유저가 없는 경우 notFound()로 404 처리
if (!user) notFound();
return <div>{user.name}</div>;
}| 비교 항목 | Before (클라이언트 페칭) | After (서버 컴포넌트) |
|---|---|---|
| JS 번들 포함 여부 | 포함됨 | 포함되지 않음 |
| 초기 로딩 스피너 | 발생 | 없음 |
| API 엔드포인트 필요 | 필요 (/api/users/:id) |
불필요 |
| 민감 정보 노출 위험 | 있음 (API 응답 노출 가능) | 없음 (서버에서만 처리) |
예시 2: 점진적 마이그레이션 — Pages Router와 App Router 공존
대형 프로젝트를 한꺼번에 재작성하는 Big Bang 방식은 위험합니다. Vercel이 공식 권장하는 전략은 app/ 디렉터리를 추가하고, 기존 pages/는 그대로 두는 것입니다.
project/
├── pages/ # 기존 Pages Router — 그대로 동작
│ ├── _app.tsx
│ ├── about.tsx
│ └── contact.tsx
├── app/ # 새로 추가한 App Router
│ ├── layout.tsx
│ └── dashboard/ # 새로 마이그레이션한 페이지
│ └── page.tsx
└── next.config.ts페이지 단위로 하나씩 app/으로 옮기는 방식입니다. 다만 Pages Router 페이지에서 App Router 페이지로 이동할 때(또는 반대로) 전체 페이지 로드가 발생하면서 UX 단절이 생기는 경우가 있습니다. 마이그레이션 순서를 잡을 때 자주 오가는 페이지들을 같은 라우터로 묶어서 처리하면 이 문제를 최소화할 수 있습니다.
예시 3: 서드파티 라이브러리 대응
실무에서 자주 맞닥뜨리는 상황인데, window나 document를 직접 참조하는 라이브러리들은 서버 컴포넌트에서 에러를 냅니다. Lottie, react-force-graph 같은 시각화 라이브러리들이 대표적입니다. next/dynamic으로 감싸면 SSR 없이 클라이언트에서만 로드할 수 있습니다.
// components/ChartWrapper.tsx
import dynamic from 'next/dynamic';
import type { ChartData } from './HeavyChart'; // 타입은 서버에서도 import 가능합니다
// ssr: false — 서버에서는 렌더링하지 않고, 클라이언트에서만 로드
const HeavyChart = dynamic(
() => import('./HeavyChart'),
{
ssr: false,
loading: () => <div>차트 로딩 중...</div>,
}
);
export function ChartWrapper({ data }: { data: ChartData }) {
return <HeavyChart data={data} />;
}장단점 분석
장점
이 중에서 실무 임팩트가 가장 컸던 건 "API 엔드포인트 불필요"와 "보안 격리" 두 가지였습니다. DB 접근 로직을 위한 REST 엔드포인트를 따로 만들 필요가 없고, 시크릿 키가 네트워크 응답에 섞여 나갈 걱정도 줄었습니다.
| 항목 | 내용 |
|---|---|
| 번들 크기 절감 | 서버 컴포넌트는 클라이언트 JS에 포함되지 않습니다. 절감 폭은 프로젝트 구성에 따라 편차가 크며, 클라이언트 컴포넌트 비중이 낮을수록 효과가 두드러집니다 |
| 직접 데이터 접근 | DB, 파일시스템, 내부 API를 서버에서 직접 호출할 수 있어 별도 API 엔드포인트가 불필요합니다 |
| 보안 격리 | 시크릿 키, DB 크리덴셜이 서버에만 존재하며 클라이언트에 노출되지 않습니다 |
| 스트리밍 렌더링 | Suspense 경계와 결합해 데이터 준비 순서대로 점진적 렌더링이 가능합니다. loading.tsx 하나로 간편하게 설정할 수 있습니다 |
| SEO 개선 | 클라이언트 렌더링은 JavaScript가 실행되어야 콘텐츠가 나타나기 때문에 크롤러가 내용을 제대로 읽지 못하는 경우가 생깁니다. 서버에서 완성된 HTML을 생성하면 이 문제를 피할 수 있습니다 |
단점 및 주의사항
이 중 가장 뒤통수를 맞았던 건 Context API 제한이었습니다. 전역 상태를 Context로 관리하던 앱에서 예상치 못한 에러가 연쇄적으로 터졌고, 원인을 찾는 데만 꽤 시간이 걸렸습니다. 마이그레이션 전에 미리 알고 있었으면 하루는 아꼈을 텐데요.
| 항목 | 설명 | 대응 방안 |
|---|---|---|
| 서드파티 라이브러리 호환성 | window/document 직접 참조 라이브러리에서 에러 발생 |
dynamic(() => import(...), { ssr: false })로 래핑 |
| Context API 제한 | createContext는 클라이언트 컴포넌트에서만 사용 가능 |
Provider를 'use client' 파일로 이동 |
| 브라우저 스토리지 접근 불가 | 서버에는 localStorage, sessionStorage가 없음 |
해당 로직을 클라이언트 컴포넌트로 분리 |
| 하이드레이션 불일치 | 서버 렌더링 결과와 클라이언트 결과가 다를 때 경고 및 성능 저하 | React Strict Mode로 조기 탐지 |
| Pages/App Router 혼용 UX | 두 라우터를 넘나드는 내비게이션에서 전체 페이지 로드 발생 | 내비게이션 경로를 같은 라우터로 묶어 마이그레이션 순서 설계 |
| App Router 응답 시간 | 캐싱 설정 미비, 서버 사이드 데이터 페칭 워터폴, 콜드 스타트 등의 원인으로 Pages Router보다 느리게 측정되는 사례 존재 | fetch 캐싱 전략, DB 쿼리 최적화, Suspense 스트리밍을 함께 설계 |
하이드레이션(Hydration): 서버에서 생성된 HTML에 클라이언트 JavaScript를 붙여 인터랙티브하게 만드는 과정입니다. 서버와 클라이언트의 렌더링 결과가 다르면 React가 전체를 다시 렌더링하게 됩니다.
실무에서 가장 흔한 실수
-
클라이언트 컴포넌트 경계를 너무 높게 설정하는 것 —
'use client'를 페이지 최상단에 붙이면 하위 컴포넌트가 전부 클라이언트로 취급됩니다. 초기에 이렇게 접근했다가 번들 크기가 Pages Router 시절보다 오히려 늘어나는 경험을 했습니다. 상호작용이 필요한 가장 작은 단위의 컴포넌트에만 경계를 두는 것이 핵심입니다. -
Context API를 서버 컴포넌트에서 그대로 사용하려는 것 — 전역 상태 관리 구조를 미리 점검하지 않으면 마이그레이션 중 에러가 연쇄적으로 발생합니다. 시작 전에 Context Provider 목록을 정리하고, 각각을
'use client'파일로 옮기는 계획을 세워두면 이 고통을 줄일 수 있습니다. -
codemod 없이 수동으로 변환하는 것 — Next.js 공식 codemod가
getServerSideProps변환,next/link문법 변경 같은 반복 작업을 자동으로 처리해줍니다. 규모가 조금만 커져도 수동 작업은 실수가 쌓입니다.
# 공식 codemod 실행 — 기계적인 변환 작업 자동화
npx @next/codemod@latest upgrade latest마치며
마이그레이션을 마치고 나면 "이걸 진작 했더라면"이라는 생각과 "이 함정은 미리 알았더라면"이라는 아쉬움이 동시에 남습니다. 핵심은 두 가지로 정리됩니다. 서버/클라이언트 경계를 명확히 설계하는 것, 그리고 클라이언트 컴포넌트의 범위를 최소화하는 것 — 이 두 원칙이 마이그레이션 내내 나침반이 됩니다. 나머지는 그 원칙을 일관되게 적용하면서 만나는 세부 과제들입니다.
지금 바로 시작해볼 수 있는 3단계:
- 현재 프로젝트에서
useEffect + fetch패턴을 찾아보시면 좋습니다. 전역 검색으로useEffect가 있는 컴포넌트를 나열하고, 그중 서버에서 처리해도 되는 것들을 골라내는 것이 출발점입니다. 이 목록이 마이그레이션의 1차 후보가 됩니다. app/디렉터리를 추가하고, 가장 단순한 페이지 하나를 먼저 옮겨보시면 좋습니다. codemod를 먼저 실행하면 기계적인 변환 작업의 상당 부분이 줄어드는 것을 확인하실 수 있습니다.- React DevTools와
next/bundle-analyzer로 변환 전후를 비교해보시면 좋습니다. 클라이언트 번들에 서버 전용 코드가 유입되지 않았는지 확인하고, Lighthouse나 Web Vitals로 TTFB·LCP 개선 여부를 측정해보시면 다음 마이그레이션 우선순위를 정하는 데 도움이 됩니다. Vercel 환경이라면 Speed Insights도 함께 활용할 수 있습니다.
다음 글: Zustand·Jotai와 서버 컴포넌트를 함께 쓰는 상태 관리 패턴 — 서버에서 초기값을 주입하고 클라이언트 스토어는 상호작용 상태만 담는 구체적인 설계 방법
참고 자료
- React Server Components Migration Guide | Dev Radar
- Next.js App Router Migration Best Practices 2026 | WebCraftDev
- Next.js 15 & 16 Features: Complete Migration Guide | Jishu Labs
- React Server Components: Practical Guide 2026 | inhaq.com
- Critical Security Vulnerability in React Server Components | react.dev
- Migrating to the Next.js App Router | Money Forward Developers Blog
- Next.js App Router Migration: The Good, Bad, and Ugly | Flightcontrol
- How to Migrate from Pages to the App Router | Next.js 공식 문서
- Rendering: Composition Patterns | Next.js 공식 문서
- Upgrading: Codemods | Next.js 공식 문서
- Making Sense of React Server Components | Josh W. Comeau
- Turbopack in 2026: The Complete Guide | DEV Community
- Component Composition Patterns | Vercel Academy
- Next.js 16 Upgrade Guide | Next.js 공식 문서