본문 바로가기
JavaScript/React

Zustand(persist, Zukeeper, Redux DevTools 등)

by curious week 2025. 4. 2.

1. Zustand를 왜 쓰는가?

Zustand는 이런 상황에서 유용:

  • 컴포넌트 간 상태 공유가 필요할 때 (예: 로그인 상태, 장바구니)
  • Redux처럼 복잡한 설정 없이 바로 쓰고 싶을 때
  • 상태가 많아지고 컴포넌트에 prop drilling이 많아졌을 때

2. 기본 개념

Zustand는 다음 3가지를 중심:

store 전역 상태 저장소
setState 상태를 변경하는 함수
getState 상태를 조회하는 함수 (일반적으로 내부에서만 사용)

Zustand는 store를 만들고, 그 상태를 사용하는 것.


3. 기본 사용법

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

type BearState = {
  bears: number
  increase: () => void
}

export const useBearStore = create<BearState>((set) => ({
  bears: 0,
  increase: () => set((state) => ({ bears: state.bears + 1 })),
}))
// 컴포넌트.tsx
import { useBearStore } from './store'

function BearCounter() {
  const bears = useBearStore((state) => state.bears)
  const increase = useBearStore((state) => state.increase)

  return (
    <>
      <h1>{bears}마리의 곰</h1>
      <button onClick={increase}>곰 추가</button>
    </>
  )
}

4. 주요 특징

매우 간단한 API Redux보다 훨씬 적은 코드로 상태 관리 가능
selector 지원 useStore((state) => state.xxx) 형태로 필요한 상태만 구독
React Context X 내부적으로 context를 쓰지 않고도 전역 상태 공유
Immer, devtools, persist 플러그인 불변성 관리, Redux Devtools 연결, 로컬 스토리지 저장까지 지원

 

Zustand 구독(Subscription) 개념

Zustand에서 컴포넌트는 useStore((state) => state.속성) 형태로 원하는 상태만 구독합니다.

  • 구독하는 상태가 바뀔 때만 해당 컴포넌트가 리렌더링
  • 불필요한 리렌더링을 방지하면서도, 상태 변화 감지를 정밀하게 할 수 있음

선택적 구독

import { create } from 'zustand';

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

export const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  inc: () => set((state) => ({ count: state.count + 1 })),
  dec: () => set((state) => ({ count: state.count - 1 })),
}));

상태 구독 예시

function CounterDisplay() {
  const count = useCounterStore((state) => state.count); // count만 구독
  return <div>Count: {count}</div>;
}

function IncrementButton() {
  const inc = useCounterStore((state) => state.inc); // inc만 구독
  return <button onClick={inc}>+</button>;
}

CounterDisplay는 count만 바뀔 때 리렌더링되고,
IncrementButton은 리렌더링되지 않음 (함수는 변하지 않기 때문)

React Context는 전체 상태 변경 시 모든 하위 소비자가 다시 렌더링됩니다. Zustand는 이를 정확히 필요한 속성만 구독하여 해결합니다.


구독 구조 내부 동작

useStore(selector) // 내부적으로 shallow 비교 (===)
  • Zustand는 내부적으로 subscribe()로 selector 함수의 반환값을 비교
  • 값이 변하지 않으면 리렌더링하지 않음
  • shallow import해서 배열/객체의 얕은 비교도 지원 가능
import { shallow } from 'zustand/shallow';

const { x, y } = useStore(state => ({ x: state.x, y: state.y }), shallow);

고급: 수동 구독 (store.subscribe)

Zustand는 React 없이도 사용할 수 있도록 명시적인 subscribe 메서드도 제공합니다:

const unsubscribe = useCounterStore.subscribe(
  (newCount) => {
    console.log('Count changed:', newCount);
  },
  (state) => state.count // selector
);
  • subscribe(listener, selector)
  • 유틸리티나 외부 로직(예: 로깅, 애니메이션 트리거 등)에 유용

이건 주로 onChange 감지처럼 UI 외적인 용도에 활용합니다.

 


1shallow – 얕은 비교 최적화

Zustand는 selector를 사용해 특정 상태만 구독하게 할 수 있습니다. 하지만, 객체나 배열을 리턴하면 참조 비교(===)로 인해 항상 리렌더링될 수 있습니다.

이때 shallow를 사용하면 얕은 값 비교만으로 리렌더링을 방지할 수 있습니다.


리렌더링 줄이기

import { create } from 'zustand';
import { shallow } from 'zustand/shallow';

interface Store {
  x: number;
  y: number;
  setX: (v: number) => void;
  setY: (v: number) => void;
}

export const useMyStore = create<Store>((set) => ({
  x: 1,
  y: 2,
  setX: (v) => set({ x: v }),
  setY: (v) => set({ y: v }),
}));
// ❌ shallow 없이 객체 selector 사용 → 항상 리렌더링
const { x, y } = useMyStore((s) => ({ x: s.x, y: s.y }));

// ✅ shallow 사용 시 x 또는 y가 바뀌지 않으면 리렌더링 안 됨
const { x, y } = useMyStore((s) => ({ x: s.x, y: s.y }), shallow);

shallow는 언제 써야 할까?

  • 객체/배열을 리턴하는 selector를 사용할 때
  • 여러 속성을 묶어 구독할 때
  • 렌더 최적화를 신경 써야 할 컴포넌트에서만 쓰는 게 좋음

자주 쓰는 기능 확장

Zustand의 persist 미들웨어와 그 옵션들

zustand/middleware에 들어있는 기능 중 하나로, 상태를 브라우저 저장소(localStorage 또는 sessionStorage)에 저장하고 앱이 다시 실행됐을 때도 복구


1. persist 기본 구조

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

const useStore = create(
  persist(
    (set, get) => ({
      // 상태 정의
    }),
    {
      name: 'store-key', // 필수: 저장소에 저장될 key
      // 옵션들...
    }
  )
)

첫 번째 인자: 상태 정의 함수
두 번째 인자: 옵션 객체


2. 자주 쓰는 옵션들 정리

name: string (필수)

  • 브라우저 저장소에 저장될 key 이름.
  • localStorage.getItem(name)으로 확인 가능.
name: 'user-store'

storage?: StateStorage

  • 어떤 저장소를 쓸지 지정 (localStorage or sessionStorage)
  • 기본값은 localStorage
storage: createJSONStorage(() => sessionStorage)

createJSONStorage는 내부적으로 setItem / getItem 등을 자동으로 JSON stringify/parse


partialize?: (state) => Partial<State>

  • 상태 중 일부만 저장하고 싶을 때 사용 (예: token만 저장하고 싶을 때)
partialize: (state) => ({ token: state.token })

version?: number

  • 상태 구조가 바뀌었을 때 버전을 지정해서 마이그레이션에 활용.
  • 예를 들어 v1에선 name만 있었고, v2에선 age가 추가되었다면:
version: 2

migrate?: (persistedState, version) => newState

  • 저장된 버전과 현재 버전이 다를 때 상태를 마이그레이션할 수 있는 함수
migrate: (persistedState, version) => {
  if (version === 1) {
    return {
      ...persistedState,
      age: 0, // 새 필드 추가
    }
  }
  return persistedState
}

onRehydrateStorage?: (state) => (state?, error?) => void

  • 저장된 상태를 복원할 때 실행할 훅
  • 예를 들어 상태 복구 중 로딩 스피너를 보여주거나, 복구 성공 여부를 로깅
onRehydrateStorage: () => {
  console.log('hydrating...')
  return (state, error) => {
    if (error) console.error('hydration error', error)
    else console.log('hydration success', state)
  }
}

3. 전체 예제

import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

type AuthState = {
  token: string | null
  setToken: (token: string) => void
  logout: () => void
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      token: null,
      setToken: (token) => set({ token }),
      logout: () => set({ token: null }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({ token: state.token }),
      version: 1,
      onRehydrateStorage: () => (state, err) => {
        if (err) console.error('Rehydrate error:', err)
        else console.log('Rehydrated:', state)
      },
    }
  )
)

4. 언제 쓰면 좋을까?

  • 로그인 유지: JWT 토큰을 로컬에 저장해서 새로고침해도 로그인 유지
  • 장바구니, 임시 설정값: 사용자의 선택을 페이지 리로드 이후에도 복원
  • 다크모드 설정, 폼 임시 저장 등에도 유용

immer – 불변성 편하게 유지하기

Zustand는 원래 set 함수에서 직접 새로운 객체를 리턴해야 합니다.
하지만 immer를 쓰면, 직접 변경하는 것처럼 코드를 작성하되, 내부적으로는 불변성이 유지됩니다.

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface State {
  count: number;
  todos: string[];
  addTodo: (todo: string) => void;
}

export const useTodoStore = create(
  immer<State>((set) => ({
    count: 0,
    todos: [],
    addTodo: (todo) =>
      set((state) => {
        state.todos.push(todo); // ✅ 직접 변경처럼 보이지만 immer가 처리
        state.count += 1;
      }),
  }))
);

immer를 쓰면 set((state) => { ... }) 안에서 직접 state를 mutate해도 자동으로 immutable update가 됩니다.


immer는 언제 쓰는 게 좋은가?

  • 배열 push/pop/splice 등 복잡한 구조 변경이 많을 때
  • state.form.xxx 같이 중첩된 구조가 깊을 때
  • set({ ...prev, ... }) 코드가 지저분하고 반복될 때

Zustand의 devtools 미들웨어

Redux DevTools와 연동돼서 브라우저에서 상태 변화 추적.
디버깅, 타임트래블, 상태 롤백 등 개발 중 상태 확인이 필요한 상황에 매우 유용


1. 사전 준비

Zustand의 devtools 미들웨어는 Redux DevTools 확장 프로그램이 있어야 작동.

https://chromewebstore.google.com/category/extensions

 

Chrome Web Store

브라우저에 새로운 기능을 추가하고 탐색 환경을 맞춤설정합니다.

chromewebstore.google.com

에서 Redux devtool 설치

설치 후, 개발자 도구의 Redux 탭에서 상태 확인 가능.


2. 기본 사용법

import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

type CounterState = {
  count: number
  increase: () => void
}

export const useCounterStore = create<CounterState>()(
  devtools(
    (set) => ({
      count: 0,
      increase: () => set((state) => ({ count: state.count + 1 })),
    }),
    { name: 'CounterStore' }
  )
)

옵션 설명

  • name: DevTools에 표시될 이름
  • enabled: DevTools 연결 여부 (기본은 true, prod에서는 false로 끄는 게 일반적)

3. 조건부로 DevTools 연결 (실전 예제)

보통은 개발 모드에서만 devtools 사용

import { devtools } from 'zustand/middleware'

const useStore = create(
  devtools(
    (set) => ({
      count: 0,
      inc: () => set((s) => ({ count: s.count + 1 })),
    }),
    {
      name: 'MyStore',
      enabled: import.meta.env.MODE === 'development', // Vite 기준
    }
  )
)

Next.js나 CRA라면 process.env.NODE_ENV === 'development' 사용


4. Redux DevTools로 볼 수 있는 기능들

  • 액션 추적 (increase, reset 등 이름 지정 가능)
  • 상태 변경 로그
  • 상태 롤백, 타임트래블
  • set, get 기반으로 디버깅 쉽게 가능

5. 액션 이름 커스터마이징

set을 쓸 때 액션 이름을 지정하면 DevTools에서 보기 쉬워짐:

increase: () =>
  set((state) => ({ count: state.count + 1 }), false, 'increase count'),

set(partialState, replace?, actionName?)


6. persist와 함께 쓰기

Zustand는 미들웨어 체이닝이 가능. persist + devtools를 함께 쓰고 싶다면 이렇게:

import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'

const useStore = create(
  devtools(
    persist(
      (set) => ({
        token: '',
        setToken: (token) => set({ token }),
      }),
      {
        name: 'auth-storage',
      }
    ),
    { name: 'AuthStore' }
  )
)

순서 중요: devtools(persist(...)) 형태로 감싸야 Devtools에서도 상태 확인 가능.


zukeeper는 Zustand 상태를 DevTools 없이도 개발 중 더 쉽게 추적하고 디버깅할 수 있도록 도와주는 툴

공식 명칭은 zukeeper인데, 이름처럼 Zustand를 "관리자처럼" 들여다볼 수 있게 해주는 개발 툴


1. Zukeeper란?

Zukeeper는 브라우저 확장 프로그램 + 미들웨어 조합

  • zustand 상태를 시각적으로 추적하고 검사할 수 있음
  • Redux DevTools 없이도 상태 확인 가능
  • 여러 상태 store를 탭별로 구분, 시각적으로 구성
  • zukeeper는 Zustand 전용 DevTool
  • 상태를 브라우저에서 GUI로 실시간 추적 가능
  • withZukeeper()로 간단히 연동
  • persist, devtools 등과 함께 사용 가능
  • 개발 중 상태 관리와 디버깅을 시각적으로 도와줌

2. 설치 방법

브라우저 확장 설치

Chrome 웹 스토어에서 Zukeeper 검색 후 설치

패키지 설치 (npm)

npm install zukeeper

 


3. 기본 사용법

Zukeeper를 Zustand에 연결:
기존 create 호출 시 미들웨어로 연결

import { create } from 'zustand'
import { withZukeeper } from 'zukeeper'

type Store = {
  count: number
  increase: () => void
}

export const useCounterStore = create<Store>()(
  withZukeeper((set) => ({
    count: 0,
    increase: () => set((state) => ({ count: state.count + 1 })),
  }))
)

withZukeeper(...) 함수로 감싸는 것만으로 Zukeeper DevTool과 연동.


4. 옵션 사용법

withZukeeper는 옵션 가능:

withZukeeper(
  (set) => ({
    ...
  }),
  {
    name: 'MyCustomStore',
  }
)

 

name Zukeeper 탭에서 store 이름 지정

5. devtools, persist와 함께 쓰기

Zustand 미들웨어는 체이닝 가능, 다음처럼 사용 가능:

import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'
import { withZukeeper } from 'zukeeper'

type Store = {
  token: string
  setToken: (t: string) => void
}

export const useAuthStore = create<Store>()(
  withZukeeper(
    devtools(
      persist(
        (set) => ({
          token: '',
          setToken: (token) => set({ token }),
        }),
        {
          name: 'auth-storage',
        }
      ),
      { name: 'Auth Devtool' }
    ),
    { name: 'Auth Store (Zukeeper)' }
  )
)

6. 실제 사용 모습

확장 프로그램 설치 후 페이지에서 상태가 업데이트될 때, Chrome DevTools의 Zukeeper 탭에서 다음을 확인 가능:

  • 상태 필드별 현재 값
  • 업데이트 로그
  • 각 store 간 구분
  • 상태 변경 시점 비교

7. Zukeeper vs Redux DevTools vs Console.log

Zukeeper Zustand 전용, 시각적 디버깅, 간편 연동
Redux DevTools Redux 기반 툴, 타임트래블, 롤백 기능 강력
console.log 단순한 디버깅용, 반복 제거 어려움

Zukeeper는 특히 Redux DevTools 없이 상태를 가볍게 확인하고 싶은 경우 추천.


 

zustand/vanilla를 사용하는 상황과 이유

React 외부에서 상태 제어 Web Worker, service, 유틸 함수 등
React 이외 프레임워크 예: Vue, Svelte, React Native 외부 모듈 등
프레임워크에 구애받지 않는 중앙 상태 스토어 설계 독립된 도메인 상태 관리 가능
subscribe() 위주 활용 직접 상태를 감시하고 작업 수행할 때 유용

1. Vanilla Zustand 스토어 만들기

// store.ts
import { createStore } from 'zustand/vanilla';

type AppState = {
  count: number;
  inc: () => void;
};

export const counterStore = createStore<AppState>((set) => ({
  count: 0,
  inc: () => set((state) => ({ count: state.count + 1 })),
}));

이 스토어는 React를 전혀 사용하지 않고도 상태를 관리할 수 있습니다.


2. React에서 이 vanilla store 사용하기

// App.tsx
import { useStore } from 'zustand';
import { counterStore } from './store';

export function Counter() {
  const count = useStore(counterStore, (state) => state.count);
  const inc = useStore(counterStore, (state) => state.inc);

  return (
    <div>
      <div>Count: {count}</div>
      <button onClick={inc}>+</button>
    </div>
  );
}

useStore(store, selector)로 zustand/vanilla 스토어를 React 컴포넌트와 연결할 수 있습니다.


3. React 외부에서 사용하기

// anywhere.ts
import { counterStore } from './store';

counterStore.getState().inc(); // 직접 호출
console.log(counterStore.getState().count); // 현재 값 가져오기

const unsubscribe = counterStore.subscribe((state) => {
  console.log('Count changed:', state.count);
});

이처럼 React 외부에서도 상태 접근 및 구독이 가능하므로, 로직 분리나 테스트에 매우 유리합니다.


4. middleware도 가능 (immer, devtools 등)

import { createStore } from 'zustand/vanilla';
import { immer } from 'zustand/middleware/immer';

interface TodoState {
  todos: string[];
  add: (text: string) => void;
}

export const todoStore = createStore(
  immer<TodoState>((set) => ({
    todos: [],
    add: (text) =>
      set((state) => {
        state.todos.push(text);
      }),
  }))
);

구조적 정리

// store.ts (순수 스토어)
export const myStore = createStore<MyType>((set) => {...});

// component.tsx (React 연결)
const value = useStore(myStore, (s) => s.value);

// logic.ts (비 UI 구독 및 조작)
myStore.subscribe(...);
myStore.getState().doSomething();

장점

프레임워크 독립 React 없이도 상태 공유 가능
테스트 편의성 pure JS 객체라 단위 테스트 쉬움
외부 통합 가능 소켓, 서비스 워커, 이벤트 리스너와 통합 용이