Zustand를 한 문장으로 설명하면
Zustand는 컴포넌트 바깥에 store를 만들고 React 컴포넌트가 selector로 필요한 상태만 구독하게 하는 가벼운 상태 관리 라이브러리입니다. Provider 없이도 시작할 수 있지만 모든 상태를 전역으로 옮기는 도구는 아닙니다. 상태의 소유 범위, 서버 데이터 여부, 팀이 필요한 규칙과 디버깅 수준을 먼저 판단해야 합니다.
검증 기준: 2026년 7월 19일 확인한 Zustand, React, Redux Toolkit, TanStack Query 공식 문서입니다. 라이브러리 이름만으로 성능이나 적합성을 단정하지 않고 상태의 성격을 기준으로 비교합니다.
- Zustand의 store·state·action·selector
- 도입 전 판단 질문
- 장바구니 props drilling 전후
- useState·Context·Zustand·서버 상태 선택표
- TypeScript 기본 store
- 원자 selector를 기본으로 쓰기
- 서버 상태와 UI 상태 분리
- Next.js에서 주의할 점
Zustand의 네 가지 구성
Zustand의 create는 React에서 사용할 hook과 getState, setState, subscribe 같은 API가 붙은 store를 만듭니다. 기본 코드는 짧지만 다음 네 역할을 나눠 읽어야 유지보수 기준이 생깁니다.
| 구성 | 의미 | 예시 |
|---|---|---|
| store | 공유 상태와 변경 함수를 담는 경계 | useCartStore |
| state | 현재 값 | items, isOpen |
| action | 상태를 변경하는 함수 | addItem, removeItem |
| selector | 컴포넌트가 구독할 값을 고르는 함수 | (state) => state.items.length |
Zustand가 reducer나 Context를 반드시 요구하지 않는다는 점은 시작 비용을 낮춥니다. 반대로 규칙을 강제하지 않는 만큼 store 경계와 action 이름, 서버 상태 처리 방식을 팀이 정해야 합니다.
도입 전에 네 가지를 묻는다
- 누가 이 값을 쓰는가? 한 컴포넌트 또는 가까운 부모·자식만 쓴다면
useState와 props가 더 명확할 수 있습니다. - 중간 컴포넌트가 쓰지 않는 값을 계속 전달하는가? 트리 깊이보다 실제 prop 전달의 반복과 변경 비용을 봅니다.
- 브라우저 UI 상태인가, 원격 서버 데이터인가? 캐시·재시도·stale 판정이 핵심이면 일반 전역 store와 다른 문제가 포함됩니다.
- 팀이 어떤 규칙을 필요로 하는가? 아주 작은 store인지, 복잡한 이벤트 흐름·미들웨어·감사 가능한 변경 이력이 필요한지 판단합니다.
“컴포넌트가 세 단계 이상 떨어져 있으면 Zustand” 같은 숫자 규칙은 정확하지 않습니다. 중간 컴포넌트가 값을 실제로 쓰는지, Context로 자연스럽게 표현되는 하나의 subtree인지, 여러 기능이 같은 상태 변경 규칙을 공유하는지를 함께 봐야 합니다.
장바구니 props drilling 전후
상품 카드에서 담은 장바구니 개수를 헤더와 사이드 패널이 함께 써야 한다고 가정해 보겠습니다. 상위 컴포넌트가 모든 값을 가지고 각 중간 컴포넌트로 넘길 수 있지만, 중간 계층이 장바구니를 쓰지 않으면서 전달만 하면 변경 지점이 늘어납니다.

// 중간 Layout은 cartCount를 사용하지 않고 Header로 전달만 합니다.
function App() {
const [items, setItems] = useState<CartItem[]>([])
return <Layout cartCount={items.length} addItem={(item) => setItems([...items, item])} />
}
function Layout(props: LayoutProps) {
return (
<>
<Header cartCount={props.cartCount} />
<ProductList addItem={props.addItem} />
</>
)
}
이 구조 자체가 항상 나쁜 것은 아닙니다. props는 데이터 흐름을 명시적으로 보여줍니다. 다만 헤더, 상품 카드, 장바구니 패널, 결제 요약이 같은 상태와 action을 직접 필요로 하고 중간 계층이 계속 전달만 한다면 store로 경계를 분리할 이유가 생깁니다.
import { create } from 'zustand'
type CartItem = { id: string; name: string; price: number }
type CartStore = {
items: CartItem[]
addItem: (item: CartItem) => void
removeItem: (id: string) => void
}
export const useCartStore = create<CartStore>()((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) => set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
}))
function CartBadge() {
const count = useCartStore((state) => state.items.length)
return <span aria-label={`장바구니 ${count}개`}>{count}</span>
}
function AddToCartButton({ product }: { product: CartItem }) {
const addItem = useCartStore((state) => state.addItem)
return <button onClick={() => addItem(product)}>장바구니 담기</button>
}
중간 Layout은 더 이상 장바구니 props를 전달하지 않습니다. Badge는 개수만, 버튼은 action만 선택합니다. 이것이 Zustand의 핵심 이점입니다. “전역 변수처럼 어디서든 접근한다”보다 컴포넌트가 필요한 slice를 직접 구독한다는 관점이 중요합니다.
상태 성격에 따른 선택표

| 도구 | 먼저 고려할 상황 | 주의점 |
|---|---|---|
useState + props |
소유 범위가 작고 데이터 흐름을 직접 보이고 싶음 | 전달만 하는 중간 계층이 많아지는지 확인 |
| Context + state/reducer | 하나의 subtree에 테마·계정·설정 같은 값을 제공 | Context는 본질적으로 느리다고 단정하지 말고 provider 값과 소비 범위를 측정 |
| Zustand | 여러 위치가 같은 클라이언트 상태와 action을 공유하고 selector 구독이 유용함 | store 범위·초기화·SSR·persist 정책을 직접 설계 |
| Redux Toolkit | 팀 표준, 명시적인 이벤트 흐름, 강한 도구 체계와 middleware가 중요함 | 규모가 작은 기능에는 필요한 구조가 더 많을 수 있음 |
| TanStack Query 등 | 원격 서버 데이터의 fetch·캐시·동기화·재시도가 핵심 | 모든 UI 상태까지 서버 상태 도구에 넣지 않음 |
Context는 값이 바뀌면 그 값을 읽는 하위 소비자가 업데이트되지만 이것만으로 “Context는 성능이 나쁘다”고 결론낼 수는 없습니다. provider를 나누고 값의 안정성을 관리하면 충분한 경우가 많습니다. React 공식 문서도 props와 children 구성을 먼저 검토하고 필요할 때 Context를 쓰도록 안내합니다.
Redux Toolkit과 Zustand도 단순히 “무겁다/가볍다”로 고르지 마세요. 작은 독립 store와 selector가 필요한지, action·middleware·개발 도구·팀 규칙을 일관되게 강제해야 하는지가 선택 기준입니다.
기본 store는 state와 action을 함께 타입으로 정의한다
TypeScript에서는 create<StoreType>()(...) 형태로 state와 action을 먼저 정의합니다. 이전 값을 이용해 갱신할 때는 함수 updater를 사용합니다. 아래 예제는 UI 패널의 열림 상태와 선택한 탭만 관리합니다.
import { create } from 'zustand'
type Tab = 'details' | 'reviews'
type ProductPanelStore = {
isOpen: boolean
tab: Tab
open: (tab?: Tab) => void
close: () => void
toggle: () => void
}
export const useProductPanelStore = create<ProductPanelStore>()((set) => ({
isOpen: false,
tab: 'details',
open: (tab = 'details') => set({ isOpen: true, tab }),
close: () => set({ isOpen: false }),
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
}))
action 이름은 화면 요소보다 사용자 동작을 드러내는 편이 좋습니다. setOpenTrue보다 open, beginCheckout, resetFilters처럼 의미를 표현하면 컴포넌트에서 읽기 쉽습니다.
전체 store 구독보다 원자 selector를 기본으로 둔다
selector 없이 useProductPanelStore()를 호출하면 전체 store를 구독합니다. 실제로 isOpen만 필요한 컴포넌트라면 해당 값만 선택하세요. 서로 독립적인 값은 selector를 두 번 쓰는 방식이 가장 단순합니다.
function ProductPanelToggle() {
const isOpen = useProductPanelStore((state) => state.isOpen)
const toggle = useProductPanelStore((state) => state.toggle)
return (
<button type='button' aria-expanded={isOpen} onClick={toggle}>
상품 정보 {isOpen ? '닫기' : '열기'}
</button>
)
}
여러 값을 객체나 배열로 묶어 반환해야 한다면 새 참조 때문에 다시 렌더링될 수 있습니다. 이때 useShallow로 최상위 결과를 얕게 비교할 수 있습니다. 중첩 객체의 깊은 동일성까지 검사하는 도구는 아니므로 상태 구조를 먼저 단순하게 유지하세요. 구체적인 적용 순서는 Zustand 학습 로드맵에서 이어서 확인할 수 있습니다.
상품 목록에서 서버 상태와 UI 상태를 분리한다
상품 화면에는 서로 성격이 다른 값이 함께 있습니다. API에서 받은 상품 목록·재고·가격은 서버가 원본인 원격 상태입니다. 선택한 정렬 방식, 사이드 패널 열림, 임시 비교 목록은 클라이언트 UI 상태일 수 있습니다.
| 값 | 주된 책임 | 가능한 위치 |
|---|---|---|
| 상품 목록·재고 | fetch, 캐시, 오류, 재시도, 갱신 | 서버 렌더링, framework fetch, TanStack Query 등 요구에 맞는 계층 |
| 정렬·페이지 | 공유 가능한 URL과 뒤로 가기 | search params 우선 검토 |
| 필터 패널 열림 | 현재 화면 UI | 로컬 state 또는 UI store |
| 장바구니 임시 항목 | 여러 컴포넌트 공유·서버 동기화 | Zustand와 서버 API의 책임을 분리 |
서버 상태라고 해서 TanStack Query를 반드시 써야 하는 것은 아닙니다. Next.js 서버 fetch나 단순 custom hook으로 충분한 앱도 있습니다. 반대로 캐시 무효화, 백그라운드 갱신, 재시도, optimistic update가 중요하면 검증된 서버 상태 도구를 도입할 이유가 생깁니다. 핵심은 원격 데이터 복제본을 Zustand에 넣고 캐시 정책을 무의식적으로 다시 구현하지 않는 것입니다.
Next.js에서는 module-global store를 조심한다
Next.js 서버는 여러 요청을 처리합니다. 서버 module에 사용자별 값이 든 단일 store를 만들면 요청 간 상태가 공유될 수 있습니다. Zustand 공식 Next.js 가이드는 store를 요청별로 만들고 React Server Component가 store를 읽거나 쓰지 않도록 권장합니다. RSC는 상태ful UI 저장소가 아니라 서버 데이터와 UI를 구성하는 경계입니다.
서버 렌더링 결과와 클라이언트 store의 초기값이 다르면 hydration 오류가 날 수 있습니다. 서버에서 전달한 초기값으로 같은 상태를 만들거나, 브라우저 저장값 복원 시점을 명시적으로 제어하세요. persist를 붙였다는 이유만으로 hydration이 자동 해결되는 것은 아닙니다.
Zustand를 쓰지 않아도 되는 경우
- 값이 한 컴포넌트 안에서만 쓰이고 초기화 시점도 명확합니다.
- 부모가 소유하고 두세 자식에게 props로 주는 구조가 오히려 데이터 흐름을 잘 보여줍니다.
- 테마나 현재 계정처럼 하나의 subtree에 제공하는 값이고 Context로 충분합니다.
- 문제의 핵심이 서버 데이터 fetch·캐시·갱신인데 클라이언트 store를 추가하려고 합니다.
- URL에 남아야 할 필터인데 store에만 두어 새로고침·공유·뒤로 가기가 깨집니다.
자주 묻는 질문
Zustand는 Context보다 항상 빠른가요?
항상 그렇다고 단정할 수 없습니다. Zustand의 selector 구독은 관련 없는 변경을 피하는 데 유용하지만 Context도 provider 범위와 값을 적절히 나누면 충분합니다. 실제 컴포넌트 구조와 렌더링을 측정하세요.
store는 하나만 만들어야 하나요?
아닙니다. 서로 다른 수명과 책임을 가진 상태를 하나의 거대한 store에 합치면 변경 영향이 커질 수 있습니다. 기능 경계와 함께 초기화되는 시점을 기준으로 나누세요.
API 응답도 Zustand에 넣어도 되나요?
가능하지만 fetch 중복 제거, 오류, stale 판정, 재시도, 무효화, 서버 렌더링을 직접 책임져야 합니다. 단순 스냅샷 공유인지 서버 상태 캐시인지 구분한 뒤 결정하세요.
“Zustand란? React 상태 관리 선택 기준과 기본 Store”에 대한 3개의 생각