junyeokk
Blog
React Ecosystem·2025. 11. 15

Zustand persist 미들웨어

웹 앱에서 상태를 관리할 때, 페이지를 새로고침하면 모든 인메모리 상태가 날아간다. 로그인 정보, 다크모드 설정, 장바구니 데이터 같은 건 새로고침 후에도 남아 있어야 자연스러운 사용자 경험을 제공할 수 있다.

보통은 상태가 바뀔 때마다 localStorage.setItem()으로 저장하고, 앱이 시작될 때 localStorage.getItem()으로 복원하는 코드를 직접 작성한다.

typescript
// 매번 이런 코드를 반복하게 된다
const saveToStorage = (state) => {
  localStorage.setItem('app-state', JSON.stringify(state));
};

const loadFromStorage = () => {
  const saved = localStorage.getItem('app-state');
  return saved ? JSON.parse(saved) : defaultState;
};

이 방식의 문제는 저장 시점, 복원 시점, 직렬화/역직렬화, 마이그레이션 등을 전부 수동으로 관리해야 한다는 것이다. 상태 필드가 추가되거나 이름이 바뀌면 기존 저장 데이터와 호환이 안 되는 문제도 생긴다.

Zustand의 persist 미들웨어는 이 전체 과정을 자동화한다. 스토어를 persist()로 감싸기만 하면 상태 변경 시 자동 저장, 앱 시작 시 자동 복원(hydration), 스토리지 엔진 교체, 부분 저장, 버전 관리와 마이그레이션까지 선언적으로 처리할 수 있다.


기본 사용법

persist 미들웨어로 스토어 생성 함수를 감싸고, name 옵션만 지정하면 바로 동작한다.

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

interface AuthStore {
  token: string | null;
  user: { name: string } | null;
  login: (token: string, user: { name: string }) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthStore>()(
  persist(
    (set) => ({
      token: null,
      user: null,
      login: (token, user) => set({ token, user }),
      logout: () => set({ token: null, user: null }),
    }),
    {
      name: 'auth-storage', // localStorage key 이름
    },
  ),
);

name은 스토리지에서 사용할 키 이름이다. 앱 내에서 여러 스토어를 persist 한다면 각각 고유한 이름을 줘야 한다. 기본 스토리지는 localStorage이고, 별도 설정 없이도 JSON.stringify/JSON.parse로 직렬화/역직렬화가 자동으로 이루어진다.

set()을 호출해서 상태가 바뀔 때마다 persist 미들웨어가 자동으로 스토리지에 저장한다. 앱을 새로고침하면 스토어가 생성되는 시점에 스토리지에서 값을 읽어와서 인메모리 상태에 병합(hydration)한다.


내부 동작 원리

persist 미들웨어가 하는 일은 크게 세 단계로 나뉜다.

1. 스토어 생성 + hydration

스토어가 처음 만들어질 때, persist 미들웨어는 스토리지에서 저장된 값을 읽어온다. 이 과정을 hydration이라고 부른다.

text
스토어 생성 → 초기 상태 설정 → 스토리지에서 읽기 → 병합(merge) → 완료

동기 스토리지(localStorage)를 사용하면 hydration이 스토어 생성과 동시에 완료된다. 즉, 스토어를 사용하는 시점에 이미 복원된 상태가 들어있다.

비동기 스토리지(AsyncStorage 등)를 사용하면 hydration이 microtask로 지연된다. 컴포넌트의 첫 번째 렌더링 시점에는 아직 복원이 안 됐을 수 있다.

2. 상태 변경 시 저장

set()이 호출되면 persist 미들웨어가 새 상태를 가로채서 스토리지에 기록한다. partialize 옵션이 있으면 지정된 필드만 추출해서 저장한다.

text
set() 호출 → 인메모리 상태 업데이트 → partialize 적용 → JSON 직렬화 → 스토리지 기록

3. 스토리지 포맷

실제로 저장되는 데이터 구조는 다음과 같다.

json
{
  "state": { "token": "abc123", "user": { "name": "J" } },
  "version": 0
}

state에 실제 상태가 들어가고, version은 마이그레이션용 버전 번호다. 이 구조를 알고 있으면 DevTools나 콘솔에서 직접 확인할 때 헷갈리지 않는다.


주요 옵션

partialize — 부분 저장

스토어의 모든 상태를 저장할 필요가 없는 경우가 많다. 함수(액션)나 임시 UI 상태는 저장하면 안 되고, 민감한 정보도 선별해야 한다. partialize 옵션으로 저장할 필드만 골라낼 수 있다.

typescript
const useStore = create<AppStore>()(
  persist(
    (set) => ({
      theme: 'dark',
      language: 'ko',
      isModalOpen: false,  // 이건 저장하면 안 됨
      tempInput: '',       // 이것도
      setTheme: (t: string) => set({ theme: t }),
    }),
    {
      name: 'app-settings',
      partialize: (state) => ({
        theme: state.theme,
        language: state.language,
      }),
    },
  ),
);

필요한 것만 고르는 방식(화이트리스트)이 가장 명시적이고 안전하다. 새 필드를 추가해도 의도치 않게 저장되는 일이 없다.

반대로 제외할 것만 빼는 방식(블랙리스트)도 가능하다.

typescript
partialize: (state) =>
  Object.fromEntries(
    Object.entries(state).filter(
      ([key]) => !['isModalOpen', 'tempInput'].includes(key),
    ),
  ),

블랙리스트 방식은 새 필드가 추가되면 자동으로 저장 대상에 포함되므로, 실수로 불필요한 데이터가 저장될 수 있다. 대부분의 경우 화이트리스트 방식을 권장한다.

storage — 스토리지 엔진 교체

기본값은 localStorage이지만, createJSONStorage 헬퍼를 사용해서 다른 스토리지로 교체할 수 있다.

typescript
import { persist, createJSONStorage } from 'zustand/middleware';

const useStore = create<Store>()(
  persist(
    (set) => ({ /* ... */ }),
    {
      name: 'my-store',
      storage: createJSONStorage(() => sessionStorage),
    },
  ),
);

sessionStorage는 탭을 닫으면 사라지므로, 탭 단위로만 유지되어야 하는 상태에 적합하다. React Native에서는 AsyncStorage, 대용량 데이터가 필요하면 IndexedDB 기반 라이브러리를 연결할 수 있다.

createJSONStorage는 내부적으로 JSON.stringify/JSON.parse를 처리해주는 래퍼다. 커스텀 스토리지 엔진을 만들 때도 이 함수를 통해 StateStorage 인터페이스에 맞는 객체를 생성하면 된다.

typescript
// 커스텀 스토리지 예시: IndexedDB 래퍼
const indexedDBStorage = createJSONStorage(() => ({
  getItem: async (name) => {
    const value = await idb.get(name);
    return value ?? null;
  },
  setItem: async (name, value) => {
    await idb.set(name, value);
  },
  removeItem: async (name) => {
    await idb.del(name);
  },
}));

version + migrate — 버전 관리와 마이그레이션

앱이 발전하면서 상태 구조가 바뀌는 건 불가피하다. 필드 이름을 바꾸거나, 타입을 변경하거나, 새 필드를 추가해야 할 때 기존에 저장된 데이터와의 호환성을 어떻게 유지할 것인가? versionmigrate 옵션이 이 문제를 해결한다.

typescript
const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      colorMode: 'dark',  // v0에서는 'theme'이라는 이름이었음
      locale: 'ko',       // v1에서 새로 추가
    }),
    {
      name: 'settings',
      version: 2,
      migrate: (persisted: any, version: number) => {
        // v0 → v1: 필드 이름 변경
        if (version === 0) {
          persisted.colorMode = persisted.theme;
          delete persisted.theme;
        }
        // v1 → v2: 새 필드 추가 (기본값 설정)
        if (version < 2) {
          persisted.locale = persisted.locale ?? 'en';
        }
        return persisted;
      },
    },
  ),
);

동작 방식은 이렇다:

  1. 스토리지에서 데이터를 읽을 때 저장된 version과 코드의 version을 비교한다.
  2. 버전이 다르면 migrate 함수를 호출한다. 인자로 저장된 상태와 저장된 버전 번호가 전달된다.
  3. migrate가 반환한 상태가 새로운 상태로 사용된다.
  4. 새로운 버전으로 다시 스토리지에 저장된다.

migrate가 없으면 버전이 다를 때 저장된 데이터를 무시하고 초기 상태를 사용한다. 데이터를 살리고 싶으면 반드시 migrate를 구현해야 한다.

merge — 병합 전략

hydration 시 스토리지에서 읽어온 상태와 코드의 초기 상태를 어떻게 합칠지 결정한다. 기본 동작은 얕은 병합(shallow merge)이다.

typescript
// 기본 동작 (shallow merge)
// currentState: { foo: { bar: 0, baz: 1 } }
// persistedState: { foo: { bar: 99 } }
// 결과: { foo: { bar: 99 } }  ← baz가 사라짐!

중첩 객체가 있으면 얕은 병합으로는 부족하다. 스토리지에 저장된 객체가 코드에서 정의한 초기 값의 하위 필드를 덮어쓰면서, 새로 추가된 필드가 사라지는 문제가 생긴다.

typescript
import deepMerge from 'deepmerge'; // 또는 lodash.merge

const useStore = create<Store>()(
  persist(
    (set) => ({
      settings: {
        notifications: true,
        volume: 80,
        newFeature: true, // 새로 추가된 필드
      },
    }),
    {
      name: 'app',
      merge: (persisted, current) =>
        deepMerge(current, persisted as Partial<typeof current>),
    },
  ),
);

딥 머지를 사용하면 스토리지에 newFeature가 없더라도 코드의 기본값이 유지된다.

onRehydrateStorage — hydration 이벤트 리스너

hydration 시작과 완료 시점에 콜백을 실행할 수 있다. 로딩 상태 관리나 에러 핸들링에 유용하다.

typescript
const useStore = create<Store>()(
  persist(
    (set) => ({ /* ... */ }),
    {
      name: 'my-store',
      onRehydrateStorage: (state) => {
        console.log('hydration 시작');

        return (state, error) => {
          if (error) {
            console.error('hydration 실패:', error);
          } else {
            console.log('hydration 완료');
          }
        };
      },
    },
  ),
);

onRehydrateStorage 함수 자체는 hydration 시작 시 호출되고, 반환하는 함수가 hydration 완료(또는 실패) 시 호출된다. 이 패턴을 활용하면 hydration 상태를 스토어에 반영하거나, 실패 시 fallback 처리를 할 수 있다.

skipHydration — 수동 hydration 제어

기본적으로 hydration은 스토어 생성 시 자동으로 실행된다. 하지만 SSR 환경에서는 서버에서 렌더링한 HTML과 클라이언트에서 hydration 이후의 상태가 달라서 mismatch 에러가 발생할 수 있다.

typescript
const useStore = create<Store>()(
  persist(
    (set) => ({
      count: 0,
    }),
    {
      name: 'counter',
      skipHydration: true, // 자동 hydration 비활성화
    },
  ),
);
tsx
// 컴포넌트에서 마운트 후 수동으로 hydration
function App() {
  useEffect(() => {
    useStore.persist.rehydrate();
  }, []);

  return <div>...</div>;
}

skipHydration: true로 설정하면 스토어 생성 시점에 스토리지를 읽지 않는다. 컴포넌트가 클라이언트에서 마운트된 후 rehydrate()를 호출해서 수동으로 복원한다. 이렇게 하면 서버 렌더링 시에는 항상 초기 상태가 사용되고, 클라이언트에서만 저장된 상태를 적용하므로 hydration mismatch를 방지할 수 있다.


createJSONStorage 커스터마이징

createJSONStorage의 두 번째 인자로 replacerreviver를 전달할 수 있다. JSON.stringifyJSON.parse에 각각 전달되는 함수다. Date 객체처럼 JSON으로 변환하면 정보가 손실되는 타입을 처리할 때 유용하다.

typescript
const storage = createJSONStorage(() => localStorage, {
  replacer: (key, value) => {
    // Date는 JSON.stringify 시 toJSON()이 호출되어 string이 됨
    // 저장 시점에 타입 정보를 함께 기록
    if (key === 'createdAt' && typeof value === 'string') {
      return { __type: 'date', value };
    }
    return value;
  },
  reviver: (key, value) => {
    if (value && value.__type === 'date') {
      return new Date(value.value);
    }
    return value;
  },
});

이 패턴을 사용하면 Date, Map, Set 같은 비-JSON 네이티브 타입도 persist 할 수 있다.


Persist API

persist 미들웨어는 스토어 외부에서 접근할 수 있는 API를 제공한다. 디버깅이나 특수한 제어에 유용하다.

typescript
// 저장된 데이터 삭제 (로그아웃 시 등)
useAuthStore.persist.clearStorage();

// 수동 rehydration
await useAuthStore.persist.rehydrate();

// hydration 완료 여부 확인
const isReady = useAuthStore.persist.hasHydrated();

// hydration 이벤트 구독
const unsub = useAuthStore.persist.onFinishHydration((state) => {
  console.log('복원 완료:', state);
});

// 런타임에 옵션 변경
useAuthStore.persist.setOptions({ name: 'new-key' });

clearStorage()는 로그아웃 기능 구현에 특히 유용하다. 스토어 상태를 초기화하는 것과 별개로, 스토리지에 남아있는 데이터까지 확실하게 제거할 수 있다.


Next.js에서의 hydration mismatch 해결

SSR을 사용하는 Next.js에서 persist를 쓰면 높은 확률로 hydration mismatch 경고를 만난다. 서버에서는 초기 상태로 렌더링하지만, 클라이언트에서는 localStorage에서 복원한 상태로 렌더링하기 때문이다.

가장 깔끔한 해결법은 커스텀 훅을 만들어서 클라이언트 마운트 후에만 persist된 값을 사용하는 것이다.

typescript
import { useState, useEffect } from 'react';

function useStoreHydration<T, F>(
  store: (callback: (state: T) => F) => F,
  callback: (state: T) => F,
): F | undefined {
  const result = store(callback);
  const [data, setData] = useState<F>();

  useEffect(() => {
    setData(result);
  }, [result]);

  return data;
}
tsx
// 사용
function ThemeToggle() {
  const theme = useStoreHydration(useSettingsStore, (s) => s.theme);

  if (theme === undefined) return null; // hydration 전 로딩

  return <button>{theme === 'dark' ? '🌙' : '☀️'}</button>;
}

첫 렌더에서는 undefined를 반환하고, useEffect 이후에 실제 값으로 업데이트된다. 서버와 클라이언트의 첫 렌더가 동일하게 유지되므로 mismatch가 발생하지 않는다.


실전 패턴: 다른 미들웨어와 조합

persist는 다른 Zustand 미들웨어와 함께 사용할 수 있다. 순서가 중요한데, persist를 가장 바깥에 두는 것이 일반적이다.

typescript
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { devtools } from 'zustand/middleware';

interface TodoStore {
  todos: { id: string; text: string; done: boolean }[];
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
}

export const useTodoStore = create<TodoStore>()(
  devtools(
    persist(
      immer((set) => ({
        todos: [],
        addTodo: (text) =>
          set((state) => {
            state.todos.push({ id: crypto.randomUUID(), text, done: false });
          }),
        toggleTodo: (id) =>
          set((state) => {
            const todo = state.todos.find((t) => t.id === id);
            if (todo) todo.done = !todo.done;
          }),
      })),
      { name: 'todo-storage', partialize: (s) => ({ todos: s.todos }) },
    ),
  ),
);

devtoolspersistimmer 순서로 감싸면 DevTools에서 persist 관련 액션도 추적할 수 있고, immer로 불변성 없이 상태를 업데이트할 수 있으며, 변경된 상태가 자동으로 스토리지에 저장된다.


주의할 점

  • 함수는 직렬화할 수 없다. 스토어에 액션 함수가 포함되어 있으면 partialize로 데이터만 추출해서 저장해야 한다. 아니면 JSON 직렬화 시 함수가 무시되어 복원 후 함수가 사라지는 문제가 생긴다. (실제로는 Zustand이 merge 시 코드의 초기 함수를 유지하므로 대부분 문제가 안 되지만, 명시적으로 partialize 하는 것이 깔끔하다.)

  • 스토리지 용량 제한. localStorage는 약 5MB 제한이 있다. 대량의 데이터를 persist 해야 한다면 IndexedDB 기반 스토리지로 교체해야 한다.

  • 민감한 데이터 주의. localStorage는 같은 도메인의 모든 JavaScript에서 접근 가능하다. XSS 공격에 노출되면 저장된 토큰이나 개인정보가 탈취될 수 있다. 민감한 인증 토큰은 httpOnly 쿠키 등 더 안전한 방식을 사용하는 것이 좋다.

  • 탭 간 동기화는 자동이 아니다. 여러 탭에서 같은 앱을 열어두면 한 탭에서 변경한 상태가 다른 탭에 자동 반영되지 않는다. storage 이벤트를 리스닝하는 커스텀 스토리지를 구현하거나, BroadcastChannel을 활용해야 한다.


왜 persist 미들웨어인가

상태 영속화를 직접 구현하는 것도 가능하지만, Zustand persist가 해결해주는 건 단순한 저장/복원이 아니다. partialize로 저장 범위를 선언적으로 제어하고, version + migrate로 상태 스키마 변경에 대응하고, skipHydration으로 SSR 환경을 처리하는 것까지 포함한다. Redux Persist는 비슷한 기능을 제공하지만 Redux 생태계에 묶여 있고, 설정이 더 복잡하다(PersistGate, persistReducer 등). Jotai의 atomWithStorage는 atom 단위로 영속화할 수 있어서 간단하지만, 여러 atom을 묶어서 마이그레이션하는 건 지원하지 않는다. Zustand persist는 스토어 단위로 동작하면서도 설정이 간결하다는 점에서 균형이 좋다.


정리

  • persist() 미들웨어 하나로 상태의 자동 저장·복원(hydration)·부분 저장(partialize)·버전 마이그레이션까지 선언적으로 처리할 수 있다
  • SSR 환경에서는 skipHydration: true + 클라이언트 마운트 후 rehydrate()로 hydration mismatch를 방지한다
  • devtoolspersistimmer 순서로 미들웨어를 조합하면 DevTools 추적, 영속화, 불변성 관리를 동시에 얻을 수 있다

관련 문서