FEInterview Prep

Paper Clover

Next.js 앱 라우터와 함께한 1년 — 우리가 떠나기로 한 이유

Bun RSC 번들러 구현자의 Next.js App Router 1년 회고. 낙관적 업데이트 불가·모든 탐색이 페치·HTML 페이로드 2배·Turbopack 문제 까지 설계상 한계를 진단하고 TanStack Start 로 점진 마이그레이션한 전 과정.

2025-12-18·10분 읽기
Next.jsReact아키텍처성능
원문 보기 ↗

핵심 요약

각 문제의 구조:

  1. 낙관적 업데이트: page.tsx(서버) + Page.client.tsx(useStateoptimisticUpdate) 로 최소 2파일 분리. 데이터 페칭 로직까지 묶으면 3파일.
  2. 소프트 네비게이션: //other/ 이동 시 홈 RSC 를 다시 fetch. staleTime 은 실험 기능이라 프로덕션 비권장. 팀의 회피책 — loading.tsx 에서 useQuery 호출로 빈 RSC 응답 동안 실제 데이터 표시.
  3. 레이아웃: layout.tsx 가 request 관찰 불가하고 QueryClient 도 각 레이아웃마다 재생성. 결국 monkey-patched fetch 캐싱에 의존.
  4. HTML 페이로드: 서버 렌더 HTML + self.__next_f.push([...]) 로 감싼 JSON 문자열 리터럴 을 같은 페이지에 중복 포함. Next.js 문서 홈페이지는 ~750KB, 콘텐츠가 두 번 나타남.

대체 방법 — Vite 기반 TanStack Start 로 증분 마이그레이션: resolve.alias.next 로 Next API 를 스텁 파일로 리디렉션, .tanstack.ts 확장자로 파일별 오버라이드.

App Router 의 RSC 는 '서버 컴포넌트/클라이언트 컴포넌트' 라는 언어적 이분법 을 강제한다. 이 경계는 use client 지시어와 번들러 규칙으로 고정되어 있어 기능별 파일 분리가 불가피 하다. 동적 데이터가 많은 업무 앱에서는 '대부분이 결국 클라이언트 컴포넌트' 로 회귀하는 모순이 생긴다.

App Router 를 '무조건 좋은 것' 으로 받아들이면 면접/의사결정에서 설계 트레이드오프를 놓친다. 이 글은 실제 프로덕션 문제를 다섯 축으로 분해한다.

  • 낙관적 업데이트: RSC 는 마운트 후 수정 불가 → useState 초기값 주입 패턴 강제
  • 소프트 네비게이션: 클라이언트가 이미 데이터를 갖고 있어도 RSC 페이로드를 다시 fetch
  • 레이아웃 제약: layout.tsx 는 request 정보에 접근 불가, QueryClient 공유 불가
  • 페이로드 2배: HTML + JSON-in-string RSC payload 가 같은 콘텐츠를 두 번 전송
  • Turbopack: 디버거 변수명 난독화, 비동기 클라이언트 컴포넌트 에러에 스택 없음

이 진단을 이해해야 Next.js 를 언제 쓰고 언제 피할지 판단할 수 있다.

학습 포인트

면접 답변으로 연결할 학습 포인트입니다.

'서버/클라이언트 컴포넌트' 경계의 실제 비용

RSC 에서 'use client'파일 단위 경계 다. 단일 페이지에서 '데이터 페치 → 상호작용 UI → 낙관적 업데이트' 를 하려면 최소 3파일:

src/app/user/[username]/
├── page.tsx            # Server: QueryClient prefetch
├── ClientPage.tsx      # 'use client' — useSuspenseQuery
└── UserProfile.tsx     # 'use client' — useState + mutate

Pages Router 의 getServerSideProps트리 셰이킹으로 단일 파일 구현이 가능했다. App Router 로 오면서 파일 수가 체계적으로 증가한다.

use clientHydrationBoundarydehydrateQueryClient
자주 하는 오해

'use client'page.tsx 에 그대로 선언하면 데이터 페칭이 클라이언트로 밀려나 SSR 이점이 사라진다.

소프트 네비게이션이 공짜가 아니다

<Link href="/"> 클릭 → 이전에 본 홈으로 돌아가도 Next.js 는 RSC 페이로드를 새로 요청. 캐시된 클라이언트 상태 재사용 불가.

// 저자 팀의 회피 코드 — headers() 로 소프트 네비 감지 후 prefetch 건너뜀
export function serverSidePrefetchQueries(queries) {
  if ((await headers()).get('next-url')) return;
  // ... 실제 prefetch
}

experimental.staleTimes 는 있지만 Next 문서가 프로덕션 비권장으로 표시.

soft navigationRSC payloadstaleTimesloading.tsx
자주 하는 오해

loading.tsx 가 '로딩 스켈레톤' 이라고만 이해. 저자 팀은 loading.tsx 안에서 useQuery 를 직접 호출해 클라이언트 캐시를 활용한다.

HTML 페이로드에 같은 콘텐츠가 두 번 들어있다

초기 페인트용 HTML + RSC 페이로드(JSON in JS string literal) 가 한 응답에 중복.

<body>
  <!-- 1) SSR 렌더된 HTML -->
  <div class="user-post-list">...실제 마크업...</div>
  <!-- 2) 같은 트리의 RSC 페이로드 -->
  <script>
    self.__next_f.push([2,
      "14:[\"$\",\"div\",null,{\"children\":[...실제 트리...]}]"
    ]);
  </script>
</body>

Suspense 경계 복원과 client component state 전달 때문에 구조적으로 피할 수 없음. 저자 측정으로는 /docs 홈이 HTML 250KB + 스크립트 500KB.

RSC payloaddual payloadhydration__next_f
자주 하는 오해

Brotli 압축으로 해결된다는 오해. 압축 후에도 두 배 구조는 남고, 특히 모바일/저사양 디바이스에서 파싱 비용이 누적된다.

읽는 순서

  1. 1이론

    리액트 공식 RSC 문서에서 '서버 컴포넌트 / 클라이언트 컴포넌트 / 서버 함수' 세 개념을 구분하고, 'use client' / 'use server' 가 번들러에게 주는 힌트를 정리.

  2. 2구현

    App Router 기반 예제 앱을 만들고 네트워크 탭에서 (1) 소프트 네비게이션 시 RSC 페이로드 재요청, (2) HTML 내 __next_f.push 중복 콘텐츠를 직접 확인.

  3. 3실무

    현재 프로젝트에서 '이 페이지는 App Router 이점이 실제로 있는가' 를 페이지별로 분류. 대부분이 'use client' 로 회귀했다면 대안(Pages Router / TanStack Start / Remix) 을 저울질할 데이터가 된다.

  4. 4설명

    'App Router 로 바꿀까요?' 질문에 (1) 콘텐츠 중심 블로그 — YES, (2) 상호작용 중심 SaaS — 이 아티클의 5가지 비용 나열, (3) 점진 마이그레이션 경로 가능성 순으로 답하는 연습.

면접 연결 질문

hardNext.js App Router 에서 '낙관적 업데이트' 를 구현할 때 파일 구조가 어떻게 나뉘고, Pages Router 대비 무엇이 늘어나는가?
힌트

[감점 답변] 'useOptimistic 쓰면 된다'. [좋은 답변] useOptimistic 자체는 클라이언트 컴포넌트 내부에서만 사용 가능. 서버에서 초기 데이터를 가져와 클라이언트로 넘기려면 page.tsx(서버) → ClientPage.tsx('use client') → useOptimistic 순으로 파일이 최소 2~3개. Pages Router 의 getServerSideProps 는 단일 파일이라 트리 셰이킹으로 서버 코드만 제거되는 것과 대비된다.

hardApp Router 가 '모든 네비게이션마다 RSC 페치' 를 하는 이유와, 이를 회피하는 합리적 방법은?
힌트

[감점 답변] '캐시 쓰면 된다'. [좋은 답변] 이유 — RSC 는 서버에서 렌더된 트리를 통째로 보내고, Next 의 라우터는 '현재 레이아웃 + 새 세그먼트' 구조로 캐시. 경로가 달라지면 해당 세그먼트의 RSC 페이로드가 필요. 회피책 — (1) experimental.staleTimes (불안정), (2) TanStack Query 등 클라이언트 캐시를 loading.tsx 에서 사용, (3) 프로젝트 성격상 맞지 않는다면 TanStack Start/Remix 같은 대안.

medium이 아티클의 저자는 왜 '서버 컴포넌트의 이점' 보다 '경계 관리 비용' 이 더 크다고 판단했는가?
힌트

[감점 답변] 'RSC 가 나쁘다'. [좋은 답변] 업무용 앱은 거의 모든 UI 가 동적 이라 결국 'use client' 가 붙는다. 그러면 RSC 의 '클라이언트에 JS 안 보냄' 이점은 실현되지 않고, 파일 분리/페이로드 중복/소프트 네비 비용 만 남는다. 정적 콘텐츠가 많은 블로그/문서 사이트에선 여전히 유효한 선택이지만, 상호작용 중심 SaaS 엔 맞지 않다고 결론.

자기 점검

'App Router 의 HTML 페이로드가 2배' 라는 주장을 네트워크 탭에서 직접 검증하려면 어디를 봐야 하는가?
view source__next_f.pushContent-Length
자주 하는 오해

DevTools Network 탭의 Size 만으로 판단. 실제로는 페이지 HTML view-source 안의 self.__next_f.push(...) 스크립트 에 들어간 문자열 크기를 확인해야 한다.

TanStack Start 로 마이그레이션 시 `resolve.alias.next = './src/tanstack-next/'` 가 하는 일은?
aliasstub점진 마이그레이션
자주 하는 오해

한 번에 모든 next/* import 를 바꿔야 한다는 오해. 실제로는 alias 로 같은 import 경로 를 유지하면서 내부 구현만 TanStack 로 교체 가능.