Zustand를 활용한 상태 관리
Zustand 스토어의 상태·액션을 TypeScript로 정의하고 컴포넌트 구독·비동기 갱신·persist 미들웨어를 적용합니다.
React 애플리케이션에서 상태 관리는 규모와 복잡도가 커질수록 더 중요해집니다.
useState, useContext만으로는
전역 상태나 복잡한 비동기 상태를 다루기 어려운 구간이 생깁니다.
Redux Toolkit, Zustand, Jotai 같은 대안이 있고, 그중 Zustand는 가볍고 간결한 API로 많이 선택되고 있습니다.
Zustand는 훅 기반의 상태 관리 라이브러리로, 별도의 보일러플레이트 코드 없이 쉽게 전역 상태를 만들고 사용할 수 있게 해줍니다.
타입스크립트와 함께 사용하면 상태의 구조와 액션의 타입을 명확하게 정의하여 더욱 견고한 상태 관리가 가능합니다.
그림은 상태 소유권을 나누는 권장 설계입니다. 아래 useUserStore() 예제는 전체 상태를 구독하며, 서버 응답을 별도 캐시로 분리한 구현은 아닙니다. selector는 스토어 변경에 의한 구독 범위를 줄이지만 부모나 Context에 의한 렌더까지 막지는 않습니다.
ZUSTAND / ACTION AND SELECTOR
같은 store의 필요한 값만 각 컴포넌트가 구독한다
교재 카운터는 action으로 count를 갱신하고 Counter와 CountDisplay가 같은 count를 각각 선택합니다. selector는 store 변경에 의한 갱신 범위를 줄이며, 부모·Context 등 모든 렌더 원인을 차단하는 것은 아닙니다.
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 변경 범위를 줄이는 도구입니다.
Zustand 시작하기
Zustand를 사용하려면 먼저 프로젝트에 설치해야 합니다.
npm install zustand
# 또는
yarn add zustand스토어 파일은 JSX가 없는 .ts, 컴포넌트와 App은 .tsx로 나눕니다. 아래 경로를 그대로 만들고 기존 React·TypeScript App에서 App.tsx만 선택합니다. 서버나 여러 사용자의 요청을 하나의 모듈 전역 store로 처리하는 설정까지 다루는 예제는 아닙니다. 여기서는 한 브라우저 앱에서 같은 store 모듈을 가져오는 두 컴포넌트의 공유를 관찰합니다.
기본적인 전역 스토어 생성 및 사용
Zustand의 핵심은 create 함수를 사용하여 스토어(Store)를 정의하는 것입니다.
이 스토어는 함수형 컴포넌트의 useState와 유사하게 상태와 해당 상태를 업데이트하는 함수들을 포함합니다.
src/store/counterStore.ts)먼저 카운터 상태를 관리할 스토어를 정의해봅시다.
import { create } from 'zustand';
// 1. 상태의 타입 정의
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
incrementByAmount: (amount: number) => void;
}
// 2. create 함수를 사용하여 스토어 생성
// create<T>() 제네릭을 사용하여 스토어의 상태와 액션의 타입을 명확히 지정합니다.
export const useCounterStore = create<CounterState>((set) => ({
// 초기 상태 정의
count: 0,
// 상태를 업데이트하는 액션 함수 정의
// `set` 함수를 사용하여 상태를 불변적으로 업데이트합니다.
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }), // 초기값으로 직접 설정
incrementByAmount: (amount: number) => set((state) => ({ count: state.count + amount })),
}));
// 추가 설명:
// - `set`: 상태를 업데이트하는 함수입니다. `set(newState)` 또는 `set((state) => newState)` 형태로 사용됩니다.
// - `get`: (선택 사항) 현재 스토어의 상태를 가져올 때 사용됩니다.
// - `zustand/middleware`의 `devtools`나 `persist` 미들웨어와 함께 사용할 수 있습니다.src/components/ZustandCounter.tsx)useCounterStore 훅을 사용하여 컴포넌트에서 상태와 액션에 접근할 수 있습니다.
import React from 'react';
import { useCounterStore } from '../store/counterStore'; // 스토어 임포트
const ZustandCounter: React.FC = () => {
// 스토어에서 원하는 상태와 액션을 선택적으로 가져올 수 있습니다.
// 이 방식은 컴포넌트가 필요한 상태만 구독하므로 불필요한 리렌더링을 방지합니다.
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>
<h2>Zustand Counter</h2>
<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>
);
};
export default ZustandCounter;src/components/ZustandCountDisplay.tsx)어떤 컴포넌트에서든 useCounterStore 훅을 호출하여 전역 상태에 접근할 수 있습니다.
import React from 'react';
import { useCounterStore } from '../store/counterStore';
const ZustandCountDisplay: React.FC = () => {
const count = useCounterStore((state) => state.count);
return (
<div style={{ border: '1px solid #ccc', padding: '10px', marginTop: '20px' }}>
<h3>Current Count (from another component): {count}</h3>
</div>
);
};
export default ZustandCountDisplay;import React from 'react';
import ZustandCounter from './components/ZustandCounter';
import ZustandCountDisplay from './components/ZustandCountDisplay';
const App: React.FC = () => {
return (
<div style={{ padding: '20px' }}>
<h1>React with Zustand & TypeScript</h1>
<ZustandCounter />
<ZustandCountDisplay /> {/* 같은 전역 상태를 공유 */}
</div>
);
};
export default App;처음 두 화면의 count는 모두 0입니다. Increment by 5와 Decrement를 차례로 누르면 두 화면 모두 5→4가 되고, Reset 뒤에는 둘 다 0입니다. 서로 다른 useCounter 훅 호출이 각각 지역 상태를 만드는 13장 2절과 달리, 여기서는 같은 useCounterStore에 있는 하나의 count를 선택합니다. 원 decrement는 0보다 작아지는 것을 막지 않으므로 Reset 뒤 Decrement를 한 번 누르면 두 값 모두 -1입니다.
비동기 액션 처리
그림은 요청 경합과 비동기 저장소 복원을 추가한 확장 설계입니다. 원문의 fetchUser에는 요청 ID나 취소 처리가 없으므로 여러 호출이 겹치면 늦은 결과가 최신 상태를 덮어쓸 수 있습니다. settings 스토어도 버전·구조 검증이나 hydration 대기 UI를 구현하지 않았습니다. 기본 localStorage의 동기 복원과 비동기 저장소의 복원 시점은 구분합니다.
비동기 요청 identity와 hydration 상태를 따로 관리한다 — 상태 추적
요청 식별자 및 저장 버전/hydration 상태가 각각 달라진다. 본문에는 요청 ID/취소와 저장 검증이 없으므로 두 독립 흐름을 구분한 설계 확장으로 작성해야 한다.
ZUSTAND / REQUESTS AND PERSISTENCE
요청 경합과 저장소 복원은 독립된 수명으로 관리한다
왼쪽은 요청 identity로 현재 결과만 반영하는 설계, 오른쪽은 저장값을 복원하는 설계입니다. 본문 fetchUser에는 경합 제어가 없으며 settings의 기본 localStorage 복원은 동기입니다. 두 축의 완료를 같은 단계로 묶지 않습니다.
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 복원은 동기입니다. 두 축의 완료를 같은 단계로 묶지 않습니다.
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에 반영한다 | 두 흐름을 하나의 필수 순서로 잇지 않는다 |
Zustand는 비동기 액션 처리도 매우 간결하게 지원합니다.
액션 함수 내에서 async/await를 사용하여 비동기 작업을 수행하고, 그 결과에 따라 상태를 업데이트할 수 있습니다.
src/store/userStore.ts)import { create } from 'zustand';
interface User {
id: number;
name: string;
email: string;
}
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: number) => {
set({ loading: true, error: null }); // 로딩 시작, 에러 초기화
try {
// 가상 API 호출
const response = await new Promise<User>((resolve, reject) => {
setTimeout(() => {
if (userId === 1) {
resolve({ id: 1, name: 'Alice', email: '[email protected]' });
} else {
reject(new Error('User not found!'));
}
}, 1500);
});
set({ user: response, loading: false }); // 성공 시 상태 업데이트
} catch (err: any) {
set({ error: err.message, loading: false, user: null }); // 실패 시 에러 상태 업데이트
}
},
}));src/components/ZustandUserProfile.tsx)import React from 'react';
import { useUserStore } from '../store/userStore';
const ZustandUserProfile: React.FC = () => {
const { user, loading, error, fetchUser } = useUserStore();
return (
<div style={{ marginTop: '30px', borderTop: '1px solid #eee', paddingTop: '20px' }}>
<h2>Zustand User Profile</h2>
<button onClick={() => fetchUser(1)} disabled={loading}>
{loading ? 'Fetching User 1...' : 'Fetch Alice (ID 1)'}
</button>
<button onClick={() => fetchUser(99)} disabled={loading} style={{ marginLeft: '10px' }}>
{loading ? 'Fetching User 99...' : 'Fetch Non-Existent User (ID 99)'}
</button>
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
{user ? (
<div>
<p>ID: {user.id}</p>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
</div>
) : (
!loading && !error && <p>Click a button to fetch user data.</p>
)}
</div>
);
};
export default ZustandUserProfile;fetchUser 함수가 호출되면 loading 상태가 true로 바뀌고, 비동기 작업이 완료되면 결과에 따라 user 또는 error 상태가 업데이트됩니다.
원 비동기 예제의 첫 화면은 Click a button to fetch user data.입니다. ID 1 결과가 완료되면 Alice를 보여주며, 그 뒤 ID 99를 요청하는 동안은 Alice를 지우지 않습니다. 실패가 도착한 뒤 user를 null로 바꾸고 Error: User not found!를 표시합니다. 버튼 비활성화는 이 두 버튼의 중복 클릭을 줄이지만, 다른 호출자가 fetchUser를 직접 실행하는 것을 금지하지는 않습니다.
예시
원 userStore는 보존하고, 최신 요청만 상태를 반영하는 별도 파일을 비교합니다. 아래 Mock은 원 예제처럼 ID 1이면 Alice, 다른 ID면 오류를 내는 Promise입니다. 실제 HTTP 요청을 취소하지는 않습니다. version은 이 store 인스턴스의 액션 호출마다 증가하며, 성공과 실패 모두 완료 시점에 같은 번호인지 확인합니다.
import { create } from 'zustand';
interface User {
id: number;
name: string;
email: string;
}
interface LatestUserState {
user: User | null;
loading: boolean;
error: string | null;
fetchUser: (id: number) => Promise<void>;
}
export const useLatestUserStore = create<LatestUserState>((set) => {
let version = 0;
return {
user: null,
loading: false,
error: null,
fetchUser: async (id: number) => {
const mine = ++version;
set({ user: null, loading: true, error: null });
try {
const user = await new Promise<User>((resolve, reject) => {
setTimeout(() => {
if (id === 1) resolve({ id: 1, name: 'Alice', email: '[email protected]' });
else reject(new Error('User not found!'));
}, 1500);
});
if (mine !== version) return;
set({ user, loading: false, error: null });
} catch (error: unknown) {
if (mine !== version) return;
set({ user: null, loading: false, error: error instanceof Error ? error.message : 'Unknown error' });
}
},
};
});비교 컴포넌트 복사본에서 import와 호출 이름만 useLatestUserStore로 맞춥니다. 이 복사본은 새 요청 시작 때 이전 user를 지우는 정책을 명시했습니다. 원 구현의 이전 user 유지 정책과 함께 비교해야 하며, 요청 번호 검사가 캐시·응답 검증·중단 기능까지 제공한다고 읽지 않습니다. 타이머는 취소하지 않으므로 예전 작업도 완료되지만 결과를 상태에 쓰지 않습니다.
Zustand와 타입스크립트의 시너지
Zustand와 타입스크립트의 조합은 다음과 같은 강력한 이점을 제공합니다.
강력한 타입 안전성: 스토어의 상태와 액션 함수에 대한 타입을 명확하게 정의함으로써, 잘못된 타입의 데이터를 상태에 저장하거나, 액션을 잘못 호출하는 등의 실수를 컴파일 시점에 방지할 수 있습니다.
자동 완성 및 리팩터링: IDE는 스토어의 타입 정보를 기반으로 상태 속성 및 액션 함수에 대한 강력한 자동 완성 기능을 제공합니다.
상태 구조가 변경될 때도 안전한 리팩터링이 가능합니다.
코드 가독성 및 명확성: 스토어가 어떤 상태를 포함하고, 어떤 방식으로 상태를 변경하는지 타입 정의를 통해 한눈에 파악할 수 있습니다.
이는 팀원 간의 협업 효율을 높입니다.
불변 갱신: set은 기본적으로 한 단계 얕게 병합합니다. 중첩 객체는 작성자가 복사해야 하며, 상태를 직접 수정하지 못하게 강제하는 API는 아닙니다.
미들웨어 (Middleware) 사용
Zustand는 미들웨어를 통해 스토어의 기능을 확장할 수 있습니다.
대표적으로 devtools (Redux DevTools와 통합) 및 persist (상태를 localStorage 등에 저장) 미들웨어가 있습니다.
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface SettingsState {
darkMode: boolean;
fontSize: number;
toggleDarkMode: () => void;
setFontSize: (size: number) => void;
}
// devtools와 persist 미들웨어 적용
export const useSettingsStore = create<SettingsState>()(
devtools( // Redux DevTools에서 스토어 상태 변화를 확인할 수 있게 해줍니다.
persist( // 상태를 localStorage에 저장하고 복원합니다.
(set) => ({
darkMode: false,
fontSize: 16,
toggleDarkMode: () => set((state) => ({ darkMode: !state.darkMode })),
setFontSize: (size: number) => set({ fontSize: size }),
}),
{
name: 'user-settings-storage', // localStorage에 저장될 키 이름
// partialize: (state) => ({ /* 저장할 상태의 일부만 선택 가능 */ }),
// storage: createJSONStorage(() => sessionStorage), // 사용 시 createJSONStorage import 추가
}
)
)
);현행 persist에서 저장소를 바꾸려면 storage: createJSONStorage(() => sessionStorage) 같은 설정을 사용합니다. 주석의 설정을 사용하려면 zustand/middleware에서 createJSONStorage를 가져와야 합니다.
미들웨어는 create 함수의 인자를 감싸는 형태로 사용됩니다.
이렇게 하면 디버깅과 사용자 설정 유지 같은 고급 기능을 쉽게 추가할 수 있습니다.
미들웨어를 적용하면 스토어 생성 시점의 기능 확장이 컴포넌트 구독, 개발 도구, 저장소 복원 흐름까지 이어집니다.
실제 프로젝트에서는 스토어가 커지기 전에 무엇을 전역으로 올릴지, 어떤 액션만 상태를 바꿀지, 어떤 selector로 구독할지를 분리해 두는 편이 안전합니다.
공유 범위와 늦은 결과를 독립적으로 판단하기
첫 카운터에서 Increment를 한 번, Increment by 5를 한 번, Decrement를 두 번 누릅니다. 두 컴포넌트가 보여 줄 값은 무엇인가요? 다른 useUserStore의 user가 갱신됐다는 이유만으로 count가 바뀌어야 할까요?
다음은 버튼 클릭과 분리한 요청 경합 가정입니다. 다른 호출자가 A=ID 99를 시작한 뒤 B=ID 1을 시작하고, 제어한 Mock에서 B 성공을 먼저, A 실패를 나중에 완료시킵니다. 원 fetchUser와 최신 요청 복사본의 마지막 user·error·loading을 각각 적으세요. 또 B가 아직 대기 중일 때 A 실패를 먼저 완료시키면, 어느 구현이 B의 loading을 유지하나요? 원 버튼 UI에서 이 경합이 반드시 발생한다는 주장은 아닙니다.
설정은 기본값 false·16에서 toggleDarkMode 한 번과 setFontSize(20)을 실행합니다. 같은 브라우저의 유효한 저장값이 정상적으로 복원된다는 조건으로 새로고침하면 설정과 카운터 count는 무엇이 되나요? 저장소 접근 실패·손상된 값·다른 사용자 환경에서도 같은 결과를 보장한다고 설명할 수 있을까요?
카운터·요청·영속화의 서로 다른 경계
카운터는 0→1→6→5→4이며 두 컴포넌트 모두 4를 봅니다. userStore는 별도 store이므로 그 변경이 count 값을 바꾸지 않습니다. 부모 등의 다른 렌더 원인은 selector의 값 갱신 여부와 구별합니다.
B 성공 뒤 A 실패인 원 구현은 마지막 실패가 user=null, error=User not found!, loading=false를 덮어씁니다. 복사본은 B의 최신 version만 반영하므로 Alice, error=null, loading=false를 유지합니다. A가 먼저 실패하고 B가 대기 중이면 원 구현은 loading=false를 쓰지만 복사본은 A 결과를 무시하여 loading=true를 유지합니다. 종료 처리도 최신 요청인지 검사해야 하는 이유입니다.
설정은 darkMode=true·fontSize=20으로 복원되고, persist를 쓰지 않은 counterStore는 새 모듈 초기값 0에서 시작합니다. 저장 성공과 유효한 복원이라는 조건이 빠지면 그 결과를 단정할 수 없습니다. 원 settingsStore의 setFontSize는 number만 요구하므로 양수·화면에 적절한 범위라는 업무 조건은 별도로 검사해야 합니다. selector는 읽기 범위이고 partialize는 저장 범위이므로 작은 구독이 비밀값 저장을 자동 제외하지 않습니다.
Zustand 적용 요약
Zustand는 React 애플리케이션 상태 관리를 위한 간결한 솔루션입니다.
타입스크립트와 함께 사용하면 상태와 액션 타입을 명확히 정의할 수 있어 개발 생산성과 코드 안정성이 크게 향상됩니다.
전역 상태 공유, 비동기 데이터 로딩, 미들웨어 기반 기능 확장까지 지원하므로 복잡한 React 애플리케이션의 상태를 효율적으로 관리하는 데 유용합니다.