Zustand는 간단하다.
처음 사용할 때는 store 하나를 만들고, 그 안에 상태와 액션을 모두 넣는 방식이 꽤 자연스럽다.
예를 들어 아래 같은 코드는 시작하기에 충분히 편하다.
import { create } from "zustand";
interface AppState {
user: { id: string; name: string } | null;
theme: "light" | "dark";
notifications: string[];
cart: { id: number; name: string; quantity: number }[];
isLoading: boolean;
setUser: (user: { id: string; name: string } | null) => void;
setTheme: (theme: "light" | "dark") => void;
addNotification: (message: string) => void;
addCartItem: (item: { id: number; name: string }) => void;
}
export const useAppStore = create<AppState>((set) => ({
user: null,
theme: "light",
notifications: [],
cart: [],
isLoading: false,
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
addNotification: (message) =>
set((state) => ({
notifications: [...state.notifications, message],
})),
addCartItem: (item) =>
set((state) => ({
cart: [...state.cart, { ...item, quantity: 1 }],
})),
}));
초반에는 문제가 없다.
하지만 프로젝트가 커질수록 store 하나에 모든 상태를 몰아넣는 방식은 점점 부담이 된다.
이번 글에서는 Zustand store를 너무 크게 만들었을 때 어떤 문제가 생기는지, 그리고 언제 slice 패턴으로 나누는 것이 좋은지 정리해보려고 한다.
store가 커질수록 생기는 문제
처음에는 "한 군데에 다 있으니 편하다"는 장점이 있다.
하지만 규모가 커지면 그 장점이 빠르게 약해진다.
1. 상태의 책임이 섞이기 시작한다
사용자 정보, 장바구니, 알림, UI 상태, 필터 상태처럼 서로 성격이 다른 값들이 한 store 안에 섞이기 시작하면
"이 store는 무엇을 책임지는가?"라는 질문에 답하기 어려워진다.
상태 관리가 어렵다는 것은 결국 책임이 흐려진다는 뜻에 가깝다.
예를 들어 theme를 바꾸는 액션과 cart를 수정하는 액션이 같은 파일 안에 길게 나열되면,
파일을 읽는 사람은 관련 없는 문맥을 계속 같이 봐야 한다.
2. store 구조를 이해하는 비용이 커진다
작은 store는 한 번에 읽힌다.
하지만 상태와 액션이 늘어나면 어디에 무엇이 있는지 찾는 것부터 어려워진다.
이쯤 되면 단순히 "상태를 저장하는 곳"이 아니라,
"여러 도메인이 섞여 있는 거대한 객체"가 되어버린다.
결국 아래 같은 문제가 생긴다.
- 새로 들어온 사람이 store 구조를 파악하기 어렵다
- 액션 이름이 비슷해져서 헷갈린다
- 어떤 상태가 어디서 변경되는지 추적하기 어렵다
3. selector를 써도 구조적 복잡함은 해결되지 않는다
앞선 글에서 selector는 필요한 상태만 구독하도록 도와준다고 정리했다.
하지만 selector는 구독 범위를 줄여줄 뿐, store 자체의 책임 분리는 대신해주지 않는다.
즉, 아래처럼 잘 꺼내 쓴다고 해도
const cart = useAppStore((state) => state.cart);
const theme = useAppStore((state) => state.theme);
store 내부가 이미 너무 커져 있다면,
그 복잡함은 그대로 남아 있다.
selector는 소비 방식의 개선이지, 설계 문제의 완전한 해결책은 아니다.
4. 타입과 액션 관리가 점점 번거로워진다
TypeScript를 쓰고 있다면 이 문제가 더 잘 드러난다.
store가 커질수록 상태 타입과 액션 타입이 한 인터페이스 안에서 계속 불어난다.
그러면 아래 같은 문제가 생긴다.
- 타입 정의가 너무 길어진다
- 수정할 때 영향 범위를 파악하기 어렵다
- 관련 없는 상태 변경이 한 타입에 계속 묶인다
결과적으로 "간단해서 Zustand를 썼는데, store 타입이 Redux reducer보다 읽기 어려워졌다"는 느낌이 들 수 있다.
5. 테스트와 유지보수 단위가 애매해진다
알림 로직을 검증하고 싶은데 장바구니 상태와 함께 엮여 있다면 테스트의 초점이 흐려진다.
반대로 장바구니 관련 변경을 하다가 사용자 상태 타입까지 건드리게 되면 수정 범위가 불필요하게 넓어진다.
이럴 때는 상태를 잘못 쪼갠 것이 아니라, 애초에 너무 크게 묶어놓은 것일 수 있다.
그럼 언제 slice 패턴을 고려해야 할까
내 기준에서는 아래 신호가 보이기 시작하면 slice 패턴을 고려한다.
1. 상태가 서로 다른 도메인으로 나뉘어 보일 때
예를 들어 아래 항목은 서로 관심사가 꽤 다르다.
- 사용자 정보
- 장바구니
- 알림
- 테마
- 검색 필터
이들은 모두 "전역 상태"일 수는 있어도, 같은 책임을 가진 상태는 아니다.
이럴 때는 하나의 store 안에 억지로 뭉쳐두기보다 도메인 단위로 나누는 편이 읽기 쉽다.
2. 액션 이름만 봐도 그룹이 나뉠 때
예를 들어 아래 액션들을 보자.
setUserlogoutaddCartItemremoveCartItemsetThemeopenModal
이 액션들은 이미 이름만 봐도 서로 묶음이 보인다.
이 정도면 파일 구조도 그 경계를 따라가게 만드는 편이 낫다.
3. 한 기능을 수정할 때 관련 없는 상태를 계속 같이 보게 될 때
장바구니 기능을 수정하는데 사용자 인증 로직이 같은 파일에 계속 보이고,
테마 상태까지 함께 스크롤해야 한다면 유지보수 비용이 커졌다는 신호다.
이건 단순히 파일이 길어서 불편한 것이 아니라,
"변경 단위"와 "구조 단위"가 어긋나기 시작했다는 뜻이다.
4. 특정 도메인만 재사용하거나 독립적으로 테스트하고 싶을 때
예를 들어 사용자 상태는 앱 전반에서 쓰이지만,
장바구니 상태는 특정 서비스에서만 강하게 쓰일 수 있다.
이런 경우 slice로 나누면 도메인 경계를 드러내기 쉽고,
특정 로직만 따로 생각하거나 테스트하기도 편해진다.
slice 패턴은 어떻게 이해하면 좋을까
slice 패턴은 거대한 store를 여러 도메인 조각으로 나누고, 이를 다시 하나의 store로 합치는 방식으로 이해하면 된다.
예를 들어 아래처럼 나눌 수 있다.
interface UserSlice {
user: { id: string; name: string } | null;
setUser: (user: { id: string; name: string } | null) => void;
logout: () => void;
}
interface CartSlice {
cart: { id: number; name: string; quantity: number }[];
addCartItem: (item: { id: number; name: string }) => void;
removeCartItem: (id: number) => void;
}
interface UiSlice {
theme: "light" | "dark";
isModalOpen: boolean;
setTheme: (theme: "light" | "dark") => void;
openModal: () => void;
closeModal: () => void;
}
그리고 slice 생성 함수를 따로 만들 수 있다.
import { StateCreator, create } from "zustand";
type StoreState = UserSlice & CartSlice & UiSlice;
const createUserSlice: StateCreator<StoreState, [], [], UserSlice> = (set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
});
const createCartSlice: StateCreator<StoreState, [], [], CartSlice> = (set) => ({
cart: [],
addCartItem: (item) =>
set((state) => ({
cart: [...state.cart, { ...item, quantity: 1 }],
})),
removeCartItem: (id) =>
set((state) => ({
cart: state.cart.filter((item) => item.id !== id),
})),
});
const createUiSlice: StateCreator<StoreState, [], [], UiSlice> = (set) => ({
theme: "light",
isModalOpen: false,
setTheme: (theme) => set({ theme }),
openModal: () => set({ isModalOpen: true }),
closeModal: () => set({ isModalOpen: false }),
});
export const useAppStore = create<StoreState>()((...args) => ({
...createUserSlice(...args),
...createCartSlice(...args),
...createUiSlice(...args),
}));
이 방식의 장점은 단순하다.
- 책임이 도메인별로 나뉜다
- store를 읽을 때 문맥 전환이 줄어든다
- 상태와 액션을 관련 있는 단위로 묶을 수 있다
그렇다고 무조건 slice로 나눠야 할까
여기서 중요한 점이 하나 있다.slice 패턴도 결국 구조화 전략일 뿐, 무조건 빨리 도입할수록 좋은 것은 아니다.
프로젝트 초반에 상태가 작고, store도 짧고, 도메인 경계도 아직 불명확하다면
처음부터 너무 세밀하게 나누는 것이 오히려 과할 수 있다.
예를 들어 상태가 아래 정도라면 굳이 나누지 않아도 된다.
interface CounterState {
count: number;
increase: () => void;
decrease: () => void;
}
이 정도 크기라면 slice 패턴보다 단순한 store 하나가 훨씬 낫다.
즉, 기준은 "멋지게 나눌 수 있는가"가 아니라 "지금 복잡함이 실제로 줄어드는가"다.
내가 slice를 도입하는 기준
실무에서는 아래 기준으로 판단하면 꽤 무난하다.
바로 나누는 편이 좋은 경우
- store 안에 서로 다른 도메인이 이미 섞여 있다
- 상태와 액션 수가 많아서 파일 읽기가 버겁다
- 한 기능을 수정할 때 관련 없는 코드까지 계속 같이 보인다
- 팀에서 여러 사람이 store를 같이 수정하고 있다
아직 단일 store로 가도 되는 경우
- store 책임이 하나로 명확하다
- 상태 수가 많지 않다
- 액션이 단순하다
- 지금 가장 중요한 것이 구조화보다 빠른 구현이다
slice 패턴으로 나눌 때 주의할 점
나눈다고 해서 자동으로 설계가 좋아지는 것은 아니다.
아래 같은 경우에는 slice 패턴을 써도 여전히 복잡할 수 있다.
1. 기준 없이 기계적으로 나누는 경우
파일 수만 늘어나고 실제 책임은 그대로 섞여 있을 수 있다.
중요한 것은 파일 개수가 아니라 도메인 경계다.
2. 서로 너무 강하게 의존하는 slice를 만드는 경우
한 slice가 다른 slice 내부 구현을 지나치게 많이 알고 있으면,
겉으로만 분리되어 있을 뿐 실제로는 결합도가 높아진다.
가능하면 "어떤 책임을 갖는가"를 기준으로 자르고,
다른 slice에 대한 의존은 최소화하는 편이 좋다.
3. 작은 프로젝트에 과한 구조를 도입하는 경우
구조화도 비용이다.
지금 문제보다 미래의 불확실성만 보고 너무 큰 틀을 먼저 만들면,
오히려 개발 속도가 느려질 수 있다.
마무리
Zustand는 가볍고 시작이 빠른 대신, store를 어디까지 키울 것인지에 대한 판단을 개발자에게 많이 맡긴다.
그래서 시간이 지나면 오히려 이런 질문이 중요해진다.
- 이 store는 지금 너무 많은 책임을 갖고 있지 않은가
- 상태를 읽는 비용보다 구조를 이해하는 비용이 더 커진 것은 아닌가
- selector로 구독 범위를 줄이는 것만으로 충분한가
- 이제는 slice처럼 책임 자체를 나눌 시점이 아닌가
내 기준에서 slice 패턴은 "커진 store를 멋지게 분해하는 기술"이라기보다,
"이미 드러난 도메인 경계를 코드 구조에 반영하는 방법"에 가깝다.
즉, store가 너무 커졌다는 신호가 보일 때는 단순히 selector나 shallow로 버티기보다,
상태의 책임을 다시 나눠보는 것이 더 근본적인 해결책이 될 수 있다.
'Develope > React' 카테고리의 다른 글
| Zustand를 로컬 상태 대신 써도 되는 순간은 언제일까 (0) | 2026.04.13 |
|---|---|
| Zustand에서 전역 상태와 서버 상태를 어떻게 구분할까? (0) | 2026.04.13 |
| Zustand에서 shallow 비교는 언제 필요할까? selector와 함께 이해하기 (0) | 2026.04.11 |
| Zustand Selector는 언제 써야 할까? 성능과 가독성 사이에서 판단하는 기준 (1) | 2026.04.09 |
| CRA에서 Vite + SSR로 마이그레이션하며 겪은 문제와 해결 과정 (0) | 2026.04.05 |