Zustand

Zustand persist 사용법: 새로고침 후 상태 저장하기

2026.05.07·수정 2026.05.12·약 18분

이 글에서 정리하는 내용

Zustand의 persist 미들웨어로 새로고침 후에도 상태를 유지하는 방법을 정리합니다. 코드 사용법 자체보다 어떤 상태를 브라우저 저장소에 남겨도 되는지, 어떤 상태는 매번 초기화하는 편이 나은지에 초점을 맞춥니다.

상태는 store에 있지만 브라우저에는 남지 않는다

Zustand store 상태가 새로고침 후 초기화되고 persist를 통해 storage에 저장되는 구조 비교

Zustand store에 값을 넣으면 여러 컴포넌트에서 같은 상태를 읽을 수 있습니다. 그래서 처음에는 store에 들어간 값이 앱 전체에서 계속 유지될 것처럼 느껴집니다. 하지만 기본 store의 상태는 브라우저 메모리 안에 있는 값입니다. 페이지를 새로고침하면 JavaScript 실행 환경이 다시 만들어지고, store도 처음 정의한 초기값으로 다시 시작합니다.

예를 들어 다크 모드 버튼을 눌러 theme 값을 dark로 바꿨다고 가정해보겠습니다. 화면에서는 즉시 다크 모드가 적용되고, 다른 컴포넌트에서도 같은 값을 읽을 수 있습니다. 그런데 아무 저장 처리를 하지 않았다면 새로고침 후에는 다시 light로 돌아갈 수 있습니다. 장바구니 상품, 관리자 화면의 필터 조건, 최근 선택한 탭도 같은 이유로 사라집니다.

이 현상은 Zustand가 상태를 못 지켜서 생기는 문제가 아닙니다. 상태가 저장된 위치가 메모리였기 때문에 생기는 자연스러운 결과입니다. React 컴포넌트의 useState 값이 새로고침 후 사라지는 것처럼, Zustand store도 별도 저장소에 연결하지 않으면 브라우저를 다시 로드하는 순간 초기 상태로 돌아갑니다.

이때 브라우저 저장소가 필요해집니다. localStorage는 브라우저를 닫았다가 다시 열어도 값이 남고, sessionStorage는 탭 또는 세션 단위로 값이 유지됩니다. Zustand의 persist는 store와 이런 storage 사이에 저장 흐름을 붙여주는 미들웨어입니다. 사용자가 store를 변경하면 지정한 storage에 상태를 저장하고, 다시 페이지에 들어왔을 때 저장된 값을 store로 복원합니다.

다만 여기서 바로 “그럼 store 전체를 저장하면 되겠네”로 넘어가면 나중에 정리하기 어려워집니다. 저장소에 남는 값은 새로고침 이후에도 살아남고, 브라우저에 직접 기록됩니다. 그래서 persist를 붙이기 전에는 저장 기술보다 저장 기준을 먼저 잡아야 합니다.

persist는 store와 storage 사이에 저장 흐름을 붙인다

persist의 기본 구조는 store 생성 함수를 한 번 감싸는 형태입니다. 기존에는 create 안에 상태와 action을 바로 작성했다면, persist를 사용할 때는 그 상태 정의와 옵션 객체를 함께 넘깁니다.

import { create } from "zustand";
import { persist } from "zustand/middleware"; type Theme = "light" | "dark"; type ThemeState = { theme: Theme; setTheme: (theme: Theme) => void;
}; export const useThemeStore = create<ThemeState>()( persist( (set) => ({ theme: "light", setTheme: (theme) => set({ theme })}), { name: "theme-storage"} )
);

위 코드에서 실제 상태를 만드는 부분은 기존 Zustand store와 거의 같습니다. 달라진 부분은 persist가 그 바깥을 감싸고 있다는 점입니다. name은 storage에 저장될 때 사용할 key입니다. 브라우저 개발자 도구에서 Application 탭의 Local 를 열어보면 이 이름으로 저장된 값을 확인할 수 있습니다.

name은 단순한 이름처럼 보이지만 실제 프로젝트에서는 store를 구분하는 기준이 됩니다. 테마 값과 장바구니 값을 같은 이름으로 저장하면 충돌이 생길 수 있습니다. 그래서 theme-storage, cart-storage, admin-filter-storage처럼 어떤 store의 값인지 드러나는 이름을 쓰는 것이 좋습니다.

기본 저장소는 보통 localStorage로 생각하면 됩니다. 탭을 닫아도 유지해야 하는 다크 모드, 장바구니, 사용자 설정에는 잘 맞습니다. 반대로 탭을 닫으면 사라져도 되는 임시 검색 조건이나 작업 세션 값이라면 sessionStorage를 선택할 수 있습니다.

import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; type FilterStatus = "all" | "active" | "done"; type FilterState = { keyword: string; status: FilterStatus; setKeyword: (keyword: string) => void; setStatus: (status: FilterStatus) => void; resetFilter: () => void;
}; export const useFilterStore = create<FilterState>()( persist( (set) => ({ keyword: "", status: "all", setKeyword: (keyword) => set({ keyword }), setStatus: (status) => set({ status }), resetFilter: () => set({ keyword: "", status: "all" })}), { name: "filter-storage", storage: createJSONStorage(() => sessionStorage)} )
);

createJSONStorage는 storage에 값을 JSON 형태로 저장하고 다시 읽어오는 흐름을 만들어줍니다. 상태 객체는 그대로 storage에 들어가는 것이 아니라 문자열로 바뀌어 저장됩니다. 그래서 저장 대상은 JSON으로 표현할 수 있는 값이어야 합니다. 문자열, 숫자, boolean, 배열, 일반 객체는 다루기 쉽지만 함수, DOM 객체, class 인스턴스처럼 직렬화에 맞지 않는 값은 저장 대상으로 보지 않는 것이 맞습니다.

개발 중에는 저장된 값을 직접 확인하는 습관도 필요합니다. persist를 붙인 뒤 상태가 예상대로 복원되지 않는다면 먼저 storage key가 맞는지, 저장된 JSON 안에 원하는 필드가 실제로 들어 있는지 확인해야 합니다. 코드만 보면 저장되는 것처럼 보여도 partialize, storage 종류, key 이름 때문에 다른 값을 보고 있을 때가 있습니다.

저장할 상태와 버릴 상태를 먼저 나눈다

persist를 붙이면 상태 유지 문제는 빠르게 해결됩니다. 하지만 store에 있는 값이 모두 같은 성격은 아닙니다. 장바구니 상품 목록처럼 사용자가 다시 들어와도 이어지는 편이 자연스러운 값이 있고, 모달 열림 여부처럼 새로고침 후까지 남으면 오히려 어색한 값도 있습니다.

예를 들어 장바구니 store에 아래 상태가 같이 있다고 가정할 수 있습니다.

type CartItem = { id: string; name: string; price: number; quantity: number;
}; type CartState = { items: CartItem[]; selectedCouponId: string | null; isCartPanelOpen: boolean; addItem: (item: CartItem) => void; closeCartPanel: () => void;
};

이 중 items는 저장할 만합니다. 사용자가 상품을 담아둔 상태는 새로고침 후에도 유지되는 쪽이 자연스럽습니다. selectedCouponId도 서비스 정책에 따라 유지할 수 있습니다. 하지만 isCartPanelOpen은 다릅니다. 이 값은 데이터라기보다 현재 화면에서 패널이 열려 있는지를 나타내는 UI 임시 상태입니다.

이런 값을 저장하면 사용자가 나중에 다시 들어왔을 때 장바구니 패널이 갑자기 열린 상태로 시작할 수 있습니다. 기술적으로는 틀리지 않지만 화면 흐름으로 보면 불필요한 기억입니다. persist를 사용할 때는 “store에 있으니 저장한다”가 아니라 “사용자가 다시 돌아왔을 때 이어져야 하는가”를 기준으로 나누는 것이 맞습니다.

관리자 화면의 필터도 비슷합니다. 검색어, 상태 필터, 정렬 기준은 사용자가 목록을 다시 볼 때 이어져도 괜찮습니다. 반면 로딩 여부, 마지막 에러 메시지, 삭제 확인 모달 상태는 저장할 필요가 없습니다. 모두 같은 store 안에 있을 수 있지만 storage에 남겨야 하는 값은 일부입니다.

민감한 값도 조심해야 합니다. access token, 비밀번호, 결제 관련 값, 권한 판단에 직접 쓰이는 정보는 에 쉽게 남기면 안 됩니다. Zustand store 안에서 잠깐 쓰는 값과 브라우저 저장소에 남겨도 되는 값은 별개입니다. 특히 인증 상태는 프로젝트 구조에 따라 쿠키, 서버 세션, 토큰 저장 정책이 달라지므로 persist로 단순 처리할 주제가 아닐 수 있습니다.

partialize로 저장 범위를 줄이는 방식

partialize는 persist 옵션 중에서 실제 프로젝트에서 자주 확인해야 하는 항목입니다. store 전체 중 storage에 저장할 부분만 골라낼 수 있습니다. 장바구니 store 안에 데이터 상태와 UI 상태가 섞여 있을 때 특히 필요합니다.

import { create } from "zustand";
import { persist } from "zustand/middleware"; type CartItem = { id: string; name: string; price: number; quantity: number;
}; type CartState = { items: CartItem[]; selectedCouponId: string | null; isCartPanelOpen: boolean; addItem: (item: CartItem) => void; removeItem: (id: string) => void; openCartPanel: () => void; closeCartPanel: () => void;
}; export const useCartStore = create<CartState>()( persist( (set) => ({ items: [], selectedCouponId: null, isCartPanelOpen: false, addItem: (item) => set((state) => ({ items: [...state.items, item]})), removeItem: (id) => set((state) => ({ items: state.items.filter((item) => item.id !== id)})), openCartPanel: () => set({ isCartPanelOpen: true }), closeCartPanel: () => set({ isCartPanelOpen: false })}), { name: "cart-storage", partialize: (state) => ({ items: state.items, selectedCouponId: state.selectedCouponId})} )
);

이 코드에서 store에는 isCartPanelOpen과 action 함수들이 함께 들어 있습니다. 하지만 storage에 저장되는 값은 itemsselectedCouponId뿐입니다. 새로고침 후 장바구니 상품은 복원되지만 패널 열림 상태는 다시 false로 시작합니다.

처음에는 partialize가 선택 옵션처럼 보입니다. 하지만 store가 커질수록 거의 필수에 가까워집니다. 사용자 설정, 리스트 필터, 장바구니, 편집 초안처럼 저장하고 싶은 값이 늘어나면 store 안에는 저장 대상과 비저장 대상이 같이 들어가기 쉽습니다. 저장 범위를 명시하지 않으면 나중에 새 상태를 추가했을 때 의도하지 않은 값까지 storage에 남을 수 있습니다.

저장 범위를 작게 가져가면 디버깅도 단순해집니다. 개발자 도구에서 storage 값을 봤을 때 실제로 복원해야 하는 데이터만 보입니다. 반대로 화면 상태로딩 상태에러 메시지까지 저장되어 있으면 새로고침 후 왜 이전 화면의 흔적이 남는지 추적하기가 번거로워집니다.

테마 store처럼 상태가 매우 작고 저장 대상이 명확한 경우에는 partialize 없이 시작해도 됩니다. 하지만 장바구니나 관리자 필터처럼 상태가 늘어날 가능성이 있는 store라면 처음부터 저장할 필드만 고르는 방식이 더 안정적입니다.

한 가지 더 볼 부분은 action 함수입니다. store 안에는 addItem, removeItem 같은 함수도 함께 존재하지만 storage에는 함수가 저장되지 않습니다. persist는 상태를 JSON으로 저장하고 다시 복원하는 흐름이기 때문에, 실제로 저장되는 대상은 데이터에 가깝습니다. 그래서 action은 store 안에 그대로 두되, storage에 남길 데이터는 partialize로 제한하는 구조가 자연스럽습니다.

hydration까지 생각해야 화면이 덜 흔들린다

Zustand persist hydration 과정에서 초기 상태와 저장된 상태가 화면에 반영되는 흐름 설명

persist는 저장된 값을 다시 store로 가져오는 과정을 거칩니다. 이 복원 과정을 hydration이라고 부릅니다. 동기 storage인 localStorage에서는 비교적 단순하게 느껴질 수 있지만, 화면 입장에서는 “처음 store 초기값”과 “storage에서 복원된 값” 사이의 차이가 생길 수 있습니다.

예를 들어 테마 초기값은 light인데 storage에는 dark가 저장되어 있다고 가정해보겠습니다. 앱이 처음 그려지는 순간에는 초기값 기준으로 화면이 만들어지고이후 저장된 값이 복원되면서 다크 모드로 바뀔 수 있습니다. 이 차이가 짧게 지나가면 괜찮지만, 테마나 인증 상태처럼 화면 전체에 영향을 주는 값이면 깜빡임처럼 보일 수 있습니다.

Next.js 같은 환경에서는 이 지점을 더 신경 써야 합니다. 서버에서 렌더링되는 시점에는 브라우저의 localStorage에 접근할 수 없습니다. 결국 브라우저에서 hydration이 일어난 뒤 저장된 상태를 읽게 됩니다. 그래서 persist로 관리하는 값을 첫 화면 조건에 바로 쓰면 서버에서 만든 화면과 클라이언트에서 복원한 화면이 다르게 느껴질 수 있습니다.

해결 방식은 프로젝트마다 다릅니다. 단순 설정값이라면 클라이언트에서만 해당 UI를 보여주도록 처리할 수 있고, 테마처럼 첫 화면에 바로 영향을 주는 값은 별도의 초기화 스크립트나 CSS 전략을 같이 고려할 수 있습니다. 핵심은 persist를 붙였다고 해서 저장된 값이 항상 첫 렌더링 전에 완벽하게 준비된다고 단정하지 않는 것입니다.

또 하나 확인할 부분은 storage의 값이 오래 남을 수 있다는 점입니다. store 구조를 바꾸거나 필드 이름을 바꿨는데 예전 storage 값이 남아 있으면 예상과 다른 상태가 복원될 수 있습니다. 이럴 때는 storage key를 바꾸거나, 버전 관리와 migration 옵션을 검토해야 합니다. 처음 학습 단계에서는 자주 쓰지 않더라도 운영 중인 서비스에서는 store 구조 변경과 기존 저장 데이터의 관계를 반드시 같이 봐야 합니다.

개발 중 상태가 이상하게 복원된다면 코드만 계속 고치기보다 저장소를 한 번 비워보는 것도 필요합니다. Local 에 남아 있는 이전 JSON이 현재 store 구조와 맞지 않으면, 수정한 코드가 정상이어도 화면은 계속 예전 상태를 기준으로 움직일 수 있습니다. persist를 다룰 때 개발자 도구의 storage 확인이 디버깅 출발점이 되는 이유입니다.

persist를 적용할 때 남겨둘 기준

persist는 Zustand 상태를 새로고침 이후에도 이어가게 만들어줍니다. 사용법만 보면 store를 감싸고 name을 지정하는 정도라 어렵지 않습니다. 실제 차이는 그다음부터 생깁니다. 어떤 값을 storage에 남길지, 어떤 값은 메모리 상태로만 둘지 나누지 않으면 store가 커질수록 의도하지 않은 값이 계속 복원됩니다.

저장해도 되는 값은 사용자가 다시 들어왔을 때 이어지는 것이 자연스러운 값입니다. 테마, 장바구니 상품, 일부 필터 조건, 편집 중인 초안 같은 값이 여기에 들어갈 수 있습니다. 반대로 모달 열림 여부, 드롭다운 상태로딩 상태, 일시적인 에러 메시지는 새로고침 후까지 기억할 필요가 낮습니다.

partialize는 이 기준을 코드에 남기는 방법입니다. 저장 대상 필드를 명시하면 나중에 store에 새로운 상태가 추가되어도 storage에 자동으로 섞이지 않습니다. 이 작은 구분이 장기적으로는 store 관리와 디버깅을 훨씬 단순하게 만듭니다.

마지막으로 hydration 흐름을 같이 봐야 합니다. 저장된 값은 다시 읽혀야 화면에 반영됩니다. 첫 렌더링부터 반드시 저장값이 반영된다고 생각하면 테마 깜빡임이나 조건부 렌더링 문제를 만날 수 있습니다. persist를 사용할 때는 “저장한다”에서 끝내지 말고, “언제 복원되고 화면에는 언제 반영되는가”까지 확인하는 습관을 남겨두는 것이 좋습니다.

같이 읽으면 좋은 글

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기