팀에서 Next.js 13이 나왔을 때부터 App Router를 도입할지 논의했다. 근데 그때는 안정성이 불안해서 넘겼고, 14에서도 넘겼다. 15가 나온 시점에서 결국 결정을 내려야 했다. 새 프로젝트를 시작하는데 Pages Router로 갈 건지, App Router로 갈 건지.
2주간 PoC를 했다. 기존 프로젝트의 핵심 페이지 5개를 App Router로 다시 만들어봤다. 그 과정에서 느낀 걸 쓴다.
왜 전환을 고민했나
Pages Router가 딱히 문제가 있는 건 아니었다. 잘 돌아가고, 팀원들도 익숙하고, 에코시스템도 안정적이다. 근데 몇 가지 불만은 있었다.
첫째, 레이아웃 공유가 번거롭다. _app.tsx에서 전체 레이아웃을 잡고, 페이지별로 다른 레이아웃이 필요하면 getLayout 패턴을 써야 한다. 동작은 하는데 공식 API가 아니라 관습적인 패턴이다 보니, 새로 합류한 팀원이 매번 "이건 뭔가요?"라고 물어봤다.
// Pages Router에서의 레이아웃 패턴 (관습적)
Page.getLayout = function getLayout(page: ReactElement) {
return (
<DashboardLayout>
<SidebarLayout>{page}</SidebarLayout>
</DashboardLayout>
);
};둘째, 데이터 페칭 방식이 페이지에 묶여 있다. getServerSideProps나 getStaticProps는 페이지 최상단에서만 쓸 수 있다. 컴포넌트가 자기에게 필요한 데이터를 직접 가져오려면 클라이언트 사이드에서 useEffect를 써야 한다. 서버에서 가져올 수 있는 데이터인데도.
셋째, Next.js 공식 문서가 이제 App Router 기준으로 쓰여진다. Pages Router 문서가 사라지진 않았지만, 새로운 기능은 App Router에서만 지원되는 경우가 많다. 장기적으로 Pages Router가 유지보수 모드에 들어가는 건 시간문제라는 느낌이 있었다.
PoC에서 좋았던 것
레이아웃이 자연스럽다
App Router의 가장 큰 장점은 파일 시스템 기반 레이아웃이다. layout.tsx를 만들면 하위 모든 라우트에 자동 적용된다.
app/
├── layout.tsx // 전체 레이아웃
├── page.tsx // 홈
├── dashboard/
│ ├── layout.tsx // 대시보드 레이아웃 (사이드바)
│ ├── page.tsx // /dashboard
│ ├── analytics/
│ │ └── page.tsx // /dashboard/analytics
│ └── settings/
│ └── page.tsx // /dashboard/settings
└── auth/
├── layout.tsx // 인증 레이아웃 (센터 정렬)
├── login/
│ └── page.tsx
└── register/
└── page.tsx
대시보드 안에서 페이지를 이동해도 사이드바는 리렌더링되지 않는다. 이게 Pages Router에서는 구현하기 까다롭다. _app.tsx에서 조건 분기를 하거나, 레이아웃 컴포넌트를 직접 관리해야 한다.
서버 컴포넌트
처음에는 "컴포넌트가 서버에서 실행된다고?"라는 개념이 잘 와닿지 않았다. 써보니까 진짜 편한 지점이 있다.
// app/dashboard/page.tsx
// 이 컴포넌트는 서버에서 실행된다. useState, useEffect 없음.
async function DashboardPage() {
const stats = await fetchDashboardStats();
const recentOrders = await fetchRecentOrders({ limit: 10 });
return (
<div>
<StatsGrid stats={stats} />
<RecentOrderTable orders={recentOrders} />
</div>
);
}getServerSideProps에서 데이터를 가져와서 props로 내리는 것과 비교하면, 데이터 페칭 로직이 해당 컴포넌트 바로 옆에 있다. 코드의 응집도가 높아진다. 그리고 컴포넌트를 쪼개면 각 조각이 자신의 데이터를 가져올 수 있다.
// 이전: 페이지에서 모든 데이터를 가져와서 props로 전달
// getServerSideProps → page → StatsGrid, RecentOrderTable
// 이후: 각 컴포넌트가 자신의 데이터를 가져옴
async function StatsGrid() {
const stats = await fetchDashboardStats();
return (/* ... */);
}
async function RecentOrderTable() {
const orders = await fetchRecentOrders({ limit: 10 });
return (/* ... */);
}PoC에서 힘들었던 것
"use client" 경계선
서버 컴포넌트에서는 useState, useEffect, onClick 같은 클라이언트 기능을 쓸 수 없다. 써야 하면 파일 상단에 "use client"를 붙여야 한다.
문제는, "use client"를 붙이면 그 컴포넌트의 하위도 전부 클라이언트 컴포넌트가 된다는 거다 (children으로 받는 경우는 예외). 이 경계를 어디에 두느냐가 설계의 핵심인데, 처음에는 감이 안 잡혔다.
// 이렇게 하면 전체가 클라이언트 컴포넌트가 됨
"use client";
export default function ProductPage() {
const [quantity, setQuantity] = useState(1);
// 이 안의 모든 것이 클라이언트에서 실행됨
// 서버에서 가져올 수 있는 데이터도 클라이언트에서 가져오게 됨
return (
<div>
<ProductDetail /> {/* 이것도 클라이언트 */}
<ProductReviews /> {/* 이것도 클라이언트 */}
<QuantitySelector value={quantity} onChange={setQuantity} />
</div>
);
}올바른 패턴은 클라이언트 로직을 최대한 아래로 밀어내는 거다.
// 서버 컴포넌트 (page.tsx)
export default async function ProductPage({ params }: Props) {
const product = await fetchProduct(params.id);
const reviews = await fetchReviews(params.id);
return (
<div>
<ProductDetail product={product} />
<ProductReviews reviews={reviews} />
<QuantitySelector productId={params.id} /> {/* 이것만 "use client" */}
</div>
);
}이 패턴을 팀원들이 자연스럽게 익히기까지 시간이 좀 걸렸다. 특히 "어디까지가 서버고 어디부터가 클라이언트인지"를 머릿속으로 그리는 게 처음에는 어렵다.
캐싱의 복잡함
Next.js App Router의 캐싱 전략은 솔직히 복잡하다. Request Memoization, Data Cache, Full Route Cache, Router Cache. 4가지 레이어가 있고, 각각 동작 방식이 다르다.
// 이 요청은 캐싱됨 (기본값)
const data = await fetch("https://api.example.com/data");
// 캐싱 안 함
const data = await fetch("https://api.example.com/data", {
cache: "no-store",
});
// 60초마다 재검증
const data = await fetch("https://api.example.com/data", {
next: { revalidate: 60 },
});fetch에 옵션을 붙여서 캐싱을 제어하는 건 괜찮은데, 문제는 fetch를 안 쓰는 경우다. Prisma로 DB를 직접 쿼리하거나, 외부 SDK를 쓸 때는 unstable_cache를 써야 한다. 이름에 unstable이 붙어 있는 게 마음에 걸린다.
PoC하면서 "왜 데이터가 안 바뀌지?"라고 한참 삽질한 적이 몇 번 있는데, 전부 캐싱 때문이었다. 개발 환경에서는 캐시가 비활성화되어 있어서 잘 되는데 프로덕션 빌드에서 안 되는 경우가 특히 짜증났다.
에코시스템 호환성
일부 라이브러리가 서버 컴포넌트를 지원하지 않는다. CSS-in-JS 라이브러리 중 상당수가 클라이언트 런타임에 의존하기 때문에, 서버 컴포넌트에서 쓸 수 없다. styled-components, emotion 등. Tailwind나 CSS Modules는 괜찮다.
상태 관리 라이브러리도 당연히 클라이언트 전용이다. 서버 컴포넌트에서는 상태라는 개념이 없으니까. 이건 제약이라기보다 패러다임의 차이인데, 기존에 전역 상태에 데이터를 넣어두고 쓰던 패턴을 서버 컴포넌트 + 직접 데이터 페칭으로 바꿔야 한다.
결국 어떤 판단을 내렸나
PoC 결과를 팀에 공유하고 투표를 했다. App Router로 가기로 했다. 결정적인 이유는 두 가지였다.
하나는 장기적 관점. Next.js의 미래가 App Router에 있다는 건 부정할 수 없다. 지금 Pages Router로 시작하면 1~2년 뒤에 또 전환을 고민해야 한다.
다른 하나는 레이아웃. 우리 프로젝트가 복잡한 중첩 레이아웃을 많이 쓰는 어드민이었다. App Router의 파일 시스템 기반 레이아웃이 이 요구사항에 딱 맞았다.
도입 후 한 달 정도는 팀원들의 생산성이 떨어졌다. 새로운 멘탈 모델에 적응하는 시간이 필요했다. 특히 서버/클라이언트 경계를 잘못 잡아서 에러가 나는 경우가 잦았다. 두 달쯤 지나니까 안정됐고, 세 달쯤 되니까 "이게 더 편한데?"라는 반응이 나왔다.
지금 돌아보면 맞는 선택이었다고 생각한다. 다만 "모든 프로젝트에 App Router를 쓰라"고 말할 수 있느냐고 묻는다면, 그건 아니다. 간단한 마케팅 페이지나 랜딩 페이지라면 Pages Router의 단순함이 오히려 장점이다. 학습 비용을 감수할 만큼 프로젝트가 복잡한지를 먼저 따져봐야 한다.
