Paper Clover
Next.js 앱 라우터와 함께한 1년 — 우리가 떠나기로 한 이유
Bun RSC 번들러 구현자의 Next.js App Router 1년 회고. 낙관적 업데이트 불가·모든 탐색이 페치·HTML 페이로드 2배·Turbopack 문제 까지 설계상 한계를 진단하고 TanStack Start 로 점진 마이그레이션한 전 과정.
핵심 요약
각 문제의 구조:
- 낙관적 업데이트:
page.tsx(서버) +Page.client.tsx(useState로optimisticUpdate) 로 최소 2파일 분리. 데이터 페칭 로직까지 묶으면 3파일. - 소프트 네비게이션:
/→/other→/이동 시 홈 RSC 를 다시 fetch.staleTime은 실험 기능이라 프로덕션 비권장. 팀의 회피책 —loading.tsx에서useQuery호출로 빈 RSC 응답 동안 실제 데이터 표시. - 레이아웃:
layout.tsx가 request 관찰 불가하고QueryClient도 각 레이아웃마다 재생성. 결국 monkey-patchedfetch캐싱에 의존. - 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 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 문서가 프로덕션 비권장으로 표시.
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.
Brotli 압축으로 해결된다는 오해. 압축 후에도 두 배 구조는 남고, 특히 모바일/저사양 디바이스에서 파싱 비용이 누적된다.
읽는 순서
- 1이론
리액트 공식 RSC 문서에서 '서버 컴포넌트 / 클라이언트 컴포넌트 / 서버 함수' 세 개념을 구분하고,
'use client'/'use server'가 번들러에게 주는 힌트를 정리. - 2구현
App Router 기반 예제 앱을 만들고 네트워크 탭에서 (1) 소프트 네비게이션 시 RSC 페이로드 재요청, (2) HTML 내
__next_f.push중복 콘텐츠를 직접 확인. - 3실무
현재 프로젝트에서 '이 페이지는 App Router 이점이 실제로 있는가' 를 페이지별로 분류. 대부분이
'use client'로 회귀했다면 대안(Pages Router / TanStack Start / Remix) 을 저울질할 데이터가 된다. - 4설명
'App Router 로 바꿀까요?' 질문에 (1) 콘텐츠 중심 블로그 — YES, (2) 상호작용 중심 SaaS — 이 아티클의 5가지 비용 나열, (3) 점진 마이그레이션 경로 가능성 순으로 답하는 연습.
면접 연결 질문
[감점 답변] 'useOptimistic 쓰면 된다'. [좋은 답변] useOptimistic 자체는 클라이언트 컴포넌트 내부에서만 사용 가능. 서버에서 초기 데이터를 가져와 클라이언트로 넘기려면 page.tsx(서버) → ClientPage.tsx('use client') → useOptimistic 순으로 파일이 최소 2~3개. Pages Router 의 getServerSideProps 는 단일 파일이라 트리 셰이킹으로 서버 코드만 제거되는 것과 대비된다.
[감점 답변] '캐시 쓰면 된다'. [좋은 답변] 이유 — RSC 는 서버에서 렌더된 트리를 통째로 보내고, Next 의 라우터는 '현재 레이아웃 + 새 세그먼트' 구조로 캐시. 경로가 달라지면 해당 세그먼트의 RSC 페이로드가 필요. 회피책 — (1) experimental.staleTimes (불안정), (2) TanStack Query 등 클라이언트 캐시를 loading.tsx 에서 사용, (3) 프로젝트 성격상 맞지 않는다면 TanStack Start/Remix 같은 대안.
[감점 답변] 'RSC 가 나쁘다'. [좋은 답변] 업무용 앱은 거의 모든 UI 가 동적 이라 결국 'use client' 가 붙는다. 그러면 RSC 의 '클라이언트에 JS 안 보냄' 이점은 실현되지 않고, 파일 분리/페이로드 중복/소프트 네비 비용 만 남는다. 정적 콘텐츠가 많은 블로그/문서 사이트에선 여전히 유효한 선택이지만, 상호작용 중심 SaaS 엔 맞지 않다고 결론.
자기 점검
DevTools Network 탭의 Size 만으로 판단. 실제로는 페이지 HTML view-source 안의 self.__next_f.push(...) 스크립트 에 들어간 문자열 크기를 확인해야 한다.
한 번에 모든 next/* import 를 바꿔야 한다는 오해. 실제로는 alias 로 같은 import 경로 를 유지하면서 내부 구현만 TanStack 로 교체 가능.