뒤로가기
Next.js App Router, 도입할 만한가

January 4, 2023

nextjsfrontend

팀에서 Next.js 13이 나왔을 때부터 App Router를 도입할지 논의했다. 근데 그때는 안정성이 불안해서 넘겼고, 14에서도 넘겼다. 15가 나온 시점에서 결국 결정을 내려야 했다. 새 프로젝트를 시작하는데 Pages Router로 갈 건지, App Router로 갈 건지.

2주간 PoC를 했다. 기존 프로젝트의 핵심 페이지 5개를 App Router로 다시 만들어봤다. 그 과정에서 느낀 걸 쓴다.

왜 전환을 고민했나#

Pages Router가 딱히 문제가 있는 건 아니었다. 잘 돌아가고, 팀원들도 익숙하고, 에코시스템도 안정적이다. 근데 몇 가지 불만은 있었다.

첫째, 레이아웃 공유가 번거롭다. _app.tsx에서 전체 레이아웃을 잡고, 페이지별로 다른 레이아웃이 필요하면 getLayout 패턴을 써야 한다. 동작은 하는데 공식 API가 아니라 관습적인 패턴이다 보니, 새로 합류한 팀원이 매번 "이건 뭔가요?"라고 물어봤다.

tsx
// Pages Router에서의 레이아웃 패턴 (관습적)
Page.getLayout = function getLayout(page: ReactElement) {
  return (
    <DashboardLayout>
      <SidebarLayout>{page}</SidebarLayout>
    </DashboardLayout>
  );
};

둘째, 데이터 페칭 방식이 페이지에 묶여 있다. getServerSidePropsgetStaticProps는 페이지 최상단에서만 쓸 수 있다. 컴포넌트가 자기에게 필요한 데이터를 직접 가져오려면 클라이언트 사이드에서 useEffect를 써야 한다. 서버에서 가져올 수 있는 데이터인데도.

셋째, Next.js 공식 문서가 이제 App Router 기준으로 쓰여진다. Pages Router 문서가 사라지진 않았지만, 새로운 기능은 App Router에서만 지원되는 경우가 많다. 장기적으로 Pages Router가 유지보수 모드에 들어가는 건 시간문제라는 느낌이 있었다.

PoC에서 좋았던 것#

레이아웃이 자연스럽다#

App Router의 가장 큰 장점은 파일 시스템 기반 레이아웃이다. layout.tsx를 만들면 하위 모든 라우트에 자동 적용된다.

text
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에서 조건 분기를 하거나, 레이아웃 컴포넌트를 직접 관리해야 한다.

서버 컴포넌트#

처음에는 "컴포넌트가 서버에서 실행된다고?"라는 개념이 잘 와닿지 않았다. 써보니까 진짜 편한 지점이 있다.

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로 내리는 것과 비교하면, 데이터 페칭 로직이 해당 컴포넌트 바로 옆에 있다. 코드의 응집도가 높아진다. 그리고 컴포넌트를 쪼개면 각 조각이 자신의 데이터를 가져올 수 있다.

tsx
// 이전: 페이지에서 모든 데이터를 가져와서 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으로 받는 경우는 예외). 이 경계를 어디에 두느냐가 설계의 핵심인데, 처음에는 감이 안 잡혔다.

tsx
// 이렇게 하면 전체가 클라이언트 컴포넌트가 됨
"use client";

export default function ProductPage() {
  const [quantity, setQuantity] = useState(1);

  // 이 안의 모든 것이 클라이언트에서 실행됨
  // 서버에서 가져올 수 있는 데이터도 클라이언트에서 가져오게 됨
  return (
    <div>
      <ProductDetail />  {/* 이것도 클라이언트 */}
      <ProductReviews /> {/* 이것도 클라이언트 */}
      <QuantitySelector value={quantity} onChange={setQuantity} />
    </div>
  );
}

올바른 패턴은 클라이언트 로직을 최대한 아래로 밀어내는 거다.

tsx
// 서버 컴포넌트 (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가지 레이어가 있고, 각각 동작 방식이 다르다.

typescript
// 이 요청은 캐싱됨 (기본값)
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의 단순함이 오히려 장점이다. 학습 비용을 감수할 만큼 프로젝트가 복잡한지를 먼저 따져봐야 한다.