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

2026.05.07·수정 2026.09.14·약 40분·작성: 해비·블로그 소개

학습 목표·선수 지식: 장바구니 데이터만 저장하고 구 저장값의 UI 필드를 제외하며 복원 실패와 저장 실패를 구분합니다. 선수 지식: Zustand action, JSON, localStorage의 브라우저 범위.

이 글에서 정리하는 내용

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

실습 경로: 현재 ZIP전체 코드. 먼저 설명에서 지정한 파일만 읽고, 설정·테스트 파일은 필요한 때 확인하세요.

상태는 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를 사용할 때는 그 상태 정의와 옵션 객체를 함께 넘깁니다.

개념 부분 코드: src/examples/post-2962-1.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.

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 Storage를 열어보면 이 이름으로 저장된 값을 확인할 수 있습니다.

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

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

개념 부분 코드: src/examples/post-2962-2.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.

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에 아래 상태가 같이 있다고 가정할 수 있습니다.

개념 부분 코드: src/examples/post-2962-3.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.

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

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

partialize는 쓰기 필터이며 읽기 검증이 아닙니다. 과거 JSON에 isCartPanelOpen: true가 이미 있다면 기본 얕은 merge는 이를 복원할 수 있습니다. 전체 프로젝트는 merge에서 검증한 items와 selectedCouponId만 읽고 isCartPanelOpen을 false로 명시합니다. 외부 JSON의 addItem 같은 키도 선택하지 않아 현재 action을 보존합니다.

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

아래 전체 프로젝트 src/store.ts는 저장할 필드 선택에 더해 읽기 검증과 중복 ID 수량 정책까지 구현합니다. store.ts 전체 코드

쓰기 결과에는 items와 selectedCouponId만 남습니다. 과거 저장값에도 패널 상태가 없을 때는 기본값 false가 유지되지만, 이미 남아 있는 필드는 partialize만으로 제외되지 않습니다. 아래 전체 코드의 허용 필드 merge를 함께 적용해야 합니다.

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

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

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

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

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

동기 localStorage의 기본 자동 hydration은 store 생성 중 끝날 수 있습니다. 비동기 storage는 생성 이후에 끝나므로 초기값을 먼저 읽을 수 있습니다. 여기서 말하는 persist의 저장값 복원과 React가 서버 HTML에 연결되는 hydration은 별개입니다. 전체 프로젝트는 skipHydration: true로 자동 복원을 끄고 effect에서 명시적으로 rehydrate합니다. 브라우저 storage 접근은 adapter 함수가 클라이언트에서 호출될 때만 수행합니다.

복원 성공과 복원 시도 종료도 구별합니다. 잘못된 JSON 또는 읽기 실패는 onRehydrateStorage의 error로 알리고 기본값을 표시합니다. rehydrate의 Promise가 끝났다는 이유만으로 성공이라고 판단하지 않습니다. 이 프로젝트의 ready는 성공 표시가 아니라 기본값을 포함해 UI를 보여줄 시점입니다.

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

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

비동기 저장소나 skipHydration으로 복원을 늦춘 경우 초기값 또는 준비 화면 뒤에 저장된 테마가 적용됩니다. 기본 동기 localStorage 자동 복원은 store 생성 중 완료될 수 있어 반드시 첫 렌더 뒤에 복원되는 것은 아닙니다.

서버에는 브라우저의 localStorage가 없습니다. SSR에서는 서버 HTML과 첫 클라이언트 렌더를 같은 기본값으로 맞추고 이후 저장값을 복원하는 등의 전략이 필요합니다. 이 글의 SPA 프로젝트는 React SSR hydration을 재현한 예제가 아닙니다.

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

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

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

persist를 적용할 때 남겨둘 기준

장바구니 정책과 저장 실패

전체 실습은 같은 상품 ID를 추가하면 한 행의 quantity를 합산하고 최대 99로 제한합니다. 잘못된 상품 값은 제외하며 removeItem은 해당 ID를 제거합니다. 저장된 가격·쿠폰·재고는 주문 확정 전에 서버가 재확인해야 합니다. 브라우저 장바구니는 결제 금액의 원본이 아닙니다.

localStorage 쓰기가 용량·브라우저 정책 때문에 실패할 수 있습니다. 이 예제는 버튼 action의 동기 오류를 잡아 저장 실패를 안내합니다. Zustand 메모리 상태는 저장 호출 전에 바뀔 수 있으므로 화면 반영과 디스크 저장 성공을 같은 것으로 취급하지 않습니다. 비동기 쓰기 adapter를 쓰는 앱은 Promise 거부 처리도 별도로 필요합니다.

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

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

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

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

설치부터 확인까지: 전체 실행 프로젝트

Node.js 24와 npm을 준비하고 ZIP을 푼 프로젝트 폴더에서 아래 순서로 실행합니다. 여러 파일을 함께 실행하는 완성형 실습입니다. 뷰어의 파일 경로는 ZIP 프로젝트 루트 기준이며, 짧은 개념 예제와 별개입니다.

npm install
npm run build
npm test
npm run dev

Vite가 출력한 로컬 주소를 여세요. 서버·인증·외부 API 없이 실행하는 브라우저 SPA입니다.

현재 실습 ZIP

현재 본문과 같은 실습 파일 내려받기

README.md

# Zustand cart 실습
Node.js 24 및 npm. 이 폴더에서 `npm install`, `npm run build`, `npm test`, `npm run dev` 순서로 실행합니다. Vite가 표시한 로컬 주소를 여세요.
Zustand 5.0.15 / React 19.3.0 / TypeScript 7.0.2 기준입니다.
독립 브라우저 SPA이며 서버·인증·외부 API는 포함하지 않습니다. 자동 테스트는 Vitest와 jsdom 또는 메모리 저장소를 사용합니다. 브라우저 실제 새로고침·기기 성능 검사는 별도로 수행해야 합니다.

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Zustand 실습</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

package.json

{
  "name": "zustand-cart",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "test": "vitest run"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "zustand": "5.0.15"
  },
  "devDependencies": {
    "typescript": "7.0.2",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "vite": "8.3.0",
    "vitest": "4.1.11",
    "jsdom": "30.0.1"
  }
}

src/App.tsx

import { useEffect, useState } from 'react';
import { useStore } from 'zustand';
import { createCartStore } from './store';
export default function App() {
  const [message, setMessage] = useState('');
  const [store] = useState(() =>
    createCartStore(
      {
        getItem: (key) => window.localStorage.getItem(key),
        setItem: (key, value) => window.localStorage.setItem(key, value),
        removeItem: (key) => window.localStorage.removeItem(key),
      },
      () => setMessage('복원 실패: 기본 장바구니로 시작합니다.'),
    ),
  );
  const [ready, setReady] = useState(false);
  const items = useStore(store, (state) => state.items);
  const open = useStore(store, (state) => state.isCartPanelOpen);
  useEffect(() => {
    let active = true;
    void Promise.resolve(store.persist.rehydrate()).finally(() => {
      if (active) setReady(true);
    });
    return () => {
      active = false;
    };
  }, [store]);
  function run(action: () => void) {
    try {
      action();
    } catch {
      setMessage('화면은 변경됐지만 브라우저 저장에 실패했습니다.');
    }
  }
  if (!ready) return <p role="status">장바구니 복원 중…</p>;
  return (
    <main>
      <h1>저장하는 데이터와 임시 UI</h1>
      <button
        onClick={() =>
          run(() =>
            store
              .getState()
              .addItem({ id: 'coat', name: '코트', price: 10000, quantity: 1 }),
          )
        }
      >
        코트 담기
      </button>
      <button onClick={() => run(store.getState().openCartPanel)}>패널 열기</button>
      <p>패널: {open ? '열림' : '닫힘'}</p>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            {item.name} {item.quantity}개{' '}
            <button onClick={() => run(() => store.getState().removeItem(item.id))}>
              제거
            </button>
          </li>
        ))}
      </ul>
      <p role="status">{message}</p>
    </main>
  );
}

src/main.tsx

import { createRoot } from 'react-dom/client';
import App from './App';
const root = document.getElementById('root');
if (!root) throw new Error('root 요소가 필요합니다.');
createRoot(root).render(<App />);

src/store.test.ts

import { expect, test, vi } from 'vitest';
import { createCartStore } from './store';
test('저장 후 새 store를 복원: 중복 ID 합산, 패널과 action 제외', async () => {
  let raw: string | null = null;
  const storage = {
    getItem: () => raw,
    setItem: (_key: string, value: string) => {
      raw = value;
    },
    removeItem: () => {
      raw = null;
    },
  };
  const a = createCartStore(storage);
  await a.persist.rehydrate();
  a.getState().addItem({ id: 'a', name: 'A', price: 10, quantity: 1 });
  a.getState().addItem({ id: 'a', name: 'A', price: 10, quantity: 2 });
  a.getState().openCartPanel();
  expect(Object.keys(JSON.parse(raw!).state).sort()).toEqual([
    'items',
    'selectedCouponId',
  ]);
  const b = createCartStore(storage);
  await b.persist.rehydrate();
  expect(b.getState().items).toHaveLength(1);
  expect(b.getState().items[0].quantity).toBe(3);
  expect(b.getState().isCartPanelOpen).toBe(false);
  b.getState().removeItem('a');
  expect(b.getState().items).toEqual([]);
});
test('기존 JSON의 UI 필드/action 덮어쓰기와 잘못된 상품을 무시한다', async () => {
  const store = createCartStore({
    getItem: () =>
      JSON.stringify({
        state: {
          items: [null, { id: 'a', name: 'A', price: 10, quantity: -1 }],
          isCartPanelOpen: true,
          addItem: 'bad',
        },
        version: 0,
      }),
    setItem: () => {},
    removeItem: () => {},
  });
  await store.persist.rehydrate();
  expect(store.getState().items).toEqual([]);
  expect(store.getState().isCartPanelOpen).toBe(false);
  expect(typeof store.getState().addItem).toBe('function');
});
test('비동기 읽기는 초기값 뒤 복원; 저장 실패 뒤 메모리는 변경된다', async () => {
  let release!: (value: string) => void;
  const error = vi.fn();
  const store = createCartStore(
    {
      getItem: () =>
        new Promise<string>((resolve) => {
          release = resolve;
        }),
      setItem: () => {
        throw new Error('quota');
      },
      removeItem: () => {},
    },
    error,
  );
  const pending = store.persist.rehydrate();
  expect(store.persist.hasHydrated()).toBe(false);
  release(JSON.stringify({ state: { items: [] }, version: 0 }));
  await pending;
  expect(store.persist.hasHydrated()).toBe(true);
  expect(() => store.getState().openCartPanel()).toThrow('quota');
  expect(store.getState().isCartPanelOpen).toBe(true);
});

src/store.ts

import { createStore } from 'zustand/vanilla';
import { createJSONStorage, persist, type StateStorage } from 'zustand/middleware';
export type CartItem = { id: string; name: string; price: number; quantity: number };
type CartData = { items: CartItem[]; selectedCouponId: string | null };
type CartState = CartData & {
  isCartPanelOpen: boolean;
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  openCartPanel: () => void;
  closeCartPanel: () => void;
};
function isItem(value: unknown): value is CartItem {
  if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
  const item = value as Record<string, unknown>;
  return (
    typeof item.id === 'string' &&
    item.id.length > 0 &&
    typeof item.name === 'string' &&
    typeof item.price === 'number' &&
    Number.isFinite(item.price) &&
    item.price >= 0 &&
    typeof item.quantity === 'number' &&
    Number.isSafeInteger(item.quantity) &&
    item.quantity > 0 &&
    item.quantity <= 99
  );
}
function readCart(value: unknown): CartData {
  const data =
    typeof value === 'object' && value !== null && !Array.isArray(value)
      ? (value as Record<string, unknown>)
      : {};
  const items: CartItem[] = [];
  if (Array.isArray(data.items))
    for (const item of data.items) {
      if (!isItem(item)) continue;
      const existing = items.find((entry) => entry.id === item.id);
      if (existing) existing.quantity = Math.min(99, existing.quantity + item.quantity);
      else
        items.push({
          id: item.id,
          name: item.name,
          price: item.price,
          quantity: item.quantity,
        });
    }
  return {
    items,
    selectedCouponId:
      typeof data.selectedCouponId === 'string' ? data.selectedCouponId : null,
  };
}
export function createCartStore(
  storage: StateStorage,
  onError: (error: unknown) => void = () => {},
) {
  return createStore<CartState>()(
    persist(
      (set) => ({
        items: [],
        selectedCouponId: null,
        isCartPanelOpen: false,
        addItem: (item) => {
          if (!isItem(item)) return;
          set((state) =>
            readCart({
              items: [...state.items, item],
              selectedCouponId: state.selectedCouponId,
            }),
          );
        },
        removeItem: (id) =>
          set((state) => ({ items: state.items.filter((item) => item.id !== id) })),
        openCartPanel: () => set({ isCartPanelOpen: true }),
        closeCartPanel: () => set({ isCartPanelOpen: false }),
      }),
      {
        name: 'cart-storage',
        skipHydration: true,
        storage: createJSONStorage<CartData>(() => storage),
        partialize: (state) => ({
          items: state.items,
          selectedCouponId: state.selectedCouponId,
        }),
        merge: (persisted, current) => ({
          ...current,
          ...readCart(persisted),
          isCartPanelOpen: false,
        }),
        onRehydrateStorage: () => (_state, error) => {
          if (error !== undefined) onError(error);
        },
      },
    ),
  );
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "noEmit": true
  },
  "include": ["src"]
}

기대 화면과 확인 순서

코트 담기를 두 번 누르면 한 행에 2개가 표시됩니다. 패널을 열고 새로고침하면 상품 수량은 남고 패널은 닫힘으로 돌아옵니다. 제거하면 마지막 행도 없어집니다. 실제 브라우저 새로고침 확인은 독자용 수동 절차입니다.

검증 범위: npm 설치, TypeScript 타입 검사와 Vite production 빌드, Vitest 자동 테스트를 실행했습니다. persist 테스트는 메모리 storage의 실제 rehydrate 경로이며 브라우저 재시작·다중 탭·SSR hydration 검증은 포함하지 않습니다.

같이 읽으면 좋은 글

공식 근거와 확인 범위

확인일: 2026-09-12. 실행 프로젝트는 Zustand 5.0.15, React 19.3.0, TypeScript 7.0.2로 검사했습니다. 공식 문서의 API 설명과 실제 설치 버전의 동작을 구분해 확인합니다.

연결 학습: persist 복원 후 action 확인

목표·선행 지식: 저장할 데이터와 런타임 함수를 구분합니다.

actions 객체까지 JSON에 저장하면 함수가 빠져 빈 객체가 될 수 있습니다. 복원 병합 방식에 따라 이 객체가 원래 action을 덮을 수 있으므로 partialize로 저장할 데이터를 명시합니다. 스키마 변경에는 version과 migrate를 검토합니다.

직접 확인할 과제

할 일 추가·새로고침·다시 추가를 연달아 실행하세요. 값 복원뿐 아니라 action 호출도 성공해야 합니다. 저장소 JSON에서 함수 묶음이 제외되는지 확인합니다. persist는 서버 백업이나 기기 간 동기화를 제공하는 기능이 아닙니다.

공통 실습 ZIP · 다음 학습 · 라이브러리 선택 가이드

공통 ZIP은 버전을 고정한 학습 예제입니다. UI 파일은 Radix·Sonner·Embla 기반 축약 구현이며 shadcn CLI 생성물과 동일하지 않습니다. 적용 범위와 실행 방법은 ZIP의 README를 확인하세요.

이 글이 도움이 되었나요?

조회 중

Zustand 학습 순서

필수 14개 · 전체 15개

읽음 기록 관리

전체 과정 목차 (15개)
  1. 필수 길잡이 · Zustand 학습 로드맵: store·action·selector·persist 순서
  2. 필수 길잡이 · React state vs Zustand: 전역 상태가 필요한 기준
  3. 필수 학습 · Zustand란? React 상태 관리 선택 기준과 기본 Store
  4. 필수 학습 · Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기
  5. 필수 학습 · Zustand state 사용법: 값 읽기와 변경 흐름 익히기
  6. 필수 학습 · Zustand action 사용법: 상태 변경 로직을 store로 분리하기
  7. 필수 학습 · Zustand selector 사용법: 필요한 상태만 가져와 리렌더링 줄이기
  8. 필수 학습 · Zustand 리렌더링 원리와 selector 최적화 방법
  9. 필수 학습 · Zustand persist 사용법: 새로고침 후 상태 저장하기 현재 글
  10. 필수 학습 · Zustand persist 마이그레이션 기준: 저장된 상태 구조가 바뀔 때
  11. 선택 참고 · Zustand 상태 변경 후 리렌더링이 안 될 때 해결 방법
  12. 필수 선수 · Zustand 실무 사용 기준: store가 복잡해질 때 피할 실수
  13. 필수 학습 · Zustand combine·immer 실습: 타입 추론과 중첩 상태 불변성
  14. 필수 학습 · Zustand subscribeWithSelector·devtools: 선택 구독과 해제 실습
  15. 필수 학습 · Zustand Todo 완성 실습: actions·선택 훅·persist 연결

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기