Zustand 완전 정복 (v5 기준)

양정규·2025년 4월 10일
post-thumbnail

1. 기본 사용법

import { create } from 'zustand'
export const use이름Store = create((set, get) => {
  return {
    상태: 초깃값,
    액션: 함수
  }
})

create 함수로 스토어를 생성합니다.
create 함수의 콜백은 set, get 매개변수를 가지며, 이를 통해 상태를 변경하거나 조회할 수 있습니다.
create 함수의 콜백이 반환하는 객체에서의 속성은 상태(State)이고, 메소드는 액션(Action)이라고 부릅니다.
create 함수 호출에서 반환하는 스토어 훅(Hook)은, useCountStore와 같이 use 접두사와 Store 접미사로 명명해 각 컴포넌트에서 사용할 수 있습니다.

바로 사용해보자!

// src/store/useCountStore.ts
import { create } from 'zustand';

interface CountState {
  count: number;
  inc: () => void;
}

export const useCountStore = create<CountState>()((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
}));
// App.tsx
const App = () => {
  const { count, inc } = useCountStore();
  return (
    <button onClick={inc}>
      Clicked {count} times
    </button>
  );
};

✅ 사용법이 매우 단순합니다.


2. 많이 사용되는 Slice 패턴으로 스토어 사용해보기

모듈별로 “slice”를 쪼개고 합치는 구조. 의존성 최소화 & 타입 분리에 유리합니다. (zustand.docs.pmnd.rs)

🟢 STEP 0 ― 기본 방식

// store.ts
import { create } from 'zustand';

export const useStore = create()((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
  user: null as null | { name: string },
  login: (name: string) => set({ user: { name } }),
}));
문제점
모든 상태·액션이 한 파일에 뒤섞임 → 커질수록 가독성 ↓
타입이 커지면 inclogin 같은 액션이 자동 완성에서 묻힘

🟡 STEP 1 ― 파일 분리 + 함수형 Slice

// slices/counterSlice.ts
export const createCounterSlice = (set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
});

// slices/userSlice.ts
export const createUserSlice = (set) => ({
  user: null as null | { name: string },
  login: (name: string) => set({ user: { name } }),
});

// store.ts
import { create } from 'zustand';
import { createCounterSlice } from './slices/counterSlice';
import { createUserSlice } from './slices/userSlice';

export const useStore = create()((set, get) => ({
  ...createCounterSlice(set, get),
  ...createUserSlice(set, get),
}));

이득 : 도메인별 파일 분리 → 도메인별 로직이 분리


🟠 STEP 2 ― Slice 타입 분리 & 재사용 액션

// types/counter.ts
export interface CounterSlice {
  count: number;
  inc: () => void;
}

// slices/counterSlice.ts
import { CounterSlice } from '../types/counter';

export const createCounterSlice = (
  set,
): CounterSlice => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
});

이득 :

  • 각 Slice가 명시적 인터페이스를 갖게 되어 type확인이 가능

🟣 STEP 3 ― selectors.ts 도입

// selectors/counter.ts
import { useStore } from '../store';

export const useCount = () =>
  useStore((s) => s.count);               // 필요한 값만 호출

export const useInc = () =>
  useStore((s) => s.inc, (a, b) => a === b); // 필요한 함수만 호출

이득 :

  • 컴포넌트가 필요한 값만 구독 → 불필요 렌더링 감소
  • 컴포넌트 파일이 “UI 로직”에 집중, 스토어 호출은 한 줄

🔵 STEP 4 ― Immer·DevTools·Persist 한번에 래핑

import { create } from 'zustand';
import { devtools, persist, immer } from 'zustand/middleware';
import { createCounterSlice } from './slices/counterSlice';
import { createUserSlice } from './slices/userSlice';

export const useStore = create(
  devtools(
    persist(
      immer((set, get) => ({
        ...createCounterSlice(set, get),
        ...createUserSlice(set, get),
      })),
      {
        name: 'app-storage',
        partialize: (s) => ({ // persist 대상만 선택
          user: s.user,
        }),
      },
    ),
    { name: 'AppStore' },
  ),
);

이득 :

  • 불변성(immer) + DevTools + 영속화를 Slice 코드 변경 없이 장착
  • partialize필요 필드만 저장 → 용량·보안 부담 ↓

🟤 STEP 5 ― 테스트·SSR 친화 Store Factory

🏭 Store Factory란?

“필요할 때마다 새로운 Zustand 스토어 인스턴스를 만들어 주는 함수”
create() 를 직접 호출하는 대신 팩토리 함수를 정의해 두고, 거기서 fresh store를 리턴하도록 하는 패턴입니다.


1. 왜 필요한가?

상황Store Factory가 해결해 주는 문제
SSR / Next.js요청(Request)마다 독립된 스토어가 필요합니다. 싱글턴을 그대로 쓰면 다른 사용자의 데이터가 섞이는 버그·메모리 누수가 발생할 수 있어요.
테스트단위 테스트마다 초기 상태가 다른 스토어를 만들어야 합니다. Factory로 간단히 주입할 수 있습니다.
동적 인스턴스탭·모달처럼 “여러 개가 동시에 떠서 서로 영향을 주면 안 되는” UI를 구현할 때 각 인스턴스마다 스토어를 새로 만들 수 있습니다.
라이브러리/패키지NPM 패키지로 배포되는 컴포넌트가 내부적으로 Zustand를 쓰면서, 호스트 앱의 전역 네임스페이스를 오염시키지 않도록 합니다.

2. 기본 형태

// makeStore.ts
import { createStore } from 'zustand';
import { createCounterSlice } from './slices/counterSlice';
import { createUserSlice } from './slices/userSlice';

export type RootState = CounterSlice & UserSlice;

export function makeStore(preloaded?: Partial<RootState>) {
  return createStore<RootState>()((set, get) => ({
    ...createCounterSlice(set, get),
    ...createUserSlice(set, get),
    ...preloaded,            // 초기 상태 오버라이드
  }));
}
  • createStore (= vanilla 모드)로 리액트 의존 없이 스토어 인스턴스를 생성.
  • preloaded 매개변수로 테스트·SSR에서 초기 값을 주입 가능.

3. 사용 예시

3‑1. CSR(브라우저) 싱글턴

// useClientStore.ts
import { useStore as useZustandStore } from 'zustand';
import { makeStore } from './makeStore';

const clientStore = makeStore();       // 탭 당 1개
export const useClientStore = <T,>(sel: (s: RootState) => T) =>
  useZustandStore(clientStore, sel);

3‑2. Next.js SSR

// pages/index.tsx
export async function getServerSideProps() {
  const store = makeStore();
  await store.getState().fetchInitialData();   // 서버에서 데이터 채우기
  return {
    props: { initialZustandState: store.getState() },
  };
}

클라이언트에서는 makeStore(props.initialZustandState)하이드레이션.

3‑3. 테스트

test('counter increments', () => {
  const store = makeStore({ count: 5 });
  store.getState().inc();
  expect(store.getState().count).toBe(6);
});

4. 장단점

장점단점
상태 격리 → 데이터 섞임·메모리 누수 방지코드가 약간 길어짐 (팩토리 + 훅 래퍼 필요)
초기 상태 주입이 쉬움 (테스트·SSR)DevTools 같은 미들웨어를 훅 래퍼에서 다시 감싸야 함
동적 인스턴스 지원대부분의 단순 SPA는 굳이 필요 없음

5. 언제 써도 되나?

  • 순수 CSR SPA이고,
  • 전역 스토어를 싱글턴 하나로 충분히 쓰며,
  • SSR·멀티 인스턴스·라이브러리 배포 요구사항이 없다면

그냥 create() 한 번 호출해서 내보내는 싱글턴 스토어면 됩니다.
Store Factory는 “격리가 꼭 필요한 시나리오”에서만 도입하세요.

  • Store Factory = 스토어 인스턴스를 만들어 주는 함수
  • SSR, 테스트, 동적 UI, 패키지 개발처럼 상태 격리가 필요한 상황에서 유용
  • 평범한 CSR 앱이라면 싱글턴 스토어로도 충분—필요할 때만 쓰자!
// makeStore.ts
import { StateCreator, StoreApi, createStore } from 'zustand';
import { createCounterSlice } from './slices/counterSlice';
import { createUserSlice } from './slices/userSlice';

export type RootState = CounterSlice & UserSlice;

export const makeStore = (preloaded?: Partial<RootState>): StoreApi<RootState> =>
  createStore<RootState>((set, get) => ({
    ...createCounterSlice(set, get),
    ...createUserSlice(set, get),
    ...preloaded,
  }));
// store.ts (CSR 전용 훅)
import { useStore as useZustandStore } from 'zustand';
import { makeStore } from './makeStore';

const store = makeStore();
export const useStore = <T,>(selector: (s: RootState) => T) =>
  useZustandStore(store, selector);
  • 테스트: const testStore = makeStore({ count: 42 }) → 단위 테스트에서 원하는 초기 상태 주입
  • Next.js: getServerSideProps 마다 makeStore() 호출 → 요청 간 데이터 격리

📌 최종 구조 요약

src/
 ├─ store/
 │   ├─ makeStore.ts          # SSR·테스트 겸용
 │   ├─ store.ts              # CSR 훅
 │   └─ slices/
 │       ├─ counterSlice.ts
 │       └─ userSlice.ts
 ├─ selectors/
 │   ├─ counter.ts
 │   └─ user.ts
 └─ types/
     ├─ counter.ts
     └─ user.ts
개선 포인트결과
파일·타입·액션·셀렉터 분리가독성 ↑, 충돌 ↓
미들웨어 통합 래핑Slice 수정 없이 기능 추가/제거
Store Factory 도입테스트·SSR·MFE 재사용성 ↑
부분 persist보안·성능 균형

🟢 나아가기 ― React Query ✕ Zustand 

서버 상태(server state)는 React Query, UI·글로벌 상태(client state)는 Zustand에 맡기는 것이 정석입니다. 둘을 자연스럽게 엮는 3가지 패턴을 살펴봅니다.

7‑1. 필터 Slice → 쿼리 키 연동

// slices/filterSlice.ts
export interface FilterSlice {
  keyword: string;
  setKeyword: (k: string) => void;
}

export const createFilterSlice = (set): FilterSlice => ({
  keyword: '',
  setKeyword: (k) => set({ keyword: k }),
});
// Posts.tsx
import { useQuery } from '@tanstack/react-query';
import { useStore } from '@/store';

const fetchPosts = async (keyword: string) => {
  const res = await fetch(`/api/posts?search=${keyword}`);
  return res.json();
};

export default function Posts() {
  const keyword = useStore((s) => s.keyword);
  const { data, isLoading } = useQuery(['posts', keyword], () => fetchPosts(keyword));

  if (isLoading) return <p>Loading…</p>;
  return (
    <ul>
      {data.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}
  • 쿼리 키에 Zustand 값을 포함 → 키가 변하면 자동으로 refetch.
  • React Query 캐시는 그대로, 필터 UI는 Zustand로 독립 유지.

7‑2. 액션에서 queryClient.invalidateQueries() 호출

import { queryClient } from '@/lib/queryClient';

export const createTodoSlice = (set) => ({
  addTodo: async (title: string) => {
    await fetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) });
    queryClient.invalidateQueries(['todos']);  // ✅ 리스트 새로고침
  },
});
  • 비즈니스 액션 안에서 서버 호출 후 React Query 캐시 무효화.
  • 컴포넌트는 별도 로직 없이 최신 목록을 받습니다.

7‑3. useQuery 결과를 Zustand에 동기화 (옵션)

서버 데이터를 전역에서 읽기 전용으로 여러 컴포넌트가 소비해야 할 때만 권장.

const { data } = useQuery(['settings'], fetchSettings, {
  onSuccess: (settings) => useStore.setState({ settings }),
});
  • React Query가 캐싱·로딩·에러를 담당하고,
  • Zustand는 다른 액션이 참조할 수 있는 전역 스냅샷을 보관.
패턴언제 쓰나장점
쿼리 키에 Slice 값 포함검색·필터·페이지네이션구현 단순, 자동 refetch
invalidateQueries()POST/PUT/DELETE 이후 목록 동기화컴포넌트 수정 없이 최신화
onSuccess → setState설정·권한처럼 다수 컴포넌트가 필요중복 fetch 방지, 전역 접근

핵심은 역할 분리: 서버 동기화는 React Query, 앱 로컬 상태는 Zustand가 맡고, 교집합은 쿼리 키 or 캐시 무효화로 느슨하게 연결합니다.


📌 최종 구조 요약

src/
 ├─ store/
 │   ├─ makeStore.ts          # SSR·테스트 겸용 스토어 팩토리
 │   ├─ useClientStore.ts     # CSR 전용 훅 (싱글턴)
 │   └─ slices/
 │       ├─ counterSlice.ts
 │       ├─ userSlice.ts
 │       └─ filterSlice.ts
 ├─ selectors/                # (선택) 공통 셀렉터 모음
 │   ├─ counter.ts
 │   └─ user.ts
 ├─ types/                    # Slice 인터페이스 정의
 │   ├─ counter.ts
 │   └─ user.ts
 ├─ lib/
 │   └─ queryClient.ts        # React‑Query 전역 클라이언트
 ├─ pages/ or components/ …   # UI 컴포넌트
파일/폴더역할
store/makeStore.ts요청·테스트마다 fresh store를 만드는 팩토리 함수
store/useClientStore.ts브라우저 탭 당 1개의 싱글턴 스토어 + 얇은 훅 래퍼
store/slices/도메인별 순수 Slice 함수들 (비즈니스 로직만)
selectors/(선택) 재사용 셀렉터 훅으로 리렌더 최적화
types/Slice 인터페이스를 모아 타입 의존성 분리
lib/queryClient.tsReact Query QueryClient 싱글턴 & 설정

✅ 구조 설계 체크리스트

  • Slice는 상태·액션만: UI 의존 코드(React import) 금지 → 테스트 용이.
  • 미들웨어 래핑은 한곳: useClientStore.ts에서 DevTools·Persist 등 통합.
  • 폴더 깊이 최소화: 3‑depth를 넘기지 않도록 유지하면 탐색·import가 편해집니다.
  • SSR/CSR 분기 명확: 서버 로직은 makeStore, 클라이언트 로직은 useClientStore로 분리.

이 구조를 베이스로 팀·프로젝트 규모에 맞춰 선택적으로 selectors/, types/ 폴더를 생략하거나 추가하면 됩니다.

profile
롤보다 개발이 재밌는 프론트엔드 개발자입니다 :D

0개의 댓글