Zustand 상태 관리: 셀렉터로 리렌더 줄이기, 미들웨어, Slice 패턴, vanilla 스토어, Next.js SSR
이 글의 핵심
스토어 전체를 구독하거나 셀렉터가 매번 새 객체를 반환하면 Zustand를 써도 불필요한 리렌더가 그대로 남습니다. 외부 스토어와 React 바인딩이 분리된 구조를 먼저 이해하고 잘못된 셀렉터와 올바른 셀렉터를 비교한 뒤, Next.js App Router에서 요청 간 상태가 섞이지 않게 스토어를 두는 패턴과 흔한 트러블슈팅을 정리했습니다.
이 글의 핵심
Zustand로 전역 상태를 관리할 때 알아야 할 구조와 함정을 정리한 글입니다. 기본 스토어부터 미들웨어, Slice 패턴, 셀렉터 최적화, Next.js App Router에서의 사용까지 예제로 다룹니다.
왜 Zustand를 고르는가
전역 상태 도구를 고를 때 비교 대상은 보통 세 가지입니다. React Context는 별도 라이브러리가 필요 없지만, Provider의 value가 바뀌면 그 Context를 쓰는 모든 컴포넌트가 다시 렌더링되고 “값의 일부만 구독”하는 기능이 없어서, 자주 바뀌는 상태를 넣으면 성능 문제가 생깁니다. Redux Toolkit은 액션·리듀서 구조와 미들웨어, DevTools 시간 여행 같은 도구가 성숙해서 큰 팀에서 상태 변경 규칙을 강제하기 좋지만, 작은 상태 하나를 추가할 때도 slice와 액션을 정의하는 절차가 필요합니다. Zustand는 이 사이에 있습니다. 스토어는 React 밖에 있는 평범한 객체이고, 컴포넌트는 셀렉터로 필요한 조각만 구독하므로 Context의 리렌더 문제가 없으며, 액션은 스토어 안의 함수라 정의 절차가 짧습니다. 라이브러리 크기도 매우 작습니다.
대신 규칙을 강제하는 장치가 거의 없다는 것이 Zustand의 트레이드오프입니다. 어디서든 set을 호출해 상태를 바꿀 수 있으므로, 팀 규모가 커지면 “누가 이 값을 바꿨는지” 추적하기 어려워질 수 있습니다. 아래에서 다루는 셀렉터 규칙, 미들웨어 순서, Slice 구성은 이 자유도를 팀 규칙으로 보완하는 방법이기도 합니다.
Zustand란?
핵심 특징
Zustand는 간단한 React 상태 관리 라이브러리입니다. 주요 장점:
- 간단한 API: 보일러플레이트 없음
- 작은 크기: 핵심 패키지가 매우 작음
- TypeScript: 제네릭으로 스토어 타입 지정
- Middleware: Persist, Devtools
- React 외부: Vanilla JS 사용 가능
기본 사용
설치
npm install zustand
Store 생성
// store/useStore.ts
import { create } from 'zustand';
interface Store {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useStore = create<Store>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
set은 기본적으로 얕은 병합(shallow merge)을 합니다. set({ count: 0 })은 count만 바꾸고 increment 같은 다른 키는 그대로 두므로, Redux 리듀서처럼 ...state를 펼칠 필요가 없습니다. 다만 병합은 최상위 한 단계에서만 일어나서, set({ user: { name } })처럼 중첩 객체를 넘기면 user 객체 전체가 교체되어 다른 필드가 사라집니다. 중첩 필드를 바꿀 때는 set((s) => ({ user: { ...s.user, name } }))처럼 직접 펼치거나 뒤에서 볼 immer 미들웨어를 씁니다. 이전 값을 기준으로 계산하는 업데이트는 set((state) => ...) 형태로 써야 여러 업데이트가 연달아 일어날 때도 최신 값을 기준으로 계산됩니다.
컴포넌트에서 사용
// components/Counter.tsx
import { useStore } from '../store/useStore';
export default function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
const decrement = useStore((state) => state.decrement);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
셀렉터를 세 번 나눠 호출한 것은 의도된 선택입니다. 각 useStore(selector)는 셀렉터가 반환한 값을 이전 값과 Object.is로 비교해 달라졌을 때만 컴포넌트를 다시 렌더링합니다. count는 숫자라서 값이 바뀔 때만 달라지고, increment와 decrement는 스토어가 만들어질 때 한 번 생성된 함수라 참조가 변하지 않으므로 리렌더를 일으키지 않습니다. Provider로 트리를 감쌀 필요가 없다는 점도 Context와 다른 부분입니다. 스토어가 모듈 수준의 싱글턴이라 어디서든 import하면 같은 스토어를 보는데, 이 성질이 편리한 동시에 뒤에서 다룰 테스트와 SSR 문제의 원인이 됩니다.
고급 패턴
Async Actions
interface UserStore {
users: User[];
loading: boolean;
error: string | null;
fetchUsers: () => Promise<void>;
}
export const useUserStore = create<UserStore>((set) => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/users');
const users = await response.json();
set({ users, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
}));
비동기 액션은 특별한 미들웨어 없이 async 함수로 쓰면 되지만, 이 예제에는 실무에서 고쳐야 할 부분이 있습니다. fetch는 HTTP 404나 500에서도 예외를 던지지 않으므로 if (!response.ok) throw new Error(...)로 직접 검사해야 오류 상태가 설정됩니다. TypeScript의 strict 모드에서는 catch의 error가 unknown이라 error.message에서 컴파일 오류가 나므로 error instanceof Error ? error.message : String(error)처럼 좁혀야 합니다. 또 사용자가 버튼을 빠르게 두 번 누르면 요청 두 개가 경쟁해서 늦게 도착한 옛 응답이 새 응답을 덮어쓰는 문제가 생길 수 있습니다. 요청 ID를 저장해 두고 응답이 최신 요청의 것인지 확인하거나 AbortController로 이전 요청을 취소하는 방식이 필요합니다. 서버 데이터를 가져와 캐시하는 일이 많다면 이 로직을 직접 쓰기보다 TanStack Query 같은 도구에 맡기고, Zustand에는 클라이언트 상태만 두는 조합이 흔합니다.
Computed Values
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: () => number;
}
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) =>
set((state) => ({ items: [...state.items, item] })),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((item) => item.id !== id),
})),
total: () => {
const { items } = get();
return items.reduce((sum, item) => sum + item.price, 0);
},
}));
get()은 액션 안에서 현재 상태를 읽을 때 씁니다. 여기서 total을 함수로 둔 이유는 Zustand에 Redux의 createSelector 같은 내장 파생 상태 기능이 없기 때문입니다. 컴포넌트에서 useCartStore((s) => s.total())처럼 셀렉터 안에서 호출하면, 합계 숫자가 같으면 리렌더가 일어나지 않으므로 올바르게 동작합니다. 주의할 점은 const total = useCartStore((s) => s.total)처럼 함수 자체를 구독하고 렌더 중에 호출하는 것입니다. 함수 참조는 바뀌지 않으므로 items가 바뀌어도 컴포넌트가 다시 렌더링되지 않아 화면의 합계가 갱신되지 않습니다. 계산이 무겁다면 셀렉터 밖에서 useMemo로 감싸거나, 항목을 추가·삭제할 때 합계를 함께 갱신해 상태로 저장하는 방법도 있습니다.
Middleware
Persist
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthStore {
user: User | null;
token: string | null;
login: (user: User, token: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
}),
{
name: 'auth-storage',
}
)
);
persist는 상태를 localStorage에 JSON으로 남기므로, 스토어 구조를 바꿔 배포하면 이전 구조로 저장된 데이터가 새 코드에 그대로 들어옵니다. 새로 설치한 개발자 브라우저에서는 재현되지 않고 기존 사용자에게서만 에러가 나는 전형적인 경우입니다. 구조를 바꿀 때는 옵션에 version을 올리고 migrate: (persisted, version) => ...로 옛 형태를 변환하며, partialize: (s) => ({ user: s.user })로 저장할 필드를 명시해 함수나 일시적인 UI 상태가 섞여 들어가지 않게 합니다. 위 예제처럼 토큰을 localStorage에 두는 것은 XSS에 노출되므로, 실제 서비스라면 인증 토큰은 HttpOnly 쿠키로 옮기고 스토어에는 로그인 여부 같은 표시용 값만 두는 편이 안전합니다.
Devtools
import { devtools } from 'zustand/middleware';
export const useStore = create<Store>()(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{ name: 'CounterStore' }
)
);
create<Store>()(...)처럼 괄호를 두 번 쓰는 형태는 오타가 아닙니다. TypeScript는 제네릭 인자를 일부만 명시하고 나머지를 추론하게 하는 기능이 없어서, Zustand는 첫 호출에서 스토어 타입을 받고 두 번째 호출에서 미들웨어 타입을 추론하는 커링 방식을 씁니다. 미들웨어를 쓸 때 create<Store>(devtools(...))처럼 한 번만 호출하면 미들웨어가 추가한 타입이 사라져 긴 타입 오류가 납니다. 여러 미들웨어를 겹칠 때는 devtools(persist(immer(...)))처럼 devtools를 가장 바깥에 두는 것이 공식 문서의 권장입니다. 안쪽 미들웨어가 바꾼 set까지 DevTools가 기록할 수 있기 때문입니다. devtools는 운영 번들에 남아도 확장 프로그램이 없으면 동작하지 않지만, enabled: process.env.NODE_ENV !== 'production' 옵션으로 명시적으로 꺼 두는 편이 깔끔합니다.
Immer
import { immer } from 'zustand/middleware/immer';
export const useStore = create<Store>()(
immer((set) => ({
nested: { deep: { value: 0 } },
updateDeep: (value: number) =>
set((state) => {
state.nested.deep.value = value;
}),
}))
);
immer 미들웨어를 쓰면 set 안에서 상태를 직접 수정하는 것처럼 코드를 쓰고, immer가 이를 새 객체로 바꿔 줍니다. 깊게 중첩된 상태를 다룰 때 스프레드가 여러 단계로 쌓이는 것을 피할 수 있지만, immer 패키지를 별도로 설치해야 하고 프록시를 만드는 비용이 조금 듭니다. 흔한 실수는 이 방식에 익숙해진 뒤 immer가 없는 스토어에서도 set((s) => { s.count++ })처럼 쓰는 것입니다. 이 경우 원래 상태 객체가 직접 변경되고 set은 undefined를 받아 아무것도 바뀌지 않은 것으로 처리되므로, 값은 바뀌었는데 화면은 갱신되지 않는 혼란스러운 버그가 됩니다. 한 프로젝트 안에서 immer를 쓰는 스토어와 쓰지 않는 스토어가 섞여 있다면 이 차이를 팀 규칙으로 분명히 해 두는 것이 좋습니다.
Slice Pattern
// store/slices/userSlice.ts
export const createUserSlice = (set, get) => ({
users: [],
fetchUsers: async () => {
const users = await api.getUsers();
set({ users });
},
});
// store/slices/cartSlice.ts
export const createCartSlice = (set, get) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
});
// store/index.ts
import { create } from 'zustand';
import { createUserSlice } from './slices/userSlice';
import { createCartSlice } from './slices/cartSlice';
export const useStore = create((set, get) => ({
...createUserSlice(set, get),
...createCartSlice(set, get),
}));
Slice에 타입 붙이기
위 예제는 타입이 없어 set/get이 any가 됩니다. TypeScript에서는 StateCreator로 각 슬라이스가 전체 스토어의 어떤 부분을 만드는지 선언합니다.
import { create, type StateCreator } from 'zustand';
type SessionSlice = { userId: string | null; setUserId: (id: string | null) => void };
type UiSlice = { sidebarOpen: boolean; toggleSidebar: () => void };
type AppStore = SessionSlice & UiSlice;
// StateCreator<전체 스토어, 미들웨어(set), 미들웨어(get), 이 슬라이스>
const createSessionSlice: StateCreator<AppStore, [], [], SessionSlice> = (set) => ({
userId: null,
setUserId: (userId) => set({ userId }),
});
const createUiSlice: StateCreator<AppStore, [], [], UiSlice> = (set) => ({
sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
});
export const useAppStore = create<AppStore>()((...a) => ({
...createSessionSlice(...a),
...createUiSlice(...a),
}));
첫 번째 타입 인자를 슬라이스 자신이 아니라 전체 스토어(AppStore)로 두면, 한 슬라이스의 액션에서 get().sidebarOpen처럼 다른 슬라이스 값을 읽어도 타입이 맞습니다. 두 번째·세 번째 인자는 devtools·persist 같은 미들웨어를 쓸 때 [['zustand/devtools', never]]처럼 채워야 하는데, 여기가 틀리면 에러 메시지가 매우 길어져서 “미들웨어를 추가했더니 타입이 터졌다”는 경험을 하게 됩니다. 스프레드로 합치는 구조라 두 슬라이스에 같은 키가 있으면 뒤의 것이 조용히 덮어쓰므로, 슬라이스별 키 접두사(session_, ui_)나 슬라이스를 중첩 객체로 두는 규칙을 정해 두는 것이 안전합니다.
Selector 최적화
잘못된 예
// 전체 store를 구독 (불필요한 리렌더링)
const store = useStore();
올바른 예
// 필요한 값만 구독
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
Shallow 비교
// v4.4+ / v5: useShallow로 셀렉터를 감싼다
import { useShallow } from 'zustand/react/shallow';
const { count, increment } = useStore(
useShallow((state) => ({ count: state.count, increment: state.increment }))
);
useShallow는 셀렉터가 반환한 객체의 최상위 키 값을 하나씩 비교해서, 모든 값이 같으면 이전 객체를 그대로 돌려줍니다. 그래서 count와 increment가 그대로라면 새 객체 리터럴을 만들더라도 컴포넌트는 리렌더되지 않습니다. 비교는 얕게만 하므로, 셀렉터가 state.items.filter(...)처럼 매번 새 배열을 만드는 연산을 포함하면 배열 내용이 같아도 참조가 달라서 효과가 없습니다. 이런 파생 배열은 셀렉터 밖에서 useMemo로 계산하거나, 스토어에 결과를 저장해 두는 편이 낫습니다.
예전 예제에서 흔한 useStore(selector, shallow)처럼 두 번째 인자로 비교 함수를 넘기는 방식은 v5에서 create가 만든 훅에서 빠졌습니다. v5로 올린 뒤 비교 함수가 무시되면, 객체를 새로 만들어 반환하는 셀렉터는 매 렌더마다 “상태가 바뀌었다”고 판단됩니다. v5는 React의 useSyncExternalStore를 그대로 쓰기 때문에 이것이 단순한 불필요한 리렌더가 아니라 Maximum update depth exceeded 같은 무한 렌더 루프로 나타날 수 있어, v4에서 멀쩡하던 화면이 업그레이드 후 갑자기 멈추는 대표적인 원인입니다. 객체·배열을 반환하는 셀렉터는 useShallow로 감싸거나, 필드마다 셀렉터를 하나씩 쓰는 것이 원칙입니다. 비교 함수를 꼭 넘겨야 하는 기존 코드는 zustand/traditional의 createWithEqualityFn으로 옮길 수 있습니다.
Vanilla JS
import { createStore } from 'zustand/vanilla';
const store = createStore<Store>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// 구독
const unsubscribe = store.subscribe((state) => {
console.log('Count:', state.count);
});
// 사용
store.getState().increment();
console.log(store.getState().count); // 1
// 구독 해제
unsubscribe();
vanilla 스토어는 React 없이 동작하는 Zustand의 핵심이며, create로 만든 훅도 내부적으로 이 스토어를 만들고 useSyncExternalStore로 React에 연결한 것입니다. 이 구조 덕분에 WebSocket 핸들러나 이벤트 리스너처럼 컴포넌트 밖의 코드에서 store.getState().increment()로 상태를 바꿀 수 있고, React 컴포넌트에서는 useStore(store, selector)로 같은 스토어를 구독할 수 있습니다. subscribe의 콜백은 모든 상태 변경마다 호출되므로, 특정 필드만 관심이 있다면 콜백 안에서 이전 값과 비교하거나 subscribeWithSelector 미들웨어를 써야 합니다. 구독 해제를 잊으면 컴포넌트나 모듈이 사라진 뒤에도 콜백이 계속 호출되어 메모리 누수가 됩니다.
심화: 구독 모델·Next.js·트러블슈팅
스토어와 리렌더
useStore(selector)는 Zustand가 셀렉터 결과의 동일성을 추적해 변경된 컴포넌트만 다시 그립니다. 셀렉터가 매번 새 객체를 만들면({ a, b } 형태) 참조가 달라져 불필요한 리렌더가 납니다. 이때 shallow 비교를 쓰거나, 필드를 쪼개 여러 useStore 호출로 나눕니다.
미들웨어 체인
devtools·persist·immer는 스토어를 래핑해 동작합니다. 순서에 따라 타입 추론과 저장 포맷이 달라질 수 있으므로, 팀에서 표준 순서를 문서화하는 편이 좋습니다. persist의 부분 저장·마이그레이션 버전은 장기 운영 시 필수입니다.
Next.js App Router
서버 컴포넌트와 클라이언트 컴포넌트 경계에서 전역 스토어를 공유하려면, 클라이언트 트리 안에서만 스토어 모듈을 import하도록 합니다. SSR 초기 상태는 props나 fetch 결과로 내려받으며, 클라이언트에서 하이드레이션 후 스토어를 시드하는 패턴이 안전합니다.
모듈 수준 싱글턴 스토어가 서버에서 위험한 이유는 Node.js 서버 프로세스가 여러 사용자의 요청을 같은 모듈 인스턴스로 처리하기 때문입니다. 서버 렌더링 중에 사용자 A의 정보로 전역 스토어를 채우면, 동시에 처리되는 사용자 B의 렌더링이 그 값을 읽을 수 있습니다. 그래서 Zustand 공식 Next.js 가이드는 create 대신 vanilla createStore로 스토어를 만드는 함수를 두고, 클라이언트 컴포넌트인 Provider가 useRef(또는 useState의 초기화 함수)로 요청·트리마다 한 번만 스토어를 만들어 Context로 내려 주는 패턴을 권장합니다. 하위 컴포넌트는 useContext로 스토어를 꺼내 useStore(store, selector)로 구독합니다. 서버에서 가져온 초기 데이터는 이 Provider의 props로 넘겨 스토어 생성 시 초기값으로 쓰면 됩니다.
persist를 SSR과 함께 쓰면 하이드레이션 오류를 자주 만납니다. 서버는 localStorage를 모르므로 초기값으로 HTML을 렌더링하고, 클라이언트는 저장된 값으로 첫 렌더를 하게 되어 “Text content does not match server-rendered HTML” 같은 불일치 경고가 나타납니다. 저는 이 경우 persist 옵션에 skipHydration: true를 주고 클라이언트의 useEffect에서 useStore.persist.rehydrate()를 호출하거나, 저장된 값에 의존하는 UI는 마운트 후에만 그리는 방식으로 해결합니다. 복원이 끝났는지 알아야 한다면 onRehydrateStorage 콜백이나 persist.hasHydrated()로 확인할 수 있습니다.
트러블슈팅
| 증상 | 점검 |
|---|---|
| 상태는 맞는데 UI만 안 바뀜 | 셀렉터가 참조 동일 객체를 반환 |
| 무한 루프 | set이 subscribe 콜백 안에서 다시 호출, 또는 v5에서 객체를 새로 반환하는 셀렉터(useShallow 누락) |
| 배포 후 일부 사용자만 에러 | persist에 저장된 옛 구조의 JSON — version을 올리고 migrate로 변환 |
| 테스트끼리 상태 공유 | 각 테스트마다 새 스토어 또는 setState 초기화 |
테스트 간 상태 공유는 모듈 싱글턴 구조의 직접적인 결과입니다. Jest나 Vitest는 같은 테스트 파일 안의 테스트들이 모듈을 공유하므로, 한 테스트에서 increment()를 호출하면 다음 테스트는 1부터 시작합니다. 스토어를 만들 때의 초기 상태를 const initialState = useStore.getState()로 저장해 두고 beforeEach에서 useStore.setState(initialState, true)로 되돌리는 방식이 가장 간단합니다. 두 번째 인자 true는 병합 대신 전체 교체를 뜻하므로, 테스트 중에 추가된 키까지 깨끗이 지워집니다. 공식 문서는 테스트 러너의 모듈 모킹으로 모든 스토어를 자동으로 초기화하는 방법도 안내합니다.
취업·면접과 연결하기
상태 관리·전역 스토어 설계는 프론트엔드 면접에서 자주 묻습니다. 개발자 기술 면접 준비: 알고리즘부터 시스템 설계까지와, 이력서에 Redux→Zustand 전환 같은 수치 스토리를 쓰는 법은 개발자 이력서·서류·면접 가이드를 참고하세요.
정리 및 체크리스트
핵심 요약
- Zustand: 간단한 상태 관리
- 작은 크기: 번들 부담이 적음
- TypeScript:
create<T>()()커링 형태로 미들웨어 타입까지 추론 - Middleware: Persist, Devtools, Immer
- Selector: 최적화 가능
- Vanilla JS: React 외부 사용
구현 체크리스트
- Zustand 설치
- Store 생성
- 컴포넌트 연결
- Async Actions 구현
- Middleware 추가
- Selector 최적화
- Slice Pattern 적용
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. Redux Toolkit에서 Zustand로 옮길 만한가요?
A. 상태 변경 규칙을 액션·리듀서로 강제하고 DevTools의 액션 기록을 적극적으로 쓰는 큰 팀이라면 Redux Toolkit을 유지할 이유가 충분합니다. 반대로 전역 상태가 몇 개의 UI 상태와 세션 정보 정도이고, 서버 데이터는 TanStack Query 같은 도구로 이미 분리했다면 Zustand로 옮기면 코드가 크게 줄어듭니다. 한 번에 바꾸기보다 새 기능부터 Zustand 스토어로 만들고 기존 slice를 하나씩 옮기는 방식이 안전합니다.
Q. Context와 Zustand를 함께 써도 되나요?
A. 오히려 권장되는 조합입니다. Context로는 “어떤 스토어 인스턴스를 쓸지”만 전달하고, 값의 구독은 Zustand 셀렉터로 하면 Context의 전체 리렌더 문제 없이 트리마다 독립된 스토어를 둘 수 있습니다. Next.js App Router에서 요청마다 스토어를 분리하는 패턴이 바로 이 구조입니다.
Q. 스토어를 하나로 둘까요, 여러 개로 나눌까요?
A. 서로 거의 참조하지 않는 도메인(인증, 장바구니, UI 설정)이라면 스토어를 나누는 편이 단순하고, 한 액션이 여러 도메인의 값을 함께 바꿔야 한다면 Slice 패턴으로 한 스토어에 합치는 편이 일관성을 지키기 쉽습니다. 여러 스토어 사이의 동기화를 subscribe로 엮기 시작했다면 하나로 합칠 때가 되었다는 신호입니다.