- Published on
Next.js RSC에서 Zustand 상태 분리로 렌더링 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론: RSC에서 상태 관리가 더 예민해지는 이유
Next.js App Router의 React Server Components(RSC)는 기본 렌더링을 서버에서 수행하고, 클라이언트에는 필요한 상호작용 코드만 내려보내는 방향으로 설계되어 있습니다. 문제는 “상호작용”이 조금만 커져도 use client 경계가 넓어지고, 그 경계 안에서 전역 상태가 한 덩어리로 움직이기 시작하면 리렌더 범위가 급격히 커진다는 점입니다.
Zustand는 가볍고 직관적인 전역 상태 라이브러리지만, RSC 환경에서는 다음과 같은 실수가 성능을 갉아먹기 쉽습니다.
- 서버에서 만들 수 있는 데이터까지 클라이언트 스토어에 넣어
use client영역이 커짐 - 서로 다른 관심사의 상태(예: 세션, UI 토글, 필터, 장바구니)가 한 스토어에 섞여 변경이 잦아짐
- selector를 쓰지 않거나, 객체를 통째로 구독해 작은 변경에도 많은 컴포넌트가 리렌더
이 글에서는 “Zustand 상태 분리”를 중심으로, RSC의 장점을 유지하면서도 클라이언트 상호작용을 효율적으로 가져가는 구조를 단계적으로 정리합니다.
RSC에서 기억해야 할 경계: 서버 데이터 vs 클라이언트 상태
RSC에서 가장 먼저 할 일은 상태를 두 종류로 나누는 것입니다.
- 서버 데이터: DB 조회 결과, 검색 결과, 초기 페이지 모델, 권한/세션 기반 데이터 등.
fetch와 RSC에서 처리하고 props로 내려보내는 것이 기본. - 클라이언트 상태: 사용자가 클릭/입력하면서 바뀌는 UI 상태, 클라이언트 캐시, 임시 편집 버퍼 등. 이때만
use client가 필요.
핵심은 “서버에서 결정 가능한 값”을 클라이언트 스토어에 억지로 넣지 않는 것입니다. 그렇게 하면 하이드레이션 비용이 커지고, 상태 변경이 없어도 클라이언트 번들이 커집니다.
문제 패턴: 단일 스토어에 모든 것을 넣었을 때
다음은 흔히 보는 단일 스토어 예시입니다.
// app/store/useAppStore.ts
'use client'
import { create } from 'zustand'
type AppState = {
user: { id: string; name: string } | null
theme: 'light' | 'dark'
isSidebarOpen: boolean
cartCount: number
searchQuery: string
setTheme: (t: 'light' | 'dark') => void
toggleSidebar: () => void
setSearchQuery: (q: string) => void
setCartCount: (n: number) => void
}
export const useAppStore = create<AppState>((set) => ({
user: null,
theme: 'light',
isSidebarOpen: false,
cartCount: 0,
searchQuery: '',
setTheme: (theme) => set({ theme }),
toggleSidebar: () => set((s) => ({ isSidebarOpen: !s.isSidebarOpen })),
setSearchQuery: (searchQuery) => set({ searchQuery }),
setCartCount: (cartCount) => set({ cartCount })
}))
이 구조에서 isSidebarOpen만 바뀌어도 useAppStore()를 넓게 구독한 컴포넌트가 연쇄적으로 리렌더될 수 있습니다. 특히 selector 없이 useAppStore() 전체를 가져오거나, useAppStore((s) => s) 같은 구독을 하면 변경 영향이 폭발합니다.
RSC 관점에서는 더 치명적입니다. 스토어 파일이 use client이므로, 이를 import하는 컴포넌트는 전부 클라이언트 컴포넌트가 됩니다. 즉, “스토어를 어디서 import하느냐”가 곧 RSC 경계를 결정합니다.
해결 전략 1: 목적별 스토어 분리(도메인 분리)
가장 효과적인 방법은 “변경 주기”와 “관심사” 기준으로 스토어를 쪼개는 것입니다.
- UI 상태 스토어: 토글, 모달, 탭, 드로어 같은 빈번한 상태
- 세션/사용자 스토어: 로그인 이벤트가 있을 때만 바뀌는 상태
- 리스트/필터 스토어: 검색/필터 입력 등 중간 빈도로 바뀌는 상태
- 장바구니/카운터 스토어: 사용자 상호작용에 따라 바뀌지만 UI 전역을 흔들지 않게 분리
예시로 UI 스토어와 카트 스토어를 분리해봅니다.
// app/store/useUiStore.ts
'use client'
import { create } from 'zustand'
type UiState = {
isSidebarOpen: boolean
toggleSidebar: () => void
}
export const useUiStore = create<UiState>((set) => ({
isSidebarOpen: false,
toggleSidebar: () => set((s) => ({ isSidebarOpen: !s.isSidebarOpen }))
}))
// app/store/useCartStore.ts
'use client'
import { create } from 'zustand'
type CartState = {
cartCount: number
setCartCount: (n: number) => void
inc: () => void
}
export const useCartStore = create<CartState>((set) => ({
cartCount: 0,
setCartCount: (cartCount) => set({ cartCount }),
inc: () => set((s) => ({ cartCount: s.cartCount + 1 }))
}))
이렇게 나누면 toggleSidebar로 인해 카트 관련 UI가 리렌더되는 일을 크게 줄일 수 있고, 무엇보다 “스토어 import로 인해 클라이언트 경계가 확장되는 문제”를 통제하기 쉬워집니다.
해결 전략 2: selector로 구독 범위를 최소화
스토어를 분리했더라도 selector가 부정확하면 리렌더는 여전히 많습니다. Zustand의 기본 원칙은 다음입니다.
- 컴포넌트는 필요한 조각만 구독한다
- 객체를 통째로 반환하는 selector는 피한다
좋은 예:
'use client'
import { useUiStore } from '../store/useUiStore'
export function SidebarToggleButton() {
const isOpen = useUiStore((s) => s.isSidebarOpen)
const toggle = useUiStore((s) => s.toggleSidebar)
return (
<button type="button" onClick={toggle} aria-pressed={isOpen}>
Sidebar: {isOpen ? 'Open' : 'Closed'}
</button>
)
}
나쁜 예(변경에 취약):
'use client'
import { useUiStore } from '../store/useUiStore'
export function SidebarToggleButtonBad() {
const ui = useUiStore((s) => s)
return (
<button type="button" onClick={ui.toggleSidebar} aria-pressed={ui.isSidebarOpen}>
Sidebar: {ui.isSidebarOpen ? 'Open' : 'Closed'}
</button>
)
}
스토어가 커질수록 s => s는 변경 전파 범위를 넓힙니다. 분리와 selector는 세트로 가져가야 효과가 납니다.
해결 전략 3: RSC에서 “클라이언트 섬”을 작게 유지
RSC에서 렌더링 최적화의 본질은 “클라이언트로 내려보내는 영역을 최소화”하는 것입니다. Zustand를 쓰는 컴포넌트는 반드시 클라이언트 컴포넌트여야 하므로, 다음 패턴이 유용합니다.
- 페이지/레이아웃은 서버 컴포넌트로 유지
- 상호작용이 필요한 작은 위젯만
use client - 서버에서 데이터 패칭 후, 필요한 초기값만 위젯에 props로 전달
예시 구조:
// app/products/page.tsx (Server Component)
import { ProductList } from './product-list'
import { CartBadgeClient } from './cart-badge-client'
export default async function ProductsPage() {
const products = await fetch('https://example.com/api/products', {
cache: 'no-store'
}).then((r) => r.json())
return (
<div>
<header style={{ display: 'flex', justifyContent: 'space-between' }}>
<h1>Products</h1>
<CartBadgeClient />
</header>
<ProductList products={products} />
</div>
)
}
// app/products/cart-badge-client.tsx (Client Component)
'use client'
import { useCartStore } from '../store/useCartStore'
export function CartBadgeClient() {
const count = useCartStore((s) => s.cartCount)
return <div>Cart: {count}</div>
}
ProductList는 서버 컴포넌트로 유지할 수 있고, 카트 배지처럼 상호작용 상태가 필요한 부분만 클라이언트로 격리됩니다. 이 구조가 유지되면, Zustand를 쓰더라도 페이지 전체가 클라이언트로 강등되는 일을 피할 수 있습니다.
해결 전략 4: 스토어 초기화는 “필요한 곳에서만”
스토어에 서버 초기값을 주입하고 싶을 때가 많습니다. 예를 들어 서버에서 장바구니 수량을 계산해 내려주고, 클라이언트에서 이후 증감시키고 싶을 수 있습니다.
이때 흔한 실수는 “전역 Provider를 최상단에 두고 모든 페이지에서 하이드레이션”하는 것입니다. 대신, 실제로 필요한 페이지/위젯 근처에서만 초기화 로직을 두는 편이 안전합니다.
다음은 클라이언트 컴포넌트에서 초기값을 한 번만 반영하는 패턴입니다.
// app/products/cart-badge-client.tsx
'use client'
import { useEffect } from 'react'
import { useCartStore } from '../store/useCartStore'
type Props = {
initialCount: number
}
export function CartBadgeClient({ initialCount }: Props) {
const count = useCartStore((s) => s.cartCount)
const setCount = useCartStore((s) => s.setCartCount)
useEffect(() => {
setCount(initialCount)
}, [initialCount, setCount])
return <div>Cart: {count}</div>
}
그리고 서버에서 initialCount만 전달합니다.
// app/products/page.tsx
import { CartBadgeClient } from './cart-badge-client'
export default async function ProductsPage() {
const initialCount = await fetch('https://example.com/api/cart/count', {
cache: 'no-store'
}).then((r) => r.json())
return (
<header>
<CartBadgeClient initialCount={initialCount} />
</header>
)
}
중요한 점은 “초기화가 필요한 위젯만” 클라이언트로 만들고, 나머지는 서버로 남겨두는 것입니다.
해결 전략 5: 상태 분리 기준을 성능 지표로 정하기
상태를 어떻게 나눌지 감으로 결정하면, 스토어만 늘어나고 효과는 애매해질 수 있습니다. 실전에서는 아래 기준이 유용합니다.
- 변경 빈도: 매 클릭마다 바뀌는 상태는 다른 도메인과 분리
- 구독자 수: 많은 컴포넌트가 읽는 상태는 더 작은 조각으로 쪼개거나 selector를 강제
- 렌더 비용: 무거운 컴포넌트가 구독 중이면 상태를 더 세분화
- RSC 경계: 서버 컴포넌트가 import하면 안 되는 스토어는 폴더 구조로도 분리
예를 들어 다음처럼 폴더를 나누면, “서버에서 실수로 import”하는 사고를 줄일 수 있습니다.
app/
(server)/
products/
page.tsx
product-list.tsx
(client)/
widgets/
cart-badge-client.tsx
store/
useUiStore.ts
useCartStore.ts
Next.js가 폴더명으로 서버/클라이언트를 자동 분리해주지는 않지만, 팀 차원에서 규칙을 강제하기 좋습니다.
디버깅 체크리스트: “왜 리렌더가 많지?”를 빠르게 찾기
- 클라이언트 컴포넌트가 불필요하게 커졌는지 확인
- 스토어 import 때문에
use client가 전파되지 않았는지
- 스토어 import 때문에
- selector가
s => s또는 큰 객체를 반환하고 있지 않은지 - 서로 다른 관심사 상태가 한 스토어에서 함께 변경되고 있지 않은지
- 초기값 주입을 최상단 Provider에서 하고 있지 않은지
- 개발 모드에서 React Strict Mode로 이펙트가 두 번 실행되는 착시가 없는지
렌더링 최적화는 사용자 체감 지표로 이어집니다. 예를 들어 레이아웃 이동이 심해지면 CLS가 튀는데, 이는 상태/하이드레이션 설계와도 연결됩니다. 관련해서는 CLS 폭증 원인? 폰트 로딩·이미지 크기 고정 실전도 함께 보면 “렌더 단계에서 무엇이 흔들리는지”를 더 입체적으로 볼 수 있습니다.
타입 안정성: 스토어 분리 시 TS로 실수 줄이기
스토어를 여러 개로 나누면 타입 정의도 흩어지기 쉬워집니다. 이때 TypeScript의 satisfies를 활용하면 “객체 리터럴의 타입 추론을 유지하면서도” 계약을 강제할 수 있어 리팩터링에 유리합니다. 관련 패턴은 TS 5.x satisfies로 타입추론 깨짐 깔끔히 해결에서 더 자세히 다뤘습니다.
간단한 예시는 아래와 같습니다.
// app/store/cartTypes.ts
export type CartState = {
cartCount: number
setCartCount: (n: number) => void
inc: () => void
}
// app/store/useCartStore.ts
'use client'
import { create } from 'zustand'
import type { CartState } from './cartTypes'
const initial = {
cartCount: 0,
setCartCount: (_n: number) => {},
inc: () => {}
} satisfies CartState
export const useCartStore = create<CartState>((set) => ({
cartCount: initial.cartCount,
setCartCount: (cartCount) => set({ cartCount }),
inc: () => set((s) => ({ cartCount: s.cartCount + 1 }))
}))
이 방식은 스토어가 커질수록 “필드 누락” 같은 실수를 빨리 잡는 데 도움이 됩니다.
결론: RSC 시대의 Zustand는 “작게, 분리해서, 필요한 만큼만”
Next.js RSC에서 Zustand를 잘 쓰는 요령은 단순히 “전역 상태를 쓴다”가 아니라, RSC의 서버 중심 렌더링 모델을 깨지 않도록 클라이언트 섬을 최소화하는 데 있습니다.
정리하면 다음 3가지만 지켜도 효과가 큽니다.
- 상태를 도메인과 변경 주기 기준으로 분리해서 리렌더 전파를 차단
- selector로 필요한 조각만 구독하고, 큰 객체 구독을 피함
- 스토어 import가
use client전파를 만든다는 사실을 전제로 컴포넌트 경계를 설계
이 원칙대로 리팩터링하면, 같은 기능을 유지하면서도 렌더링 비용과 하이드레이션 부담을 눈에 띄게 줄일 수 있습니다.