본문 바로가기

Zustand를 활용한 상태 관리

여러 컴포넌트가 함께 쓰는 상태를 Zustand 5 스토어로 만들고, 상태와 액션의 타입, 선택자로 필요한 값만 구독하는 방법, 겹친 비동기 요청에서 최신 결과만 반영하는 방법, persist로 저장하고 복원할 때의 동작을 확인합니다.

useState는 한 컴포넌트의 상태를, Context는 한 트리 안에서 값을 전달하는 방법을 줍니다. 그런데 서로 멀리 떨어진 여러 컴포넌트가 같은 상태를 읽고 바꿔야 하면, 상태를 아주 위쪽 컴포넌트로 올리고 Provider와 props로 내려보내는 코드가 길어집니다. 이럴 때 컴포넌트 바깥에 상태를 두고 필요한 컴포넌트가 직접 구독하게 하는 상태 관리 라이브러리를 씁니다.

Zustand는 그런 라이브러리 가운데 API가 작은 편으로, 상태와 그것을 바꾸는 함수를 하나의 스토어(store)로 만들고 훅으로 꺼내 씁니다. 이 글은 2026년 10월 현재 최신인 Zustand 5로 스토어에 타입을 붙이고, 선택자(selector)로 필요한 값만 구독한 뒤, 비동기 액션과 저장 미들웨어를 다룹니다.

스토어 만들기

먼저 프로젝트에 Zustand를 설치합니다. Zustand는 타입 정의를 패키지 안에 함께 배포하므로 @types 패키지를 따로 설치하지 않아도 됩니다.

npm install zustand

스토어는 JSX가 없으므로 .ts 파일로 만듭니다. 다음은 카운터의 상태와 그것을 바꾸는 액션을 함께 담은 스토어입니다.

src/store/counterStore.ts
import { create } from "zustand";

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
  incrementByAmount: (amount: number) => void;
}

export const useCounterStore = create<CounterState>()((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
  incrementByAmount: (amount) => set((state) => ({ count: state.count + amount })),
}));

create<CounterState>()(...)에서 타입 인수 뒤의 빈 괄호는 실수가 아닙니다. 스토어를 만드는 함수는 상태를 돌려주면서 동시에 set과 get으로 그 상태를 받기도 해서, 타입스크립트가 초기값만으로 상태의 타입을 추론하지 못합니다. 그래서 Zustand의 TypeScript 안내는 상태의 타입을 먼저 적고 한 번 더 호출하는 이 커링 형태를 권하며, 미들웨어를 쓸 때도 같은 형태가 필요합니다.

set은 넘긴 객체를 현재 상태와 합쳐 새 상태를 만듭니다. 이때 합치는 것은 최상위 속성 한 단계뿐이어서, set({ count: 0 })은 count만 바꾸고 액션 함수들은 그대로 둡니다. 중첩된 객체의 일부만 바꾸려면 전개 구문으로 안쪽 객체를 직접 복사해야 하며, Zustand가 상태 객체를 직접 고치는 코드를 막아 주지는 않습니다.

더 깊이: 빈 괄호가 필요한 두 가지 이유

Zustand의 고급 TypeScript 안내는 상태의 타입을 초기값에서 추론하지 못하는 이유를, 상태의 타입 T가 불공변(invariant)이기 때문이라고 설명합니다. 스토어를 만드는 함수는 상태를 돌려주므로 T가 출력 자리에 있고, 동시에 매개변수로 받은 get이 T를 돌려주므로 입력 자리에도 있습니다. 다시 말해 T가 공변(covariant) 자리와 반공변(contravariant) 자리에 함께 있는 것이며, 이때 컴파일러는 T를 어디서 정할지 알 수 없어 unknown으로 추론합니다.

그렇다면 create<CounterState>(...)처럼 한 번에 적으면 될 것 같지만, create에는 미들웨어가 더하는 타입을 위한 타입 매개변수가 더 있습니다. 타입스크립트에서는 타입 인수를 일부만 적으면 나머지 타입 매개변수를 인수에서 추론하지 않으므로, 안내는 이 커링 형태를 TypeScript 저장소의 이슈 #10571에 대한 우회책이라고 부릅니다. 첫 호출 create<CounterState>()는 실행 중에는 아무 일도 하지 않고 상태의 타입만 정하며, 그것이 돌려준 함수가 나머지 타입을 인수에서 추론합니다. 안내는 이 타입이 완전히 건전하지는 않다는 점도 인정하는데, 스토어를 만드는 도중에 get()을 동기적으로 부르면 검사는 통과하지만 초기 상태가 아직 없어 실행 중에는 undefined를 받습니다.

선택자로 구독하기

컴포넌트는 스토어 훅에 선택자 함수를 넘겨, 상태 가운데 필요한 값만 꺼냅니다. 선택자의 결과가 바뀔 때만 그 컴포넌트가 다시 렌더링됩니다.

src/components/ZustandCounter.tsx
import { useCounterStore } from "../store/counterStore";

export default function ZustandCounter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);
  const reset = useCounterStore((state) => state.reset);
  const incrementByAmount = useCounterStore((state) => state.incrementByAmount);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
      <button onClick={reset}>Reset</button>
      <button onClick={() => incrementByAmount(5)}>Increment by 5</button>
    </div>
  );
}
src/components/ZustandCountDisplay.tsx
import { useCounterStore } from "../store/counterStore";

export default function ZustandCountDisplay() {
  const count = useCounterStore((state) => state.count);
  return <h3>Current Count (from another component): {count}</h3>;
}
src/App.tsx
import ZustandCounter from "./components/ZustandCounter";
import ZustandCountDisplay from "./components/ZustandCountDisplay";

export default function App() {
  return (
    <div>
      <ZustandCounter />
      <ZustandCountDisplay />
    </div>
  );
}

두 컴포넌트는 처음에 모두 0을 보여 줍니다. Increment by 5를 누르면 둘 다 5, Decrement를 누르면 둘 다 4가 되고, Reset 뒤에는 둘 다 0입니다. 13장 2절의 useCounter는 부르는 컴포넌트마다 상태를 따로 만들었지만, 여기서는 두 컴포넌트가 같은 스토어의 count 하나를 읽습니다. 이 decrement는 0 아래를 막지 않으므로 Reset 뒤 Decrement를 누르면 둘 다 -1이 됩니다.

카운터 action의 set이 같은 store를 갱신하고 두 컴포넌트가 각각 count selector로 구독하는 실제 예와 소유권·병합·영속화의 설계 경계.

ZUSTAND / ACTION AND SELECTOR

같은 store의 필요한 값만 각 컴포넌트가 구독한다

교재 카운터는 action으로 count를 갱신하고 Counter와 CountDisplay가 같은 count를 각각 선택합니다. selector는 store 변경에 의한 갱신 범위를 줄이며, 부모·Context 등 모든 렌더 원인을 차단하는 것은 아닙니다.

같은 store의 필요한 값만 각 컴포넌트가 구독한다카운터 action의 set이 같은 store를 갱신하고 두 컴포넌트가 각각 count selector로 구독하는 실제 예와 소유권·병합·영속화의 설계 경계.counter actionset으로 count 갱신동일한 counter storecount + actionsCounterstate.count 구독CountDisplaystate.count 구독
같은 store의 두 count 구독자를 분기한다counter action→동일 counter store에서 Counter와 CountDisplay로 각각 연결된다. 두 구독자는 같은 count를 독립적으로 선택하며 Counter→CountDisplay의 실행 순서가 아니다. 원 네 노드·세 간선을 유지하고 부모·Context 렌더와 저장 partialize는 별도 경계다.counter actionset으로 count 갱신동일한 counter storecount + actionsCounterstate.count 구독CountDisplaystate.count 구독

SELECTOR RESULT

선택 결과의 변화가 기준

state.count 같은 필요한 값을 선택합니다. 선택 결과는 기본 Object.is 비교로 판단하며 여러 값을 새 객체로 반환한다면 참조 안정성과 useShallow 등 적절한 비교를 고려합니다.

LESSON EXAMPLE

전체 구독 사례도 있다

Counter는 count와 action을 각각 선택하지만 UserProfile은 useUserStore()로 전체 상태를 구독합니다. selector가 어떤 값과 갱신에 관심 있는지 명시합니다. 부모나 Context의 렌더도 별도로 볼 수 있습니다.

DESIGN EXTENSION

지역·서버·공유 클라이언트 상태

입력 초안 같은 지역 UI, 재검증이 필요한 서버 데이터, 여러 화면의 공유 사실을 분류하는 것은 권장 설계입니다. 원문 userStore는 서버 캐시 도구로 분리한 구현이 아닙니다.

UPDATE POLICY

action 경계는 작성자가 정한다

이름 붙은 action으로 갱신을 모으면 의도가 드러납니다. Zustand가 외부 setState나 직접 객체 변경을 자동 금지하지는 않으며 set은 한 단계만 얕게 병합해 중첩 값은 복사해야 합니다.

PERSIST BOUNDARY

선택 구독과 저장 범위는 별개

selector는 읽는 값의 범위입니다. partialize는 저장할 필드를 고르는 별도 경계이므로 작은 selector가 비밀값·일시 상태의 저장을 자동 제외하지 않습니다.

그림의 두 구독자는 같은 count를 보므로 해당 값이 바뀌면 둘 다 갱신됩니다. 독립 selector는 관련 store 변경 범위를 줄이는 도구입니다.

그림처럼 액션의 set이 스토어를 바꾸면, 같은 count를 선택한 두 구독자가 각각 새 값을 받습니다. 두 컴포넌트가 차례로 실행되는 것이 아니라 같은 값을 따로 구독하는 것이며, 선택자는 스토어의 변경 가운데 어떤 것에 반응할지를 정할 뿐 부모나 Context 때문에 생기는 렌더링까지 막지는 않습니다. 선택자의 결과가 바뀌었는지는 기본으로 Object.is로 비교합니다.

그래서 선택자가 여러 값을 담은 새 객체를 돌려주면 문제가 됩니다. 매번 새 객체가 만들어져 결과가 늘 바뀐 것으로 보이기 때문이며, Zustand 5에서는 이것이 Maximum update depth exceeded 오류로 이어질 수 있습니다. 여러 값을 한 번에 꺼낼 때는 zustand/react/shallow의 useShallow로 선택자를 감싸, 안의 값이 얕게 같으면 같은 결과로 보게 합니다.

import { useShallow } from "zustand/react/shallow";

const { increment, decrement } = useCounterStore(
  useShallow((state) => ({ increment: state.increment, decrement: state.decrement })),
);

비동기 액션

액션은 보통의 함수이므로 async 함수로 만들 수 있고, 작업의 진행에 따라 set을 여러 번 부를 수 있습니다. 다음 스토어는 앞 글들의 사용자 불러오기를 스토어로 옮긴 것입니다.

src/store/userStore.ts
import { create } from "zustand";

export interface User {
  id: number;
  name: string;
  email: string;
}

export function fakeFetchUser(userId: number): Promise<User> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (userId === 1) {
        resolve({ id: 1, name: "Alice", email: "[email protected]" });
      } else {
        reject(new Error("User not found!"));
      }
    }, 1500);
  });
}

interface UserState {
  user: User | null;
  loading: boolean;
  error: string | null;
  fetchUser: (userId: number) => Promise<void>;
}

export const useUserStore = create<UserState>()((set) => ({
  user: null,
  loading: false,
  error: null,
  fetchUser: async (userId) => {
    set({ loading: true, error: null });
    try {
      const user = await fakeFetchUser(userId);
      set({ user, loading: false });
    } catch (err) {
      set({ user: null, loading: false, error: err instanceof Error ? err.message : "Unknown error" });
    }
  },
}));
src/components/ZustandUserProfile.tsx
import { useUserStore } from "../store/userStore";

export default function ZustandUserProfile() {
  const { user, loading, error, fetchUser } = useUserStore();

  return (
    <div>
      <button onClick={() => fetchUser(1)} disabled={loading}>
        Fetch Alice (ID 1)
      </button>
      <button onClick={() => fetchUser(99)} disabled={loading}>
        Fetch Non-Existent User (ID 99)
      </button>
      {error && <p>Error: {error}</p>}
      {user ? <p>{user.name} ({user.email})</p> : !loading && !error && <p>Click a button to fetch user data.</p>}
    </div>
  );
}

처음에는 Click a button to fetch user data.가 보이고, ID 1을 누르면 1.5초 뒤 Alice ([email protected])가 보입니다. 이어서 ID 99를 누르면 불러오는 동안에는 Alice가 남아 있다가, 실패가 도착하면 user가 null이 되고 Error: User not found!가 보입니다. 선택자 없이 useUserStore()를 부르면 상태 전체를 구독하므로, 이 컴포넌트는 스토어의 어떤 값이 바뀌어도 다시 렌더링됩니다.

버튼은 불러오는 동안 비활성화되지만, 다른 코드가 fetchUser를 직접 부르는 것까지 막지는 못합니다. 이 액션에는 요청을 구별하는 장치가 없으므로, 두 요청이 겹치면 나중에 끝난 요청의 결과가 먼저 시작했든 나중에 시작했든 상태를 덮습니다. 또 이 스토어는 받은 사용자를 담아 둘 뿐이어서, 서버 데이터를 캐시하고 다시 검증하는 일은 TanStack Query 같은 서버 상태 도구에 맡기는 편이 낫습니다.

최신 요청만 반영하기

겹친 요청을 다루는 방법 가운데 하나는 요청마다 번호를 붙이고, 결과가 도착했을 때 그 번호가 가장 최근의 것인지 확인하는 것입니다. 13장 2절에서 본 ignore 플래그와 같은 생각을 스토어에 적용한 것입니다.

비동기 요청 identity와 hydration 상태를 따로 관리한다 — 상태 추적

요청 식별자 및 저장 버전/hydration 상태가 각각 달라진다. 본문에는 요청 ID/취소와 저장 검증이 없으므로 두 독립 흐름을 구분한 설계 확장으로 작성해야 한다.

독립된 두 열로 요청 시작·결과 판정·현재 상태 반영과 저장소 읽기·버전 및 구조 확인·병합 복원을 나타내는 확장 설계.

ZUSTAND / REQUESTS AND PERSISTENCE

요청 경합과 저장소 복원은 독립된 수명으로 관리한다

왼쪽은 요청 identity로 현재 결과만 반영하는 설계, 오른쪽은 저장값을 복원하는 설계입니다. 본문 fetchUser에는 경합 제어가 없으며 settings의 기본 localStorage 복원은 동기입니다. 두 축의 완료를 같은 단계로 묶지 않습니다.

요청 경합과 저장소 복원은 독립된 수명으로 관리한다독립된 두 열로 요청 시작·결과 판정·현재 상태 반영과 저장소 읽기·버전 및 구조 확인·병합 복원을 나타내는 확장 설계.요청 시작현재 identity 저장성공·실패·종료 도착현재 identity인가?현재 결과만 반영이전 결과는 무시저장소에서 읽기동기 또는 비동기버전·구조 확인필요한 migration현재 state와 병합복원 완료·오류 처리
요청 결과와 저장값 복원의 두 독립 경로요청 시작→결과가 현재인지 판정→현재 결과만 반영의 경로와 저장소 읽기→버전·구조 확인→현재 상태에 병합의 경로를 분리한다. 원 여섯 노드·네 간선이며 두 경로를 연결하지 않는다. 경합 제어·검증은 원 그림의 설계 확장이고 기본 localStorage 복원은 동기라는 조건을 유지한다.요청 결과의 수명요청 시작현재 identity 저장성공 · 실패 도착현재 identity인가?현재 결과만 반영이전 결과는 무시저장소에서 읽기동기 또는 비동기버전 · 구조 확인필요한 migration현재 state와 병합복원 완료 · 오류별도: 저장값 복원의 수명

REQUEST OWNERSHIP

종료 처리도 최신 요청만

요청이 겹칠 수 있다면 success·error뿐 아니라 loading을 끄는 종료 처리도 현재 요청인지 확인합니다. 이전 완료가 새 요청의 loading을 해제해서는 안 됩니다.

ACTUAL EXAMPLE

본문 fetchUser의 범위

1.5초 Mock 결과를 set으로 저장합니다. 버튼은 loading 동안 비활성화하지만 다른 호출자와 경합을 막는 요청 ID·취소·응답 검증은 없으며, 그림의 경합 제어는 설계 확장입니다.

HYDRATION TIMING

동기 저장소와 비동기 저장소

기본 localStorage 복원은 보통 store 생성 중 동기로 끝납니다. 비동기 저장소는 나중에 완료하므로 필요하면 대기 UI·완료/오류 상태를 별도로 연결합니다. 모든 persist가 비동기 대기 단계를 갖지는 않습니다.

STORAGE CONTRACT

버전과 구조를 직접 확인

version/migrate와 값 구조 검사는 서로 다릅니다. 앱이 저장값을 검사하고 실패·기본값 정책을 정해야 하며 타입 주석이나 migrate 옵션만으로 schema 검증이 자동 수행되지는 않습니다.

PARTIALIZE

저장할 필드를 선택

복구 가능한 설정을 partialize로 명시하고 토큰·진행 중 요청·일시 오류를 정책에 따라 제외합니다. 원문 settingsStore에는 partialize가 주석으로만 있으며 자동 허용 목록 검증을 구현하지 않았습니다.

RESTORE AND UPDATE

병합과 재저장을 구분

persist의 기본 복원은 얕은 병합입니다. 중첩 데이터는 병합 정책을 확인합니다. 후속 state 갱신의 저장은 복원 이후의 별도 동작이며 저장했다고 요청 상태가 idle로 전환되지는 않습니다.

저장소 복원은 네트워크 요청의 선행 조건이 자동으로 되지 않습니다. 그림은 필요한 경합·검증 정책을 추가한 설계이며 실제 예제와 구분합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
서로 다른 두 입력request: pending / persist: hydration 중 모델네트워크 요청과 저장값 복원은 독립 수명이다본문에 없는 request ID/검증 UI는 설계 보강으로 분리한다
현재성/버전 검사request: 현재 여부 / persist: 적합 버전 여부요청은 현재 ID, 저장값은 version과 shape/merge 정책을 확인한다완료 속도와 신뢰 여부는 다르다
독립 결과 반영request: 현재 결과 / persist: 허용 복원 값현재 요청 데이터와 허용 복원 값만 각 소유 state에 반영한다두 흐름을 하나의 필수 순서로 잇지 않는다

비동기 요청 identity와 hydration 상태를 따로 관리한다 — 상태 추적

요청 식별자 및 저장 버전/hydration 상태가 각각 달라진다. 본문에는 요청 ID/취소와 저장 검증이 없으므로 두 독립 흐름을 구분한 설계 확장으로 작성해야 한다.

독립된 두 열로 요청 시작·결과 판정·현재 상태 반영과 저장소 읽기·버전 및 구조 확인·병합 복원을 나타내는 확장 설계.

ZUSTAND / REQUESTS AND PERSISTENCE

요청 경합과 저장소 복원은 독립된 수명으로 관리한다

왼쪽은 요청 identity로 현재 결과만 반영하는 설계, 오른쪽은 저장값을 복원하는 설계입니다. 본문 fetchUser에는 경합 제어가 없으며 settings의 기본 localStorage 복원은 동기입니다. 두 축의 완료를 같은 단계로 묶지 않습니다.

요청 경합과 저장소 복원은 독립된 수명으로 관리한다독립된 두 열로 요청 시작·결과 판정·현재 상태 반영과 저장소 읽기·버전 및 구조 확인·병합 복원을 나타내는 확장 설계.요청 시작현재 identity 저장성공·실패·종료 도착현재 identity인가?현재 결과만 반영이전 결과는 무시저장소에서 읽기동기 또는 비동기버전·구조 확인필요한 migration현재 state와 병합복원 완료·오류 처리
요청 결과와 저장값 복원의 두 독립 경로요청 시작→결과가 현재인지 판정→현재 결과만 반영의 경로와 저장소 읽기→버전·구조 확인→현재 상태에 병합의 경로를 분리한다. 원 여섯 노드·네 간선이며 두 경로를 연결하지 않는다. 경합 제어·검증은 원 그림의 설계 확장이고 기본 localStorage 복원은 동기라는 조건을 유지한다.요청 결과의 수명요청 시작현재 identity 저장성공 · 실패 도착현재 identity인가?현재 결과만 반영이전 결과는 무시저장소에서 읽기동기 또는 비동기버전 · 구조 확인필요한 migration현재 state와 병합복원 완료 · 오류별도: 저장값 복원의 수명

REQUEST OWNERSHIP

종료 처리도 최신 요청만

요청이 겹칠 수 있다면 success·error뿐 아니라 loading을 끄는 종료 처리도 현재 요청인지 확인합니다. 이전 완료가 새 요청의 loading을 해제해서는 안 됩니다.

ACTUAL EXAMPLE

본문 fetchUser의 범위

1.5초 Mock 결과를 set으로 저장합니다. 버튼은 loading 동안 비활성화하지만 다른 호출자와 경합을 막는 요청 ID·취소·응답 검증은 없으며, 그림의 경합 제어는 설계 확장입니다.

HYDRATION TIMING

동기 저장소와 비동기 저장소

기본 localStorage 복원은 보통 store 생성 중 동기로 끝납니다. 비동기 저장소는 나중에 완료하므로 필요하면 대기 UI·완료/오류 상태를 별도로 연결합니다. 모든 persist가 비동기 대기 단계를 갖지는 않습니다.

STORAGE CONTRACT

버전과 구조를 직접 확인

version/migrate와 값 구조 검사는 서로 다릅니다. 앱이 저장값을 검사하고 실패·기본값 정책을 정해야 하며 타입 주석이나 migrate 옵션만으로 schema 검증이 자동 수행되지는 않습니다.

PARTIALIZE

저장할 필드를 선택

복구 가능한 설정을 partialize로 명시하고 토큰·진행 중 요청·일시 오류를 정책에 따라 제외합니다. 원문 settingsStore에는 partialize가 주석으로만 있으며 자동 허용 목록 검증을 구현하지 않았습니다.

RESTORE AND UPDATE

병합과 재저장을 구분

persist의 기본 복원은 얕은 병합입니다. 중첩 데이터는 병합 정책을 확인합니다. 후속 state 갱신의 저장은 복원 이후의 별도 동작이며 저장했다고 요청 상태가 idle로 전환되지는 않습니다.

저장소 복원은 네트워크 요청의 선행 조건이 자동으로 되지 않습니다. 그림은 필요한 경합·검증 정책을 추가한 설계이며 실제 예제와 구분합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
서로 다른 두 입력request: pending / persist: hydration 중 모델네트워크 요청과 저장값 복원은 독립 수명이다본문에 없는 request ID/검증 UI는 설계 보강으로 분리한다
현재성/버전 검사request: 현재 여부 / persist: 적합 버전 여부요청은 현재 ID, 저장값은 version과 shape/merge 정책을 확인한다완료 속도와 신뢰 여부는 다르다
독립 결과 반영request: 현재 결과 / persist: 허용 복원 값현재 요청 데이터와 허용 복원 값만 각 소유 state에 반영한다두 흐름을 하나의 필수 순서로 잇지 않는다

그림의 왼쪽은 요청을 시작할 때 번호를 기억해 두고 현재 번호의 결과만 반영하는 흐름이고, 오른쪽은 다음 절에서 볼 저장값의 복원입니다. 두 흐름은 서로 다른 수명을 가지므로 하나의 단계로 묶지 않습니다. 다음 스토어가 왼쪽 흐름을 구현합니다.

src/store/latestUserStore.ts
import { create } from "zustand";
import { fakeFetchUser } from "./userStore";
import type { User } from "./userStore";

interface LatestUserState {
  user: User | null;
  loading: boolean;
  error: string | null;
  fetchUser: (userId: number) => Promise<void>;
}

export const useLatestUserStore = create<LatestUserState>()((set) => {
  let version = 0;
  return {
    user: null,
    loading: false,
    error: null,
    fetchUser: async (userId) => {
      const mine = ++version;
      set({ user: null, loading: true, error: null });
      try {
        const user = await fakeFetchUser(userId);
        if (mine !== version) return;
        set({ user, loading: false });
      } catch (err) {
        if (mine !== version) return;
        set({ user: null, loading: false, error: err instanceof Error ? err.message : "Unknown error" });
      }
    },
  };
});

version은 스토어를 만드는 함수 안의 지역 변수여서 바깥에서 바꿀 수 없고, fetchUser를 부를 때마다 1씩 늘어납니다. 결과가 도착했을 때 자기 번호 mine이 현재 version과 다르면 그사이에 새 요청이 시작된 것이므로 아무것도 하지 않습니다. 성공뿐 아니라 실패와 loading을 끄는 처리까지 번호를 확인해야, 이전 요청이 먼저 끝나면서 새 요청의 loading을 꺼 버리는 일이 없습니다.

이 스토어는 새 요청을 시작할 때 이전 사용자를 지우도록 정했으므로, 불러오는 동안 이전 사용자를 남기던 userStore와 화면이 다릅니다. 또 이전 요청의 타이머를 취소하지는 않으므로 이전 요청도 끝까지 실행되며, 그 결과를 상태에 쓰지 않을 뿐입니다. 실제 HTTP 요청이라면 AbortController로 요청 자체를 취소하는 방법을 함께 씁니다.

미들웨어로 저장하고 복원하기

Zustand는 스토어를 만드는 함수를 감싸 기능을 더하는 미들웨어를 제공합니다. devtools는 상태의 변화를 Redux DevTools 브라우저 확장에서 볼 수 있게 하고, persist는 상태를 브라우저 저장소에 저장했다가 다시 불러옵니다.

src/store/settingsStore.ts
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";

interface SettingsState {
  darkMode: boolean;
  fontSize: number;
  toggleDarkMode: () => void;
  setFontSize: (size: number) => void;
}

export const useSettingsStore = create<SettingsState>()(
  devtools(
    persist(
      (set) => ({
        darkMode: false,
        fontSize: 16,
        toggleDarkMode: () => set((state) => ({ darkMode: !state.darkMode })),
        setFontSize: (size) => set({ fontSize: size }),
      }),
      {
        name: "user-settings-storage",
        // partialize: (state) => ({ darkMode: state.darkMode, fontSize: state.fontSize }),
        // storage: createJSONStorage(() => sessionStorage),
      },
    ),
  ),
);

persist는 name을 키로 상태를 JSON으로 바꾸어 저장하며, 저장소를 정하지 않으면 localStorage를 씁니다. localStorage는 동기 저장소이므로 스토어가 만들어질 때 저장값의 복원도 함께 끝나고, 첫 렌더링부터 복원된 값을 볼 수 있습니다. 반면 React Native의 AsyncStorage 같은 비동기 저장소는 복원이 나중에 끝나므로, 필요하면 onRehydrateStorage 옵션이나 persist.hasHydrated()로 복원이 끝났는지 확인해 기다리는 화면을 따로 만듭니다.

주석으로 둔 두 옵션은 필요할 때 켭니다. storage를 바꾸려면 zustand/middleware에서 createJSONStorage도 가져와야 하고, partialize는 저장할 필드를 고릅니다. 선택자는 컴포넌트가 읽는 값을 정할 뿐이어서, 토큰이나 진행 중인 요청 같은 값을 저장하지 않으려면 partialize로 저장할 필드를 직접 정해야 합니다.

복원할 때 persist는 저장값을 현재 상태에 얕게 합치므로, 중첩된 객체는 저장값의 것으로 통째로 바뀝니다. 또 저장값의 모양을 검사하지 않으므로, 누군가 저장소의 값을 "fontSize": "large"처럼 바꾸어 두면 number로 선언한 필드에 문자열이 들어옵니다. 저장하는 모양이 바뀔 때는 version과 migrate 옵션으로 옛 값을 옮기고, 값 자체의 검증은 앱에서 따로 합니다.

정리

  • Zustand 스토어는 create<State>()(...)의 커링 형태로 상태와 액션의 타입을 먼저 적어 만들며, set은 최상위 속성 한 단계만 합칩니다.
  • 컴포넌트는 선택자로 필요한 값만 구독하고, 결과는 Object.is로 비교되므로 여러 값을 객체로 꺼낼 때는 useShallow로 감쌉니다.
  • 같은 스토어를 구독하는 컴포넌트는 같은 값을 보며, 선택자 없이 부르면 상태 전체를 구독합니다.
  • 겹칠 수 있는 비동기 액션은 요청 번호로 최신 요청인지 확인하고, 성공과 실패, loading의 해제 모두에 그 확인을 적용합니다.
  • persist는 기본으로 localStorage에 저장하고 스토어를 만들 때 동기로 복원하며, 저장 범위는 partialize로, 모양의 변경은 version과 migrate로 다룹니다.

연습 문제

1. 두 컴포넌트의 값

ZustandCounter에서 Increment를 한 번, Increment by 5를 한 번, Decrement를 두 번 눌렀습니다. 두 컴포넌트가 보여 주는 값은 얼마인가요? 그 뒤 useUserStore의 user가 바뀌면 count도 바뀌나요?

해설 보기

0에서 1, 6, 5, 4로 바뀌므로 두 컴포넌트 모두 4를 보여 줍니다. useUserStore는 별도의 스토어이므로, 그 상태가 바뀌어도 count는 그대로이고 count를 선택한 컴포넌트도 그 변경 때문에 다시 렌더링되지 않습니다.

2. 겹친 요청

다른 코드가 ID 99의 요청 A를 시작하고 곧바로 ID 1의 요청 B를 시작했으며, B의 성공이 먼저, A의 실패가 나중에 도착했다고 가정합니다. useUserStore와 useLatestUserStore의 마지막 user, error, loading은 각각 무엇인가요?

해설 보기

useUserStore는 마지막에 도착한 A의 실패가 상태를 덮어, user는 null, error는 User not found!, loading은 false가 됩니다. useLatestUserStore는 A의 번호가 현재 번호가 아니므로 A의 결과를 버리고, B가 반영한 Alice와 error의 null, loading의 false를 유지합니다. 순서가 반대여서 A의 실패가 먼저 도착하고 B가 아직 진행 중이라면, useUserStore는 loading을 false로 꺼 버리지만 useLatestUserStore는 true를 유지합니다.

3. 새로고침 뒤의 값

설정 스토어에서 toggleDarkMode()를 한 번, setFontSize(20)을 한 번 실행한 뒤 페이지를 새로고침했습니다. 저장과 복원이 정상적으로 이루어졌다면 설정과 카운터의 count는 각각 무엇인가요?

해설 보기

설정은 저장소에서 복원되어 darkMode는 true, fontSize는 20입니다. 카운터 스토어에는 persist가 없으므로 새로고침하면 모듈이 다시 실행되어 count는 처음 값 0부터 시작합니다. 다만 저장소를 쓸 수 없는 환경이거나 저장값이 손상되었다면 이 결과가 보장되지 않으며, setFontSize가 받는 숫자의 범위도 따로 검사해야 합니다.

다음 글 테스팅과 성능 최적화에서는 React Testing Library로 사용자의 동작과 비동기 화면을 검사하고, memo, useCallback, useMemo를 측정한 뒤에 적용하는 방법을 다룹니다.