목록으로

Next.js App Router 상태 관리 — 네 가지 경계와 값의 수명

App Router가 만드는 모듈 스코프·직렬화·하이드레이션·네비게이션 네 경계를 기준으로 상태를 어디에 둘지 정리한다.


main
date
Sep 23, 2026
slug
nextjs-app-router-state
author
status
Public
tags
Nextjs
React
React-Query
ServerSide
summary
App Router가 만드는 모듈 스코프·직렬화·하이드레이션·네비게이션 네 경계를 기준으로 상태를 어디에 둘지 정리한다.
type
Post
thumbnail
category
💻 Frontend
updatedAt
Sep 23, 2026 01:17 AM
App Router에서 상태 관리 선택은 라이브러리 취향 문제가 아니라 값의 수명 문제다. 같은 export const store = createStore() 한 줄이 실행 위치에 따라 브라우저 탭 수명을 갖기도 하고 서버 프로세스 수명을 갖기도 한다. 이 문서는 App Router가 만드는 네 개의 경계와, 각 경계에서 상태를 어디에 둬야 하는지를 정리한다.
기준 버전은 Next.js 15 / React 19다. Next.js 16 변경점은 마지막 절에서 따로 다룬다.

1. 실행 시점과 값의 수명

시점
실행 위치
생성된 값의 수명
대표 코드
빌드 타임
빌드 머신
배포 단위
generateStaticParams, 프리렌더링
요청 타임
서버 프로세스
모듈 스코프 = 프로세스 수명 / 렌더 스코프 = 요청 1회
Server Component 렌더, Server Action
하이드레이션
브라우저
첫 렌더 1회
Client Component 최초 마운트
클라이언트 세션
브라우저
탭 수명
이벤트 핸들러, useState, 스토어
핵심은 두 번째 행이다. 같은 파일의 같은 줄이 모듈 스코프냐 렌더 스코프냐에 따라 수명이 다르고, 타입 시스템은 이를 구분하지 않는다.
Pages Router에서는 getServerSideProps가 요청 단위 경계를 명시적으로 드러냈다. App Router는 Server Component를 일반 컴포넌트와 같은 문법으로 작성하게 만들면서 이 경계를 문법에서 제거했다.

2. 경계 1 — 모듈 스코프

동작

// shared/lib/query-client.ts export const queryClient = new QueryClient({ /* ... */ });
이 모듈이 서버 번들에 포함되면 Node 프로세스가 모듈을 한 번 평가하고, 생성된 인스턴스는 프로세스 종료까지 유지된다. 그 사이의 모든 요청이 같은 객체를 참조한다. 요청 A가 쓴 값을 요청 B가 읽는다.
TanStack Query 문서:
Server: always make a new query client
Zustand 문서:
A Next.js server can handle multiple requests simultaneously. This means that the store should be created per request and should not be shared across requests.

'use client'는 면제가 아니다

Client Component도 SSR 단계에서 서버에서 한 번 실행된다. 'use client'는 "서버에서 실행되지 않는다"는 선언이 아니라 "클라이언트 번들에 포함되고 하이드레이션 대상이 된다"는 모듈 그래프 경계 선언이다.
따라서 아래는 모두 같은 범주다.
// 모듈 스코프 캐시 const seen = new Set<string>(); // 모듈 스코프 Zustand 스토어 export const useModalStore = create<ModalState>()(/* ... */); // 모듈 스코프 class instance export const cartStore = new CartStore();

증상이 늦게 나타나는 이유

서버 렌더 경로에서 해당 인스턴스에 쓰기가 없으면 누출이 발생하지 않는다. 초기 상태로 생성되고 변경은 전부 브라우저 이벤트 핸들러에서만 일어나는 경우가 이에 해당한다. 이는 설계로 보장된 안전이 아니라 호출 경로상 우연히 성립한 안전이다.
누출 조건:
  • SSR 경로에서 setQueryData / prefetchQuery 등 쓰기 호출이 추가될 때
  • Server Component가 해당 모듈, 또는 이를 재수출하는 배럴 파일을 import할 때
  • 요청 스코프 값(사용자 ID, 세션)을 모듈 스코프 구조에 기록할 때
개발 서버는 요청을 순차 처리하고 단독 테스트가 일반적이므로 로컬에서 재현되지 않는다.

배럴 파일 주의

// shared/lib/index.ts import { queryClient } from './react-query/query-client'; export { queryClient }; export { useToast } from './use-toast';
배럴에서 useToast 하나만 import해도 모듈 평가가 일어나 QueryClient가 생성된다. 'use client'가 없는 파일이 이 배럴을 import하면 서버 모듈 스코프에 인스턴스가 만들어진다.

판단 기준

모듈 스코프 허용:
  • 상수, 설정값, 룩업 테이블
  • 요청과 무관하고 변하지 않는 리소스 (폰트, 설정 파일 파싱 결과)
  • DB 커넥션 풀처럼 의도적으로 프로세스 단위여야 하는 것
모듈 스코프 금지:
  • 사용자별 데이터를 담을 수 있는 모든 것
  • 요청 처리 중 쓰기가 발생하는 모든 것
  • "세션 동안 유지"가 의도인 모든 것 (서버에서 세션 ≠ 프로세스)

패턴 A — 요청 단위 인스턴스 (cache())

React cache()는 렌더 패스 하나 안에서 같은 인자에 대해 같은 결과를 반환한다. 인자가 없으면 요청당 싱글톤이 된다.
// app/get-query-client.ts import { QueryClient } from '@tanstack/react-query'; import { cache } from 'react'; function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { // 하이드레이션 직후 즉시 refetch 방지 staleTime: 60 * 1000, }, }, }); } let browserQueryClient: QueryClient | undefined; export const getQueryClient = cache(() => { if (typeof window === 'undefined') { return makeQueryClient(); // 서버: 요청마다 새로 } if (!browserQueryClient) browserQueryClient = makeQueryClient(); return browserQueryClient; // 브라우저: 탭당 하나 });
서버는 격리, 브라우저는 재사용이 목적이므로 전략이 다르다. 브라우저에서 매번 새로 생성하면 Suspense 리렌더마다 캐시가 소실된다.

패턴 B — 트리 단위 인스턴스 (Provider + useRef)

create() 대신 zustand/vanilla의 createStore()를 쓰고 인스턴스를 컴포넌트 안에서 생성한다.
// stores/counter-store.ts import { createStore } from 'zustand/vanilla'; export type CounterStore = { count: number; increment: () => void }; export const createCounterStore = (initState = { count: 0 }) => createStore<CounterStore>()((set) => ({ ...initState, increment: () => set((s) => ({ count: s.count + 1 })), }));
// providers/counter-store-provider.tsx 'use client'; import { createContext, useContext, useRef, type ReactNode } from 'react'; import { useStore } from 'zustand'; import { createCounterStore, type CounterStore } from '@/stores/counter-store'; type StoreApi = ReturnType<typeof createCounterStore>; const StoreContext = createContext<StoreApi | null>(null); export function CounterStoreProvider({ children }: { children: ReactNode }) { const storeRef = useRef<StoreApi | null>(null); if (storeRef.current === null) { storeRef.current = createCounterStore(); // 마운트당 1회 } return <StoreContext value={storeRef.current}>{children}</StoreContext>; } export function useCounterStore<T>(selector: (s: CounterStore) => T): T { const store = useContext(StoreContext); if (!store) throw new Error('CounterStoreProvider 밖에서 호출됨'); return useStore(store, selector); // selector로 구독 범위 제한 }
컴포넌트 인스턴스는 요청마다, 마운트마다 새로 생성되므로 격리가 자동으로 성립한다. 서버에서 받은 초기값을 createCounterStore(initialState)로 주입하기도 쉽다.

Provider 배치 깊이

Next.js 문서:
You should render providers as deep as possible in the tree – notice how ThemeProvider only wraps {children} instead of the entire <html> document. This makes it easier for Next.js to optimize the static parts of your Server Components.
children으로 전달된 Server Component는 여전히 서버에서 렌더되므로 Provider를 루트에 둔다고 하위 전체가 클라이언트 컴포넌트가 되지는 않는다. 다만 정적 최적화 여지는 줄어든다.

3. 경계 2 — 직렬화 (RSC Payload)

RSC Payload 구성

Server Component는 HTML이 아니라 RSC Payload를 생성한다. Next.js 문서 정의:
The RSC Payload is a compact, serialized representation of the rendered React Server Components tree. It contains: · The rendered result of Server Components · Placeholders for where Client Components should be rendered and references to their JavaScript files · Any props passed from a Server Component to a Client Component
세 번째 항목에 따라 Server → Client props는 전부 직렬화 대상이다.
  • 첫 로드: HTML(비대화형 프리뷰) + RSC Payload(트리 조정) + JS(하이드레이션)
  • 이후 네비게이션: HTML 없음. RSC Payload만 프리페치되고 Client Component는 전적으로 클라이언트에서 렌더

직렬화 가능 / 불가능

구분
타입
가능
string, number, bigint, boolean, undefined, null, Symbol.for() 등록 심볼
가능
Array, Map, Set, TypedArray, ArrayBuffer, Date, FormData, Promise
가능
객체 리터럴로 만든 plain object (프로퍼티도 직렬화 가능해야 함)
가능
'use server'가 붙은 Server Function
불가
class, class instance (위 내장 타입 제외)
불가
일반 함수, 이벤트 핸들러
불가
null 프로토타입 객체
불가
전역 등록되지 않은 심볼 (Symbol('x'))

class instance에 대한 함의

App Router에서 class instance는 서버에서 생성해 클라이언트로 전달할 수 없다. 클라이언트에서 생성되어 클라이언트에서만 사는 객체여야 한다.
"서버에서 초기 데이터를 받아 스토어를 채운다"는 패턴을 쓰려면 평범한 객체로 낮춰 전달한 뒤 클라이언트에서 재구성해야 한다.
// Server Component const cart = await loadCart(userId); // CartAggregate 인스턴스 // 불가 <CartView cart={cart} /> // 가능 — 스냅샷으로 낮춰서 전달 <CartView snapshot={cart.toJSON()} />
// Client Component — 인스턴스 재구성 function CartView({ snapshot }: { snapshot: CartSnapshot }) { const storeRef = useRef<CartStore | null>(null); if (storeRef.current === null) storeRef.current = new CartStore(snapshot); // ... }
비용: toJSON() / fromJSON() 쌍의 유지보수, 서버·클라이언트 양쪽의 class 정의 공유. 얻는 것이 메서드 호출 문법뿐이라면 수지가 맞지 않는다.

모듈 그래프 경계

Next.js 문서:
Once a file is marked with "use client", all of its imports and the components it directly renders are included in the client bundle.
'use client' 파일이 import하는 모든 것이 클라이언트 번들에 포함된다. 무거운 도메인 로직 class를 Client Component에서 import하면 전체가 번들에 실린다.
단, children이나 props로 전달된 Server Component는 클라이언트 모듈 그래프에 포함되지 않는다. 슬롯 패턴이 동작하는 근거다.
// Modal은 Client Component, Cart는 서버 렌더 결과로 삽입 <Modal> <Cart /> </Modal>

4. 경계 3 — 하이드레이션

문제 조건

서버 HTML과 클라이언트 첫 렌더 결과가 다르면 하이드레이션 에러가 발생한다. 구조적으로 일치할 수 없는 값들:
  • localStorage 값 (테마 등)
  • window.matchMedia 결과
  • 타임존·로케일 의존 포맷팅
  • Zustand persist 미들웨어 복원 상태

useEffect 2패스 방식의 비용

function Theme() { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); if (!mounted) return null; return <span>{localStorage.getItem('theme')}</span>; }
동작하지만 클라이언트 사이드 네비게이션에서도 매번 2패스 렌더가 발생한다. 이미 브라우저에 있는 값을 한 프레임 비우고 다시 그리므로 깜빡임과 레이아웃 시프트가 생긴다.

useSyncExternalStore

const snapshot = useSyncExternalStore( subscribe, // (onStoreChange) => unsubscribe getSnapshot, // 클라이언트 값 getServerSnapshot, // SSR + 하이드레이션 전용 (선택) );
React 문서에 따르면 getServerSnapshot은 서버 HTML 생성 시점과 클라이언트 하이드레이션 시점에만 사용된다. 하이드레이션 이후 클라이언트 사이드 네비게이션에서는 곧바로 getSnapshot을 쓴다. 따라서 2패스 렌더가 최초 1회로 끝난다.
const emptySubscribe = () => () => {}; function useTheme() { return useSyncExternalStore( emptySubscribe, () => localStorage.getItem('theme') ?? 'light', // client () => 'light', // server ); }
getServerSnapshot은 서버와 클라이언트 초기 렌더에서 정확히 같은 값을 반환해야 한다.

getSnapshot 캐싱 규칙

스토어가 변하지 않은 동안 getSnapshot 반복 호출은 같은 값을 반환해야 한다. 매번 새 객체를 만들면 Object.is 비교가 항상 실패해 무한 루프에 빠지고 The result of getSnapshot should be cached 에러가 난다.
class Store { private items: Item[] = []; private listeners = new Set<() => void>(); subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }; // 저장된 참조 그대로 반환 getSnapshot = () => this.items; // 금지 — 무한 루프 // getSnapshot = () => [...this.items]; add(item: Item) { this.items = [...this.items, item]; // 새 참조로 교체 this.listeners.forEach((l) => l()); // 구독자 통보 } }
selector 기반 구독은 useSyncExternalStore만으로 불가능하고 use-sync-external-store/shim/with-selector의 useSyncExternalStoreWithSelector가 필요하다. Zustand의 useStore(store, selector)가 내부적으로 수행하는 작업이다.
💡
위 보일러플레이트를 직접 작성하는 것보다 Zustand vanilla store를 쓰는 편이 대부분 낫다. 직접 구현은 특수한 수명주기를 다룰 때로 한정한다.

5. 경계 4 — 네비게이션

클라이언트 사이드 트랜지션

\<Link\> 이동은 전체 페이지 로드가 아니다. Next.js 문서:
Instead of reloading the page, it updates the content dynamically by: · Keeping any shared layouts and UI. · Replacing the current page with the prefetched loading state or a new page if available.
/a → /b 이동 시 상태 보존 여부:
상태의 위치
보존
공유 레이아웃 안 Client Component useState
유지
레이아웃에 걸린 Provider (Zustand / Context)
유지
페이지 컴포넌트 안 useState
언마운트되며 소실
모듈 스코프 변수 (브라우저)
유지
URL searchParams
URL이 곧 상태
레이아웃 Provider에 있는 모달 스토어는 라우트가 바뀌어도 닫히지 않는다. 닫으려면 usePathname()을 구독해 명시적으로 처리해야 한다.
\<a href\>나 window.location 이동은 전체 리로드이므로 위 클라이언트 상태가 전부 초기화된다.

Partial Rendering

네비게이션 시 변경된 세그먼트만 재요청된다. staleTimes 문서:
shared layouts won't automatically be refetched on every navigation, only the page segment that changes.
결과적으로 레이아웃에서 읽은 서버 데이터(헤더의 알림 뱃지 등)는 페이지를 이동해도 갱신되지 않는다. router.refresh() 또는 revalidatePath / revalidateTag를 명시적으로 호출해야 한다.

클라이언트 라우터 캐시

RSC Payload는 브라우저 메모리에 캐시된다. Next.js 15 기본값:
  • 동적 렌더링 페이지: 0초 (캐시 안 함)
  • 정적 페이지 또는 prefetch={true}: 5분
dynamic 기본값은 v15.0.0에서 30초 → 0초로 변경됐다. 14에서 업그레이드한 프로젝트에서 "이동마다 서버를 다시 친다"는 체감 변화의 원인이다.
// next.config.js — 실험적 플래그, 프로덕션 권장 아님 module.exports = { experimental: { staleTimes: { dynamic: 30, static: 180 }, }, };
로딩 바운더리(loading.tsx)는 static 기간 동안 재사용 가능한 것으로 취급된다. 이 설정은 뒤로/앞으로 가기 캐시 동작에는 영향을 주지 않는다.

6. 뮤테이션 이후의 흐름

'use server'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; export async function createPost(formData: FormData) { const session = await auth(); if (!session?.user) throw new Error('Unauthorized'); await db.post.create({ /* ... */ }); revalidatePath('/posts'); redirect('/posts'); }
동작 특성:
  • Server Action은 POST로만 호출되며, UI를 거치지 않은 직접 POST 요청으로도 도달 가능하다. 모든 Server Function 안에서 인증·인가를 재확인해야 한다.
  • 응답 한 번에 갱신된 UI와 새 데이터가 함께 온다. 별도 refetch 라운드트립이 없다.
  • 클라이언트는 Server Action을 순차 디스패치한다. 병렬 데이터 페칭이 필요하면 Server Component나 Route Handler로 옮긴다.
  • redirect는 프레임워크가 처리하는 제어 흐름 예외를 throw한다. 이후 코드는 실행되지 않으므로 재검증은 redirect 앞에서 호출해야 한다.

클라이언트 상태 보존

Server Action 이후의 서버 재렌더는 전체 리로드가 아니다. Next.js 문서:
The server update applies to the current React tree, re-rendering, mounting, or unmounting components, as needed. Client state is preserved for re-rendered components, and effects re-run if their dependencies changed.
router.refresh()도 동일하다. 열려 있던 모달, 입력 중인 폼, 스크롤 위치가 유지된다. 폼을 비우려면 명시적으로 리셋해야 한다.

pending 상태

'use client'; import { useActionState, startTransition } from 'react'; import { createPost } from '@/app/actions'; export function Button() { const [state, action, pending] = useActionState(createPost, null); return ( <button onClick={() => startTransition(action)}> {pending ? '저장 중…' : '작성'} </button> ); }

7. 상태 배치 기준

위에서부터 판단하고, 해당하면 거기서 멈춘다.

1순위 — URL

공유 가능해야 하거나, 뒤로가기가 동작해야 하거나, 새로고침 후에도 남아야 하는 상태. 검색어, 필터, 정렬, 페이지네이션, 탭 선택, 열린 상세 패널 ID.
Server Component에서 searchParams로 직접 읽을 수 있고 공유·북마크·뒤로가기가 따라온다.
히스토리 스택을 늘리지 않으려면 네이티브 History API를 쓴다. Next.js 라우터와 통합되어 usePathname / useSearchParams가 동기화된다.
'use client'; import { useSearchParams } from 'next/navigation'; export function SortControl() { const searchParams = useSearchParams(); function updateSort(order: string) { const params = new URLSearchParams(searchParams.toString()); params.set('sort', order); window.history.pushState(null, '', `?${params}`); // 서버 왕복 없음 } return <button onClick={() => updateSort('asc')}>오름차순</button>; }

2순위 — 서버 (쿠키 / DB)

세션이나 디바이스를 넘어 유지돼야 하는 상태. 로그인, 언어, 테마.
테마를 localStorage에 두면 4절의 하이드레이션 문제가 반복된다. 쿠키에 두면 서버가 첫 렌더에서 값을 알므로 깜빡임이 없다. Server Action에서 쿠키를 set하면 Next.js가 현재 페이지와 레이아웃을 서버에서 재렌더해 UI에 반영한다.

3순위 — 서버 상태 캐시 (TanStack Query)

서버에서 온 데이터는 상태가 아니라 캐시다. 클라이언트 스토어에 복사하는 순간 원본과의 동기화 책임이 생긴다.
// app/posts/page.tsx import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; import { getQueryClient } from './get-query-client'; export default async function PostsPage() { const queryClient = getQueryClient(); await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: getPosts }); return ( <HydrationBoundary state={dehydrate(queryClient)}> <Posts /> </HydrationBoundary> ); }
staleTime이 0이면 하이드레이션 직후 즉시 refetch가 일어나 프리페치가 무의미해진다. 60초 수준이 공식 권고다.

4순위 — 클라이언트 전역 상태 (Zustand / Context)

서버와 무관하고 여러 컴포넌트가 공유해야 하는 순수 UI 상태. 모달 스택, 사이드바 열림, 토스트 큐, 장바구니 드래프트.
2절의 패턴 B를 적용해 Provider 안에 가두고, 필요한 깊이까지만 내린다.

5순위 — useState

컴포넌트 하나 안에서 끝나는 상태.

8. class instance의 적용 범위

부적합

일반적인 전역 상태 관리. 근거:
  • 직렬화 경계를 넘지 못한다 (3절)
  • 모듈 스코프 함정에 노출된다 (2절)
  • React에 변경을 알리려면 구독 메커니즘을 직접 구현해야 한다 (4절)
  • 결과물이 Zustand vanilla store와 기능적으로 동일해진다

적합

상태의 현재 값보다 객체가 살아있는 동안 수행하는 동작이 본질인 리소스. 커넥션 수명주기, 재연결, 백오프, 하트비트, 메시지 큐잉. useEffect 의존성 배열로 표현하면 복잡도가 급격히 올라가는 영역이다.
// features/notification/notification-stream.ts type Listener = (n: RealtimeNotification) => void; export class NotificationStream { private source: EventSource | null = null; private listeners = new Set<Listener>(); private retryDelay = 1_000; private timer: ReturnType<typeof setTimeout> | null = null; private seen = new Set<string>(); // 인스턴스 = 세션 스코프 connect(url: string) { this.disconnect(); const es = new EventSource(url, { withCredentials: true }); this.source = es; es.onopen = () => { this.retryDelay = 1_000; }; es.onmessage = (e) => { const n: RealtimeNotification = JSON.parse(e.data); if (this.seen.has(n.id)) return; // 재전송 중복 제거 this.seen.add(n.id); this.listeners.forEach((l) => l(n)); }; es.onerror = () => { es.close(); this.source = null; this.timer = setTimeout(() => this.connect(url), this.retryDelay); this.retryDelay = Math.min(this.retryDelay * 2, 30_000); // 지수 백오프 }; } subscribe(listener: Listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } disconnect() { if (this.timer) clearTimeout(this.timer); this.source?.close(); this.source = null; } }

연결 방식

모듈 스코프 싱글톤으로 export하면 2절의 함정에 그대로 걸린다. Provider 안에서 생성하고 마운트·언마운트에 수명을 묶는다.
'use client'; export function NotificationStreamProvider({ children }: { children: ReactNode }) { const streamRef = useRef<NotificationStream | null>(null); if (streamRef.current === null) streamRef.current = new NotificationStream(); const queryClient = useQueryClient(); useEffect(() => { const stream = streamRef.current!; const unsubscribe = stream.subscribe((n) => { showNotificationToast(n); // 목록·뱃지는 서버 상태. 스토어에 복사하지 않고 캐시를 무효화한다 void queryClient.invalidateQueries({ queryKey: queryKeys.notification._def }); }); stream.connect('/api/notifications/stream'); return () => { unsubscribe(); stream.disconnect(); }; }, [queryClient]); return <>{children}</>; }
📌
설계 원칙: class는 커넥션만 관리하고 데이터는 서버 상태 캐시로 넘긴다. 알림 목록을 class 내부 배열로 보유하면 서버와의 동기화 책임이 생기며, 이는 TanStack Query의 역할이다.
즉 class instance 도입은 "전역 상태 관리 방식 변경"이 아니라 "명령형 리소스 하나의 캡슐화"라는 국소적 결정이다.

9. Next.js 16 변경점

cacheComponents: true 플래그로 활성화되는 Cache Components가 전제를 일부 바꾼다.
  • use cache 디렉티브로 함수·컴포넌트 단위 캐싱을 선언한다. cacheLife로 수명을, cacheTag로 무효화 키를 지정한다.
  • PPR(Partial Prerendering)이 기본 동작이 된다. 정적 셸을 먼저 전송하고 동적 부분은 \<Suspense\> 뒤에서 스트리밍된다.
  • cookies() 호출이 라우트 전체를 동적 렌더링으로 전환하지 않는다. 문서: Reading cookies() here doesn't opt-in the whole route into dynamic rendering, the way the previous rendering model did.
  • use cache: private로 요청 데이터에 의존하는 결과에도 수명을 부여할 수 있다. 결과는 브라우저에만 저장된다.
  • use cache: remote는 인스턴스 간 공유되는 durable 캐시 핸들러로 결과를 옮긴다. 네트워크 왕복 비용이 있으므로 hit rate가 높을 때만 유효하다.
  • next/cache의 refresh()와 updateTag가 추가되어 무효화 의미가 세분화됐다. refresh()는 클라이언트 라우터를 갱신하지만 태그된 데이터는 재검증하지 않는다.
  • 캐시 키에 build id가 포함되므로 모든 캐시는 배포 단위로 초기화된다.
상태 관리 관점의 함의: "서버 데이터 = 매 요청 새로 조회"라는 가정이 더 약해진다. 캐시 수명이 코드에 선언적으로 명시되므로, 서버 데이터를 클라이언트 스토어에 복사해두는 패턴의 정당성이 줄어든다.
2~5절의 경계 — 모듈 스코프 수명, 직렬화, 하이드레이션, 네비게이션 보존 규칙 — 는 변하지 않는다. 프레임워크 구현 디테일이 아니라 서버·클라이언트 분리 아키텍처에서 나오는 제약이기 때문이다.

10. 체크리스트

모듈 스코프

모듈 최상단의 new / create() 중 사용자 데이터를 담을 수 있는 것이 있는가
배럴 파일(index.ts)이 그런 모듈을 재수출하고 있지 않은가
'use client'를 면제로 착각하고 있지 않은가 (SSR에서도 실행된다)

직렬화

Server → Client props에 class instance나 일반 함수가 포함되어 있지 않은가
Client Component가 무거운 도메인 모듈을 import해 번들을 키우고 있지 않은가

하이드레이션

localStorage / window 의존 값을 useEffect 2패스로 처리하고 있지 않은가
직접 구현한 스토어의 getSnapshot이 매번 새 참조를 반환하지 않는가
첫 페인트에 필요한 값을 쿠키로 옮길 수 있지 않은가

네비게이션

라우트 이동 시 닫혀야 할 UI 상태가 레이아웃 Provider에 잔존하지 않는가
레이아웃에서 읽는 서버 데이터에 갱신 경로가 있는가
staleTimes.dynamic 기본값 변경(30s → 0s)의 영향을 확인했는가

상태 배치

이 상태는 URL에 있어야 하지 않은가
서버 데이터를 클라이언트 스토어에 복사하고 있지 않은가
Provider가 필요 이상으로 루트에 가깝지 않은가

참고

© 2024. Dahoon06. All rights reserved.