Zustand 슬라이스 패턴: 대규모 스토어를 도메인별로 분리하고 TypeScript로 타입 안전하게 연결하기
프로젝트 초반에는 단일 Zustand 스토어 하나면 충분합니다. user, cart, modal 상태가 한 파일에 모여있어도 크게 불편하지 않죠. 그런데 어느 순간 스토어 파일이 500줄, 800줄을 넘어가면서 슬슬 감이 옵니다. 팀원이 같은 파일을 동시에 건드리다 충돌이 나고, 로그아웃 처리가 어디서 어떻게 장바구니를 비우는지 추적하는 것도 일이 됩니다. 저도 처음엔 "Zustand는 가벼우니까 하나로 가도 되겠지"라고 생각했다가, 결국 대규모 리팩터링을 경험한 쪽입니다.
이 글을 읽고 나면 StateCreator 제네릭을 활용해 스토어를 도메인별로 분리하고, 크로스 슬라이스 액션을 컴파일 타임에 타입 안전하게 연결하는 구조를 직접 구현할 수 있습니다. Redux Toolkit의 복잡함 없이 Zustand의 가벼움을 유지하면서도 엔터프라이즈급 상태 관리 구조를 갖추는 것이 목표입니다.
대상 독자: Zustand의 기본 사용 경험이 있는 분을 기준으로 합니다. create()와 set()/get() 사용이 익숙하지 않다면 Zustand 공식 문서의 Getting Started 섹션을 먼저 보시는 것을 권장합니다. Zustand v5 기준, TypeScript strict mode 환경을 가정하며, 기존 단일 스토어에서 점진적으로 마이그레이션하는 방법도 함께 다룹니다.
핵심 개념
StateCreator 제네릭 구조 이해하기
슬라이스 패턴의 아이디어는 간단합니다. 하나의 거대한 스토어를 도메인별로 쪼개서 관리하되, 런타임에는 하나의 통합 스토어로 합쳐서 사용하는 방식입니다. Redux Toolkit의 createSlice와 개념적으로 비슷하지만, Zustand는 이를 훨씬 단순하게 지원합니다.
핵심은 StateCreator 타입입니다. 제네릭 인자 구조를 먼저 짚고 가면 이후 코드가 훨씬 읽기 편합니다.
StateCreator<
TStore, // 전체 스토어 타입 — 크로스 슬라이스 접근의 열쇠
TMutators, // 미들웨어 체인 정보 (보통 [] 또는 [['zustand/immer', never], never])
[],
TSlice // 이 슬라이스가 실제로 제공하는 타입
>슬라이스 레벨에서는 TMutators를 보통 []로 비워두고, 미들웨어는 create()에서 조합할 때 한 번에 적용합니다. Immer를 사용한다면 [['zustand/immer', never], never] 형태가 들어가는데, 이건 예시 3에서 자세히 살펴볼 수 있습니다.
import { StateCreator } from 'zustand';
type AuthSlice = {
user: User | null;
login: (user: User) => void;
logout: () => void;
};
// StateCreator<전체스토어타입, 뮤테이터, [], 이슬라이스타입>
const createAuthSlice: StateCreator<
AuthSlice & CartSlice, // 첫 번째 인자: 전체 스토어 타입
[],
[],
AuthSlice // 네 번째 인자: 이 슬라이스가 제공하는 타입
> = (set, get) => ({
user: null,
login: (user) => set({ user }),
logout: () => {
set({ user: null });
get().clearCart(); // CartSlice 액션을 타입 안전하게 호출
},
});핵심 원칙 — 첫 번째 인자에 전체 스토어 타입을 넣는 것이 전부입니다. 이렇게 해야
get()으로 다른 슬라이스의 상태와 액션에 접근할 때 TypeScript가 타입을 정확히 추론합니다.
크로스 슬라이스 액션이 필요한 이유
StateCreator의 구조를 이해했다면, 자연스럽게 이 질문이 따라옵니다. "로그아웃하면 장바구니도 비워야 하는데, 이 로직을 어디에 두어야 하지?"
솔직히 슬라이스 패턴에서 가장 헷갈리는 부분이 이 지점입니다. 크로스 슬라이스 액션은 get() 함수를 통해 구현합니다. get()은 전체 스토어 상태의 스냅샷을 반환하기 때문에, 첫 번째 제네릭 인자에 올바른 전체 스토어 타입이 들어가 있다면 다른 슬라이스의 액션도 타입 안전하게 호출할 수 있습니다.
// 한 방향 의존성 예시: AuthSlice → CartSlice, NotificationSlice
const createAuthSlice: StateCreator<BoundStore, [], [], AuthSlice> =
(set, get) => ({
user: null,
logout: () => {
set({ user: null });
get().clearCart(); // CartSlice 액션
get().addNotification('로그아웃됨'); // NotificationSlice 액션
},
});단방향 의존성 원칙 — 슬라이스 간 의존 방향은 가능하면 한쪽으로만 유지하는 것을 권장합니다.
A → B는 괜찮지만,A ↔ B양방향 참조가 생기면 순환 의존성 문제로 디버깅이 어려워질 수 있습니다.
슬라이스를 하나의 스토어로 조합하기
크로스 슬라이스 액션의 원리를 이해했다면, 마지막 조각은 슬라이스를 하나의 스토어로 합치는 것입니다. 조합 방식 자체는 단순하지만, 중요한 규칙이 하나 있습니다. devtools, persist, immer 같은 미들웨어는 개별 슬라이스가 아닌 조합된 스토어에 한 번만 적용해야 합니다.
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
type BoundStore = AuthSlice & CartSlice & UISlice & NotificationSlice;
const useBoundStore = create<BoundStore>()(
devtools(
persist(
(...a) => ({
...createAuthSlice(...a),
...createCartSlice(...a),
...createUISlice(...a),
...createNotificationSlice(...a),
}),
{ name: 'app-store' }
)
)
);Zustand v5에서는 TypeScript와 미들웨어를 함께 쓸 때 create<T>()() 이중 괄호 패턴이 표준으로 자리잡았습니다. 처음 보면 오타처럼 보이는데, 커링(currying)을 통해 타입 추론을 올바르게 작동시키는 의도된 패턴입니다.
실전 적용
예시 1: 전자상거래 앱의 도메인 분리 구조
실무에서 가장 많이 접하는 시나리오입니다. 사용자 인증, 장바구니, 알림이 서로 연결된 전형적인 구조로, 기초부터 실제 파일 분리까지 전체 흐름을 한 번에 볼 수 있습니다.
파일 구조부터 잡겠습니다.
src/stores/
├── slices/
│ ├── auth.slice.ts
│ ├── cart.slice.ts
│ └── notification.slice.ts
├── types.ts ← BoundStore 타입 정의
└── store.ts ← 슬라이스 조합 + 미들웨어types.ts — 전체 스토어 타입을 먼저 정의
// types.ts
export type User = {
id: string;
name: string;
email: string;
};
export type CartItem = {
id: string;
name: string;
price: number;
quantity: number;
};
export type AuthSlice = {
user: User | null;
login: (user: User) => void;
logout: () => void;
};
export type CartSlice = {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
checkout: () => Promise<void>;
};
export type NotificationSlice = {
notifications: string[];
addNotification: (message: string) => void;
clearNotifications: () => void;
};
// 이 타입을 각 슬라이스의 첫 번째 제네릭 인자로 사용
export type BoundStore = AuthSlice & CartSlice & NotificationSlice;각 슬라이스 구현
// slices/auth.slice.ts
import { StateCreator } from 'zustand';
import { AuthSlice, BoundStore, User } from '../types';
export const createAuthSlice: StateCreator<BoundStore, [], [], AuthSlice> =
(set, get) => ({
user: null,
login: (user: User) => {
set({ user });
get().addNotification(`${user.name}님, 환영합니다!`);
},
logout: () => {
const { user } = get();
set({ user: null });
get().clearCart();
get().clearNotifications();
get().addNotification(`${user?.name ?? ''}님이 로그아웃했습니다`);
},
});// slices/cart.slice.ts
import { StateCreator } from 'zustand';
import { BoundStore, CartItem, CartSlice } from '../types';
export const createCartSlice: StateCreator<BoundStore, [], [], CartSlice> =
(set, get) => ({
items: [],
addItem: (item: CartItem) => {
set((state) => ({
items: [...state.items, item],
}));
get().addNotification(`${item.name}이(가) 장바구니에 추가됐습니다`);
},
removeItem: (id: string) => {
set((state) => ({
items: state.items.filter((item) => item.id !== id),
}));
},
clearCart: () => set({ items: [] }),
// 실제 API 호출이 있으므로 async 시그니처 사용
checkout: async () => {
const { user, items } = get();
if (!user) {
get().addNotification('로그인이 필요합니다');
return;
}
if (items.length === 0) {
get().addNotification('장바구니가 비어있습니다');
return;
}
// await paymentApi.checkout({ userId: user.id, items });
get().clearCart();
get().addNotification('주문이 완료됐습니다!');
},
});// slices/notification.slice.ts
import { StateCreator } from 'zustand';
import { BoundStore, NotificationSlice } from '../types';
export const createNotificationSlice: StateCreator<
BoundStore,
[],
[],
NotificationSlice
> = (set) => ({
notifications: [],
addNotification: (message: string) => {
set((state) => ({
notifications: [...state.notifications, message],
}));
},
clearNotifications: () => set({ notifications: [] }),
});스토어 조합
// store.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { BoundStore } from './types';
import { createAuthSlice } from './slices/auth.slice';
import { createCartSlice } from './slices/cart.slice';
import { createNotificationSlice } from './slices/notification.slice';
export const useBoundStore = create<BoundStore>()(
devtools(
persist(
(...a) => ({
...createAuthSlice(...a),
...createCartSlice(...a),
...createNotificationSlice(...a),
}),
{
name: 'app-store',
// 민감한 사용자 정보는 persist에서 제외
partialize: (state) => ({
items: state.items, // 장바구니만 유지
}),
}
),
{ name: 'AppStore' }
)
);
persist미들웨어 사용 시 주의 — 슬라이스별로 rehydration 순서에 의존하는 초기화 로직이 있다면 예상치 못한 문제가 생길 수 있습니다. 예를 들어AuthSlice의user가 복원되기 전에CartSlice의 초기화 로직이get().user를 참조하는 경우가 그렇습니다. 복잡한 초기화 로직은persist의onRehydrateStorage콜백에서 처리하는 것을 권장합니다.
예시 2: useShallow로 불필요한 리렌더링 차단하기
v5에서 안정화된 useShallow는 슬라이스 패턴과 함께 쓸 때 특히 빛을 발합니다. CartSlice의 items와 AuthSlice의 user처럼 서로 다른 슬라이스에서 상태를 동시에 선택할 때, 참조 동일성 문제로 발생하는 불필요한 리렌더링을 막아줍니다.
import { useShallow } from 'zustand/react/shallow';
import { useBoundStore } from '../stores/store';
// ❌ 셀렉터가 매번 새 객체를 반환하므로 항상 리렌더링
// (items는 CartSlice, user는 AuthSlice에서 옴)
function CartBadge() {
const { items, user } = useBoundStore((state) => ({
items: state.items, // CartSlice
user: state.user, // AuthSlice
}));
// ...
}
// ✅ useShallow로 감싸면 items 또는 user가 실제로 변경될 때만 리렌더링
function CartBadge() {
const { items, user } = useBoundStore(
useShallow((state) => ({
items: state.items, // CartSlice
user: state.user, // AuthSlice
}))
);
return (
<div>
{user && <span>{user.name}</span>}
<span>장바구니 {items.length}개</span>
</div>
);
}❌ 코드의 문제는 스토어 업데이트 여부와 무관합니다. 셀렉터가 호출될 때마다 { items, user } 형태의 새 객체 리터럴을 반환하기 때문에, Zustand가 참조 동일성(===)으로 비교하면 항상 다른 값으로 판단합니다. 부모 컴포넌트가 리렌더링되는 것만으로도 동일한 문제가 발생할 수 있습니다.
예시 3: Immer 미들웨어와 슬라이스 조합 시 타입 헬퍼
이 예시는 앞선 두 예시를 익힌 분들을 위한 내용입니다. immer 미들웨어를 슬라이스와 함께 쓰면 타입 추론이 깨지는 경우가 있습니다. 핵심 개념에서 언급했던 TMutators 인자에 [['zustand/immer', never], never] 형태를 넣어야 하는 시점이 바로 여기입니다.
매번 이 긴 타입을 반복하지 않으려면 커뮤니티에서 널리 쓰이는 타입 헬퍼 패턴을 사용하면 됩니다.
import { StateCreator } from 'zustand';
// Immer + 슬라이스 조합을 위한 타입 헬퍼
type ImmerStateCreator<TStore, TSlice> = StateCreator<
TStore,
[['zustand/immer', never], never],
[],
TSlice
>;
// 사용 예시 — 긴 타입 선언 없이 재사용 가능
export const createCartSlice: ImmerStateCreator<BoundStore, CartSlice> =
(set, get) => ({
items: [],
addItem: (item) => {
set((state) => {
// Immer draft로 직접 변경 가능
state.items.push(item);
});
get().addNotification(`${item.name}이(가) 장바구니에 추가됐습니다`);
},
// ...
});장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 관심사 분리 | 도메인별 상태와 액션을 독립 파일로 관리하여 파일 크기와 복잡도를 제어할 수 있습니다 |
| 타입 안전성 | StateCreator 제네릭으로 크로스 슬라이스 접근 시 컴파일 타임에 타입 오류를 잡을 수 있습니다 |
| 미들웨어 일원화 | devtools, persist 등을 조합 스토어에 한 번만 적용해 중복 설정을 피할 수 있습니다 |
| 협업 용이성 | 팀원별로 슬라이스 파일을 분리해 작업하면 Git 충돌을 최소화할 수 있습니다 |
| 점진적 도입 | 기존 단일 스토어에서 슬라이스로 조금씩 옮겨가는 방식으로 마이그레이션이 가능합니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 타입 복잡성 | 첫 번째 제네릭 인자에 전체 스토어 타입을 넘기는 패턴이 처음엔 직관적이지 않습니다 | types.ts에 BoundStore 타입을 별도로 선언하고 일관되게 참조하면 됩니다 |
| 순환 참조 위험 | 슬라이스 간 상호 참조가 많아지면 의존성 사이클이 생길 수 있습니다 | 단방향 의존성을 유지하고, 공통 로직은 유틸 함수로 추출하는 방법이 있습니다 |
| Immer 타입 이슈 | immer + 슬라이스 조합 시 타입 추론이 깨질 수 있습니다 |
ImmerStateCreator 타입 헬퍼를 별도로 정의해서 재사용하는 방법으로 해결할 수 있습니다 |
| 테스트 복잡성 | 크로스 슬라이스 액션 테스트 시 전체 스토어를 구성해야 합니다 | 슬라이스 팩토리를 직접 호출하는 단위 테스트보다 조합된 스토어로 통합 테스트를 작성하는 방향이 더 안정적입니다 |
| 리렌더링 관리 | 여러 상태를 동시에 선택할 때 useShallow 없이 사용하면 불필요한 리렌더링이 발생합니다 |
여러 상태를 한 번에 선택하는 곳에는 useShallow를 기본으로 적용하는 것을 권장합니다 |
테스트 복잡성과 관련해서, 저도 처음엔 슬라이스를 따로 테스트하려고 시도했는데 결국 조합된 스토어로 통합 테스트를 작성하는 것이 훨씬 현실적이었습니다. 미들웨어 없이 순수하게 조합만 하면 vitest나 jest 어디서든 간단하게 테스트할 수 있습니다.
// store.test.ts
import { create } from 'zustand';
import { BoundStore } from './types';
import { createAuthSlice } from './slices/auth.slice';
import { createCartSlice } from './slices/cart.slice';
import { createNotificationSlice } from './slices/notification.slice';
const createTestStore = () =>
create<BoundStore>()((...a) => ({
...createAuthSlice(...a),
...createCartSlice(...a),
...createNotificationSlice(...a),
}));
test('로그아웃 시 장바구니가 비워진다', () => {
const useStore = createTestStore();
useStore.getState().login({ id: '1', name: '김개발', email: 'test@test.com' });
useStore.getState().addItem({ id: 'a', name: '상품A', price: 1000, quantity: 1 });
useStore.getState().logout();
expect(useStore.getState().items).toHaveLength(0);
expect(useStore.getState().user).toBeNull();
});미들웨어를 제외하고 슬라이스 팩토리만 조합해서 테스트 스토어를 만드는 방식입니다. devtools나 persist가 없으니 외부 의존성 없이 순수하게 상태 로직만 검증할 수 있습니다.
실무에서 가장 흔한 실수
-
슬라이스마다 미들웨어를 개별 적용하는 실수 —
createAuthSlice를 정의할 때persist나devtools를 직접 감싸면 각 슬라이스가 독립적인 persist 저장소를 갖게 됩니다. 미들웨어는 반드시create()에서 조합할 때 한 번만 적용해야 합니다. -
StateCreator첫 번째 제네릭 인자에 자기 슬라이스 타입만 넣는 실수 — 이렇게 하면get()의 반환 타입이 자기 슬라이스 타입으로만 제한되어 다른 슬라이스 액션을 호출하면 TypeScript 오류가 발생합니다. 반드시BoundStore(전체 스토어 타입)를 넣어야 합니다. -
양방향 크로스 슬라이스 의존성을 만드는 실수 —
AuthSlice가CartSlice를 참조하고,CartSlice도AuthSlice를 참조하는 구조는 순환 의존성 경고의 시작점입니다. 어느 한쪽이 일방적으로 참조하도록 의존 방향을 정리하거나, 공유 로직을NotificationSlice처럼 별도 슬라이스로 분리하는 방법을 고려해볼 수 있습니다.
마치며
StateCreator의 첫 번째 제네릭 인자에 전체 스토어 타입을 선언하는 것이 슬라이스 패턴 타입 안전성의 핵심이며, 이 하나의 원칙을 이해하면 나머지는 자연스럽게 따라옵니다.
지금 바로 시작해볼 수 있는 3단계:
-
types.ts파일을 먼저 만들어보세요 — 기존 스토어에서 타입들을 추출해AuthSlice,CartSlice등으로 분리하고, 이를 합친BoundStore타입을 정의하는 것부터 시작하면 됩니다. 코드 이동은 그 다음 단계입니다. -
슬라이스 파일을 하나씩 분리해보세요 — 기존
create()안의 내용을StateCreator<BoundStore, [], [], 해당슬라이스타입>형태의 팩토리 함수로 옮기고,store.ts에서 스프레드 연산자로 다시 조합하면 동작은 그대로 유지됩니다. -
크로스 슬라이스 액션이 있는 곳에
get()을 적용해보세요 — 예를 들어 로그아웃 처리처럼 여러 도메인에 걸친 액션이 있다면,get().clearCart()형태로 리팩터링하면서 TypeScript가 타입 오류를 잡아주는지 확인해볼 수 있습니다.
참고 자료
- Slices Pattern | Zustand 공식 문서
- Slices Pattern | DeepWiki (pmndrs/zustand)
- zustandjs/zustand-slices | GitHub
- zustandjs/zustand-slices | DeepWiki
- ADVANCED ZUSTAND "4", Slices Pattern & Scalable Store Architecture | Level Up Coding
- A Slice-Based Zustand Store for Next.js 14 and TypeScript | Atlys Engineering
- Zustand Architecture Patterns at Scale | Brainhub
- When to use multiple stores vs slices | GitHub Discussions
- combine | Zustand 공식 미들웨어 문서