새로고침을 하면 상태가 초기화되는 건 당연한 일입니다. 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를 통해 좀 더 간결하게 관리할 수 있게 되면 좋겠습니다.