Zustand 상태관리
React에서 여러 컴포넌트가 같은 데이터를 공유해야 할 때, props를 여러 단계 내려보내는 "prop drilling"이 발생한다. 전역 상태관리 라이브러리는 이 문제를 해결해준다. Zustand는 그중에서도 가장 간결한 API를 제공하는 라이브러리로, 보일러플레이트가 거의 없고 React 바깥에서도 상태에 접근할 수 있다는 특징이 있다.
기본 사용법: create와 set
Zustand의 스토어는 create 함수로 만든다. 상태와 상태를 변경하는 함수를 한 객체에 함께 정의한다.
import { create } from 'zustand';
interface CounterState {
count: number;
increment: () => void;
reset: () => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
reset: () => set({ count: 0 }),
}));
set 함수는 상태를 업데이트하는 유일한 방법이다. 객체를 직접 전달하거나, 이전 상태를 인자로 받는 함수를 전달할 수 있다. set은 기존 상태와 얕은 병합(shallow merge)을 수행하므로, 변경하지 않는 필드는 그대로 유지된다.
컴포넌트에서는 훅처럼 사용한다.
function Counter() {
const count = useCounterStore((s) => s.count);
const increment = useCounterStore((s) => s.increment);
return <button onClick={increment}>{count}</button>;
}
셀렉터 함수 (s) => s.count로 필요한 값만 꺼내면, 해당 값이 바뀔 때만 컴포넌트가 리렌더링된다. 만약 셀렉터 없이 useCounterStore()로 전체 상태를 가져오면 스토어의 어떤 값이든 바뀔 때마다 리렌더링이 발생하므로, 항상 셀렉터를 사용하는 것이 좋다.
실제 예시: 인증 스토어
Chiki 프로젝트의 admin 앱에서 인증 상태를 관리하는 스토어를 보면, Zustand의 실용적인 활용을 이해할 수 있다.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthUser {
memberId: string;
name: string;
email: string;
role: string;
}
interface AuthState {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
setAuth: (user: AuthUser, token: string) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
setAuth: (user, token) =>
set({ user, token, isAuthenticated: true }),
clearAuth: () =>
set({ user: null, token: null, isAuthenticated: false }),
}),
{
name: 'auth-storage',
},
),
);
이 스토어는 로그인 성공 시 setAuth로 사용자 정보와 토큰을 저장하고, 로그아웃이나 인증 실패 시 clearAuth로 초기화한다. isAuthenticated 플래그로 로그인 여부를 빠르게 판단할 수 있다.
persist 미들웨어
기본적으로 Zustand 상태는 메모리에만 존재해서 새로고침하면 사라진다. persist 미들웨어를 사용하면 상태를 localStorage에 자동으로 저장하고 복원할 수 있다.
import { persist } from 'zustand/middleware';
const useStore = create<MyState>()(
persist(
(set) => ({
// 상태와 액션 정의
}),
{
name: 'storage-key', // localStorage의 키 이름
},
),
);
persist는 create 함수를 감싸는 형태로 사용한다. name에 지정한 키로 localStorage에 JSON 문자열이 저장된다. 앱이 다시 로드될 때 자동으로 localStorage에서 값을 읽어와 상태를 복원한다.
인증 스토어에 persist를 적용하면, 사용자가 브라우저를 새로고침해도 로그인 상태가 유지된다. 별도의 "로그인 상태 복원" 로직을 작성할 필요가 없다.
주의할 점은 create<MyState>()(persist(...))처럼 create 뒤에 빈 괄호 ()가 하나 더 붙는다는 것이다. TypeScript에서 미들웨어의 타입 추론을 위해 커링(currying) 형태를 사용하기 때문이다.
getState: 컴포넌트 바깥에서 상태 접근
Zustand의 강력한 기능 중 하나는 React 컴포넌트가 아닌 곳에서도 상태에 접근할 수 있다는 것이다. useStore.getState()를 호출하면 현재 상태의 스냅샷을 가져올 수 있다.
대표적인 사용 사례가 Axios 인터셉터에서 JWT 토큰을 가져오는 것이다.
// lib/api/index.ts — React 컴포넌트가 아닌 일반 모듈
httpClient.instance.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
Axios 인터셉터는 React 컴포넌트가 아니므로 useAuthStore((s) => s.token) 형태의 훅을 사용할 수 없다. 이때 getState()를 사용하면 스토어의 현재 값을 직접 읽을 수 있다. 매 API 요청마다 인터셉터가 실행될 때 그 시점의 최신 토큰 값을 가져온다.
// getState()로 상태 읽기
const currentToken = useAuthStore.getState().token;
// getState()로 액션 호출
useAuthStore.getState().clearAuth();
getState()는 구독(리렌더링)을 하지 않으므로, 값이 변경되어도 자동으로 반응하지는 않는다. 호출하는 시점의 상태를 한 번 읽을 뿐이다. 따라서 컴포넌트 내에서는 셀렉터를, 컴포넌트 바깥에서는 getState()를 사용하는 것이 일반적인 패턴이다.
Zustand vs 다른 상태관리 라이브러리
Zustand의 가장 큰 장점은 단순함이다. Redux는 action, reducer, dispatch 등 여러 개념을 이해해야 하고, Context API는 Provider를 트리 상단에 배치해야 한다. Zustand는 create 하나로 스토어를 만들고, 훅으로 바로 사용한다.
[Redux] Action → Dispatch → Reducer → Store → Selector → Component
[Context API] Provider → useContext → Component (리렌더링 최적화 어려움)
[Zustand] create → useStore(selector) → Component
또한 Zustand는 Provider가 필요 없다. 스토어를 파일에서 생성하면 어디서든 import해서 사용할 수 있다. 이 특성 덕분에 getState()로 컴포넌트 바깥에서도 자연스럽게 사용할 수 있다.
요약
Zustand는 create로 상태와 액션을 정의하고, 셀렉터로 필요한 값만 구독하는 간결한 상태관리 라이브러리다. persist 미들웨어로 새로고침 후에도 상태를 유지할 수 있고, getState()로 React 바깥에서도 상태에 접근할 수 있다. 인증 토큰 관리처럼 여러 곳에서 공유해야 하는 클라이언트 상태에 적합하다.