
Zustand 심화: 전역 상태 관리의 실제 패턴
Redux가 너무 복잡해서 Zustand로 바꿨는데, 보일러플레이트가 90% 줄었다. 근데 제대로 쓰려면 알아야 할 패턴이 있었다.

Redux가 너무 복잡해서 Zustand로 바꿨는데, 보일러플레이트가 90% 줄었다. 근데 제대로 쓰려면 알아야 할 패턴이 있었다.
느리다고 느껴서 감으로 최적화했는데 오히려 더 느려졌다. 프로파일러로 병목을 정확히 찾는 법을 배운 이야기.

텍스트에서 바이너리로(HTTP/2), TCP에서 UDP로(HTTP/3). 한 줄로서기 대신 병렬처리 가능해진 웹의 진화. 구글이 주도한 QUIC 프로토콜 이야기.

HTML 파싱부터 DOM, CSSOM 생성, 렌더 트리, 레이아웃(Reflow), 페인트(Repaint), 그리고 합성(Composite)까지. 브라우저가 화면을 그리는 6단계 과정과 치명적인 렌더링 성능 최적화(CRP) 가이드.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

프로젝트에서 Redux를 쓰고 있었는데, 솔직히 매번 상태 하나 추가할 때마다 지옥이었다. action creator 만들고, reducer 만들고, type 정의하고, dispatch 연결하고... 간단한 카운터 하나 만드는데 파일 5개를 건드려야 했다.
"이게 맞나?" 싶어서 대안을 찾아봤고, Zustand를 만났다. 첫인상은 "이렇게 간단해도 되나?"였다. Redux에서 20줄 짜리 보일러플레이트가 Zustand에선 5줄로 끝났다. Provider도 없고, Context Hell도 없었다.
근데 실제로 쓰다 보니 알아야 할 게 있더라. 성능 최적화, 미들웨어 활용, 대규모 앱 구조화... 이런 걸 모르면 결국 또 다른 스파게티 코드를 만들게 된다. 그래서 정리했다. 내가 삽질하면서 배운 Zustand의 실제 패턴들을.
Redux에서 Zustand로 넘어오면서 가장 충격적이었던 건, store가 그냥 함수라는 거였다.
Redux는 이랬다:
// actions.ts
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const increment = () => ({ type: INCREMENT });
export const decrement = () => ({ type: DECREMENT });
// reducer.ts
const counterReducer = (state = { count: 0 }, action) => {
switch (action.type) {
case INCREMENT:
return { count: state.count + 1 };
case DECREMENT:
return { count: state.count - 1 };
default:
return state;
}
};
// Component에서
import { useDispatch, useSelector } from 'react-redux';
const dispatch = useDispatch();
const count = useSelector(state => state.counter.count);
dispatch(increment());
Zustand는 이렇다:
import { create } from 'zustand';
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
// Component에서
const { count, increment } = useCounterStore();
increment();
파일 하나, 함수 하나, 끝. Provider도 없다. 그냥 import해서 쓴다.
이게 가능한 이유는 Zustand store가 React 밖에 존재하는 singleton이기 때문이다. Redux처럼 Context로 전달할 필요가 없다. 그냥 전역 함수처럼 어디서든 불러 쓰면 된다.
이 깨달음이 게임 체인저였다. 상태 관리를 "거대한 아키텍처"가 아니라 "필요한 곳에 쓰는 도구"로 볼 수 있게 됐다.
처음엔 이렇게 썼다:
const useUserStore = create((set) => ({
user: { name: 'John', email: 'john@example.com', age: 30 },
updateUser: (data) => set({ user: data }),
}));
// Component에서
function ProfileName() {
const store = useUserStore(); // 전체 store를 가져옴
return <div>{store.user.name}</div>;
}
문제는 user.age가 바뀌어도 ProfileName 컴포넌트가 리렌더링된다는 거였다. 필요 없는 업데이트였다.
해결책은 selector:
function ProfileName() {
const userName = useUserStore((state) => state.user.name); // 필요한 것만
return <div>{userName}</div>;
}
이제 user.name이 바뀔 때만 리렌더링된다. Zustand는 shallow comparison으로 변화를 감지한다.
더 복잡한 selector도 가능하다:
const fullName = useUserStore((state) =>
`${state.user.firstName} ${state.user.lastName}`
);
이건 마치 식당에서 메뉴판 전체를 달라고 하는 게 아니라 "오늘의 스프만 주세요"라고 주문하는 것과 같다. 필요한 것만 받으니까 효율적이다.
중첩된 객체를 업데이트할 때는 지옥이었다:
const useCartStore = create((set) => ({
items: [
{ id: 1, name: 'Apple', quantity: 2 },
{ id: 2, name: 'Banana', quantity: 1 },
],
updateQuantity: (id, quantity) => set((state) => ({
items: state.items.map(item =>
item.id === id ? { ...item, quantity } : item
),
})),
}));
Immer middleware를 쓰면:
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
const useCartStore = create(
immer((set) => ({
items: [
{ id: 1, name: 'Apple', quantity: 2 },
{ id: 2, name: 'Banana', quantity: 1 },
],
updateQuantity: (id, quantity) => set((state) => {
const item = state.items.find(i => i.id === id);
if (item) item.quantity = quantity; // 그냥 수정하면 됨!
}),
}))
);
마법처럼 불변성이 지켜진다. Immer가 뒤에서 proxy로 변경사항을 추적하고 새 객체를 만들어준다. 이건 마치 Draft 모드에서 편집하다가 "게시" 버튼을 누르면 새 버전이 생기는 것과 같다.
사용자 설정이나 장바구니처럼 유지되어야 할 상태가 있다.
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
const useSettingsStore = create(
persist(
(set) => ({
theme: 'light',
language: 'ko',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: 'app-settings', // localStorage key
storage: createJSONStorage(() => localStorage),
}
)
);
이제 페이지를 새로고침해도 설정이 유지된다. sessionStorage나 커스텀 storage도 쓸 수 있다.
만약 일부 필드만 persist하고 싶다면:
persist(
(set) => ({ /* ... */ }),
{
name: 'app-settings',
partialize: (state) => ({
theme: state.theme,
language: state.language,
// token 같은 민감한 정보는 제외
}),
}
)
앱이 커지면 store 하나에 모든 걸 때려넣을 순 없다. Slice 패턴을 써서 분리한다:
// userSlice.ts
export const createUserSlice = (set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
});
// cartSlice.ts
export const createCartSlice = (set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id)
})),
});
// store.ts
import { create } from 'zustand';
import { createUserSlice } from './userSlice';
import { createCartSlice } from './cartSlice';
const useStore = create((...a) => ({
...createUserSlice(...a),
...createCartSlice(...a),
}));
이제 각 slice는 독립적으로 관리되지만, 하나의 store에서 접근 가능하다. 마치 큰 회사를 부서별로 나누되, 같은 건물에 있는 것처럼.
Slice끼리 상호작용이 필요하면:
export const createCartSlice = (set, get) => ({
items: [],
checkout: () => {
const user = get().user; // 다른 slice의 상태 접근
if (!user) {
alert('로그인이 필요합니다');
return;
}
// checkout logic...
},
});
API 호출을 store에서 처리하는 패턴:
const usePostsStore = create((set) => ({
posts: [],
loading: false,
error: null,
fetchPosts: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/posts');
const posts = await response.json();
set({ posts, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
createPost: async (post) => {
set({ loading: true });
try {
const response = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(post),
});
const newPost = await response.json();
set((state) => ({
posts: [...state.posts, newPost],
loading: false,
}));
} catch (error) {
set({ error: error.message, loading: false });
}
},
}));
// Component에서
function PostList() {
const { posts, loading, fetchPosts } = usePostsStore();
useEffect(() => {
fetchPosts();
}, [fetchPosts]);
if (loading) return <div>Loading...</div>;
return posts.map(post => <Post key={post.id} {...post} />);
}
이 패턴의 장점은 로딩/에러 상태가 store에 포함되어 있어서 여러 컴포넌트에서 공유할 수 있다는 거다.
Redux DevTools를 Zustand에서도 쓸 수 있다:
import { devtools } from 'zustand/middleware';
const useStore = create(
devtools(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }), false, 'increment'),
decrement: () => set((state) => ({ count: state.count - 1 }), false, 'decrement'),
}),
{ name: 'CounterStore' }
)
);
이제 브라우저 DevTools에서 상태 변화를 시각적으로 추적할 수 있다. Time-travel debugging도 된다.
여러 middleware를 조합하려면:
const useStore = create(
devtools(
persist(
immer((set) => ({
// store definition
})),
{ name: 'my-store' }
),
{ name: 'MyStore' }
)
);
순서가 중요하다. 보통 devtools → persist → immer 순으로 감싼다.
Store는 React와 독립적이라서 테스트가 쉽다:
import { renderHook, act } from '@testing-library/react';
import { useCounterStore } from './counterStore';
describe('CounterStore', () => {
beforeEach(() => {
// 각 테스트 전에 store 초기화
useCounterStore.setState({ count: 0 });
});
it('should increment count', () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('should decrement count', () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(-1);
});
});
Store를 직접 조작할 수도 있다:
it('should handle async operations', async () => {
const store = usePostsStore.getState();
await store.fetchPosts();
expect(store.posts.length).toBeGreaterThan(0);
expect(store.loading).toBe(false);
});
비슷한 시기에 나온 라이브러리들과 비교하면:
Zustand: Flux 패턴, 중앙집중식 store, 익숙한 API
Jotai: Atomic 패턴, 작은 상태 단위들의 조합
Valtio: Proxy 기반, mutable API
내 경험상 Zustand는 Redux 대체용으로 최고였다. 기존 패턴이 익숙하고, 팀 온보딩도 쉬웠다.
실제로 진행했던 마이그레이션 과정:
1단계: 간단한 slice부터 시작
// 기존 Redux
const userReducer = (state = initialState, action) => {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.payload };
default:
return state;
}
};
// Zustand로 변환
const useUserStore = create((set) => ({
user: null,
setUser: (user) => set({ user }),
}));
2단계: 점진적으로 교체
3단계: Provider 제거
결과: 보일러플레이트 90% 감소, 개발 속도 2배 향상, 번들 사이즈 감소.
Redux를 쓰면서 느꼈던 "과한" 느낌이 Zustand에선 없다. 필요한 건 다 있으면서도 심플하다.
핵심 패턴을 정리하면:
결국 상태 관리는 복잡할 필요가 없다. 필요한 기능만 있으면 된다. Zustand는 그 80%를 20%의 노력으로 커버한다.
마지막으로, 선택의 기준은 이렇다:
내 경우엔 Zustand가 딱 맞았다. Redux의 구조와 패턴은 익숙하되, 보일러플레이트는 최소화하고 싶었으니까. 그리고 실제로 그렇게 됐다.