카테고리 없음

zustand 로 새로고침해도 안변하는 데이터관리하기

modori@ 2026. 4. 8. 21:18

새로고침을 하면 상태가 초기화되는 건 당연한 일입니다. React 앱에서 전역 상태를 zustand로 관리하더라도, 브라우저를 새로고침하면 메모리에 있던 값은 사라지니까요.

그런데 실제로 서비스를 운영하다보면 “새로고침해도 유지돼야 하는 데이터”가 꽤 많습니다.

 

이 글에서는 Zustand로 새로고침해도 안 변하는 데이터를 관리하는 방법을 사용 방법과 작동원리에 대해서 알아보려고 합니다.


1) 왜 새로고침하면 상태가 날아갈까?

Zustand 스토어는 기본적으로 메모리에 존재합니다.

그래서 새로고침(= JS 런타임 재시작)이 일어나면, 스토어는 다시 생성되고 초기값으로 돌아갑니다.

해결하려면 “어딘가에 저장”이 필요합니다.

 

그래서 보편적으로 사용하게 되는게 local storage죠.

하지만 유동적으로 변하는 데이터를 저장하며 사용하게되면 생각보다 고려할 게 많아집니다.

  • 여러 탭에서 값이 엇갈리는 동기화 문제
  • 저장/복원 타이밍이 어긋나서 이전 값으로 덮어씌워지는 문제
  • JSON.stringify/parse 과정에서 생기는 타입 손실/파싱 에러 처리
  • getItem/setItem 반복으로 인한 호출부 보일러플레이트 증가

이런 이유로 “저장/복원”을 직접 구현하기보다는, Zustand에서 제공하는 미들웨어로 이 책임을 위임하는 편이 훨씬 깔끔합니다.


2) persist란?

persist는 Zustand 스토어의 상태를 외부 스토리지에 자동으로 저장하고 불러올 수 있게 해주는 미들웨어입니다.

  • 상태 변경 시 → 스토리지에 저장
  • 앱 시작 시 → 스토리지에서 읽어와 복원

3) 기본 사용법 (sessionStorage 예시)

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

export const useBearStore = create(
  persist(
    (set, get) => ({
      bears: 0,
      addABear: () => set({ bears: get().bears + 1 }),
    }),
    {
      name: 'food-storage',
      storage: createJSONStorage(() => sessionStorage),
    }
  )
)

동작 흐름은 다음처럼 이해하면 편합니다.

  • 스토어 생성 시점에 persist가 set을 감쌈
  • 이후 set이 호출되면:
    • 메모리 상태 업데이트
    • 스토리지에 직렬화(JSON)해서 저장
  • 앱 시작 시점에는:
    • 스토리지에서 값을 읽고
    • 스토어 상태를 덮어써서 복원(hydration)

4) createJSONStorage는 왜 필요할까?

createJSONStorage는 스토리지(localStorage/sessionStorage 등)를 persist가 기대하는 형태로 바꿔주는 어댑터입니다.

(1) JSON 직렬화/역직렬화

상태는 객체이므로 문자열로 바꿔서 저장해야 합니다.

const createJSONStorage = (getStorage, options?) => {
  return {
    getItem: (name) => {
      const value = getStorage().getItem(name)
      return JSON.parse(value, options?.reviver)
    },
    setItem: (name, value) => {
      const str = JSON.stringify(value, options?.replacer)
      getStorage().setItem(name, str)
    },
    removeItem: (name) => {
      getStorage().removeItem(name)
    },
  }
}

(2) SSR에서 안전하게 접근하기 (함수로 감싸는 이유)

SSR은 서버(Node.js)에서 실행되기 때문에 브라우저 전용 API를 사용할 수 없습니다.

// ❌ SSR에서 즉시 에러, 해당 storage 접근 불가
storage: createJSONStorage(localStorage)

// ✅ 실제 접근 시점까지 지연
storage: createJSONStorage(() => localStorage)

 


5) hydration 타이밍 이슈 (깜빡임/조건 분기 버그)

persist는 기본적으로 아래와 같이 동작합니다.

 

스토어 생성 (기본값으로 렌더) -> 스토리지에서 값을 읽음 -> 읽은 값으로 상태를 덮어씀 

 

그래서 기본값에서 동기화 되는 과정에 아래 현상들이 발생합니다.

  • 로그인 상태가 잠깐 풀린 것처럼 보임 (user: null → hydration 후 user)
  • UI 깜빡임
  • 라우팅/초기 fetch가 잘못된 상태 기준으로 실행됨

해결: hasHydrated 플래그로 “복원 완료 후” 렌더/로직 실행

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

type Store = {
  hasHydrated: boolean
  user: { id: string } | null
  setHasHydrated: (v: boolean) => void
}

export const useStore = create<Store>()(
  persist(
    (set) => ({
      hasHydrated: false,
      user: null,
      setHasHydrated: (v) => set({ hasHydrated: v }),
    }),
    {
      name: 'app-storage',
      storage: createJSONStorage(() => sessionStorage),
      onRehydrateStorage: () => (state) => {
        if (state) state.setHasHydrated(true)
      },
    }
  )
)

컴포넌트에서는:

const hasHydrated = useStore((s) => s.hasHydrated)
if (!hasHydrated) return null // 또는 스켈레톤/로딩

6) partialize: “전부 저장하지 말고, 일부만 저장”

persist는 기본적으로 스토어 전체를 저장하려고 합니다. 하지만 실무에서는 “저장하면 안 되는 값”이 섞이기 쉽습니다.

  • 토큰/민감정보(보안)
  • 대용량 캐시(성능/용량)
  • 서버에서 다시 받아야 하는 값(데이터 품질)

이럴 때 partialize로 저장할 필드만 선택합니다.

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

type AuthState = {
  user: { id: string; nickname: string } | null
  accessToken: string | null
  refreshToken: string | null
  tempCache: Record<string, unknown>
  setUser: (user: AuthState['user']) => void
  setTokens: (a: string | null, r: string | null) => void
  clear: () => void
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      accessToken: null,
      refreshToken: null,
      tempCache: {},
      setUser: (user) => set({ user }),
      setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
      clear: () => set({ user: null, accessToken: null, refreshToken: null, tempCache: {} }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => sessionStorage),
      partialize: (state) => ({
        user: state.user,
        // accessToken/refreshToken/tempCache는 저장하지 않음
      }),
    }
  )
)

7) 버전/마이그레이션: 구조가 바뀌었을 때 기존 데이터 살리기

상태 구조가 바뀌면 기존에 저장된 값이 그대로 hydrate되면서 버그가 생길 수 있습니다.

이때 version + migrate로 “옛 데이터 → 새 데이터” 변환을 해줍니다.

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

type StateV2 = {
  profile: { id: string; nickname: string } | null
  theme: 'light' | 'dark'
}

type Actions = {
  setTheme: (t: StateV2['theme']) => void
}

export const useSettingsStore = create<StateV2 & Actions>()(
  persist(
    (set) => ({
      profile: null,
      theme: 'light',
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: 'settings-storage',
      storage: createJSONStorage(() => localStorage),
      version: 2,
      migrate: (persistedState: any, version) => {
        // v1: { user, theme } -> v2: { profile, theme }
        if (version === 1) {
          return {
            profile: persistedState?.user ?? null,
            theme: persistedState?.theme ?? 'light',
          }
        }

        return {
          profile: persistedState?.profile ?? null,
          theme: persistedState?.theme ?? 'light',
        }
      },
    }
  )
)

마무리

Zustand에서 새로고침해도 유지되는 데이터를 만들려면 결국 “저장 + 불러오기”가 필요합니다.

zustand middleware를 통해 좀 더 간결하게 관리할 수 있게 되면 좋겠습니다.