회사에서 새 프로젝트를 시작할 때마다 돌아오는 질문이 있다. "상태 관리 뭐 쓸까?" Redux는 이미 팀 전체가 피로감을 느끼고 있었고, Recoil은 메타가 유지보수를 제대로 안 한다는 소문이 돌았다. 자연스럽게 Zustand와 Jotai가 후보에 올랐다.
둘 다 만든 사람이 같다. Daishi Kato. 그래서 처음에는 "같은 사람이 만든 건데 뭐가 다르지?"라는 의문이 있었다. 결론부터 말하면, 설계 철학이 꽤 다르다. 그리고 그 차이가 실무에서 체감된다.
두 라이브러리를 각각 다른 프로젝트에서 6개월씩 써봤다. 그 경험을 기반으로 쓴다.
기본 개념의 차이
Zustand는 store 기반이다. 하나의 스토어에 상태와 액션을 정의한다. Redux와 비슷한 멘탈 모델이지만, 보일러플레이트가 극단적으로 적다.
import { create } from "zustand";
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
totalPrice: () => number;
}
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),
})),
totalPrice: () =>
get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}));
Jotai는 atom 기반이다. 상태의 최소 단위를 atom으로 쪼개고, 필요한 곳에서 조합한다.
import { atom, useAtom } from "jotai";
const cartItemsAtom = atom<CartItem[]>([]);
const cartTotalAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
});
// 컴포넌트에서
function CartTotal() {
const [total] = useAtom(cartTotalAtom);
return <span>{total.toLocaleString()}원</span>;
}첫인상은 Jotai가 더 간결해 보인다. atom 하나 만들고 useAtom으로 읽으면 끝이니까. 그런데 프로젝트가 커지면 이야기가 달라진다.
Zustand가 편했던 순간들
첫 번째 프로젝트는 어드민 대시보드였다. 여러 페이지에서 공유하는 전역 상태가 많았다. 사용자 정보, 사이드바 열림/닫힘, 현재 선택된 조직, 알림 목록 등.
Zustand에서는 이걸 관심사별로 스토어를 나누면 됐다.
// stores/auth.ts
const useAuthStore = create<AuthStore>((set) => ({
user: null,
token: null,
login: async (credentials) => {
const { user, token } = await authApi.login(credentials);
set({ user, token });
},
logout: () => set({ user: null, token: null }),
}));
// stores/ui.ts
const useUIStore = create<UIStore>((set) => ({
sidebarOpen: true,
theme: "light",
toggleSidebar: () =>
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}));
좋았던 점은, 컴포넌트 바깥에서도 상태에 접근할 수 있다는 거다. API 인터셉터에서 토큰이 만료되면 로그아웃 처리를 해야 하는데, Zustand는 이게 자연스럽다.
// api/interceptor.ts - React 컴포넌트가 아닌 일반 모듈
import axios from "axios";
import { useAuthStore } from "@/stores/auth";
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout();
window.location.href = "/login";
}
return Promise.reject(error);
}
);useAuthStore.getState()로 React 외부에서 상태를 읽고 변경할 수 있다. Jotai에서 이걸 하려면 store를 별도로 만들어서 주입하는 방식을 써야 하는데, 공식적으로 권장하는 패턴이 아니다 보니 좀 어색하다.
Jotai가 빛났던 순간들
두 번째 프로젝트는 설문 빌더였다. 드래그 앤 드롭으로 질문을 만들고, 각 질문마다 옵션이 있고, 조건부 분기가 있는 복잡한 폼이었다.
이 프로젝트에서 Zustand를 썼다면 하나의 거대한 스토어가 됐을 거다. 질문 목록, 각 질문의 옵션, 선택된 질문, 드래그 상태, 미리보기 모드 등을 전부 한 곳에 넣어야 한다.
Jotai에서는 각 질문을 독립적인 atom으로 만들 수 있다.
import { atom } from "jotai";
import { atomFamily } from "jotai/utils";
// 질문 ID별로 독립적인 atom 생성
const questionAtomFamily = atomFamily((id: string) =>
atom<Question>({
id,
type: "text",
title: "",
options: [],
required: false,
})
);
// 질문 ID 목록만 따로 관리
const questionIdsAtom = atom<string[]>([]);
// 파생 atom: 전체 질문 개수
const questionCountAtom = atom((get) => get(questionIdsAtom).length);
// 파생 atom: 필수 질문만 필터링
const requiredQuestionsAtom = atom((get) => {
const ids = get(questionIdsAtom);
return ids.filter((id) => get(questionAtomFamily(id)).required);
});
핵심은, 질문 하나를 수정해도 다른 질문 컴포넌트가 리렌더링되지 않는다는 거다. Zustand에서 큰 스토어 하나에 넣으면 셀렉터를 꼼꼼히 쓰지 않는 한 불필요한 리렌더링이 발생한다.
// 이 컴포넌트는 해당 질문의 atom만 구독한다
function QuestionEditor({ id }: { id: string }) {
const [question, setQuestion] = useAtom(questionAtomFamily(id));
return (
<div>
<input
value={question.title}
onChange={(e)=>
setQuestion((prev)=> ({ ...prev, title: e.target.value }))
}
/>
{/* 이 input이 바뀌어도 다른 QuestionEditor는 리렌더링 안 됨 */}
</div>
);
}질문이 50개, 100개가 되어도 하나를 편집할 때 그 컴포넌트만 업데이트된다. 설문 빌더처럼 같은 구조의 데이터가 많고, 각각 독립적으로 변하는 경우에는 Jotai의 atom 모델이 압도적으로 유리하다.
DevTools과 디버깅
Zustand는 Redux DevTools를 그대로 쓸 수 있다. 미들웨어 한 줄이면 된다.
import { devtools } from "zustand/middleware";
const useCartStore = create<CartStore>()(
devtools(
(set) => ({
// ...
}),
{ name: "cart-store" }
)
);
상태 변경 히스토리를 시간 순으로 볼 수 있고, 특정 시점으로 되돌릴 수도 있다. 버그가 발생했을 때 "이 시점에서 상태가 이상해졌구나"를 추적하기 좋다.
Jotai는 공식 DevTools 확장이 있긴 한데, Zustand만큼 성숙하지는 않다. jotai-devtools 패키지를 별도로 설치해야 하고, atom이 많아지면 어떤 atom이 어떤 값을 가지고 있는지 추적하기가 좀 힘들다. atom에 debugLabel을 하나하나 붙여야 구분이 된다.
const cartItemsAtom = atom<CartItem[]>([]);
cartItemsAtom.debugLabel = "cartItems";
const cartTotalAtom = atom((get) => {
// ...
});
cartTotalAtom.debugLabel = "cartTotal";
이거 50개 atom에 다 붙이고 있으면 은근 귀찮다.
미들웨어와 persist
Zustand의 미들웨어 시스템은 진짜 잘 되어 있다. persist, devtools, immer를 조합해서 쓸 수 있다.
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
const useSettingsStore = create<SettingsStore>()(
persist(
immer((set) => ({
theme: "light" as const,
fontSize: 14,
notifications: true,
updateTheme: (theme: "light" | "dark") =>
set((state) => {
state.theme = theme;
}),
})),
{
name: "settings-storage",
partialize: (state) => ({
theme: state.theme,
fontSize: state.fontSize,
}),
}
)
);
Jotai도 atomWithStorage로 persist를 지원하지만, 여러 atom을 한꺼번에 persist하거나 세밀하게 제어하려면 커스텀 로직이 필요하다.
그래서 뭘 쓰라는 건가
내 경험상 이렇다.
프로젝트에 전역 상태가 명확하게 존재하고, 여러 페이지에서 공유하며, 컴포넌트 외부에서도 상태에 접근해야 한다면 Zustand. 어드민, 대시보드, 전자상거래처럼 인증/장바구니/알림 같은 전역 상태가 핵심인 프로젝트.
같은 구조의 데이터가 많고, 각각 독립적으로 변하며, 리렌더링 최적화가 중요하다면 Jotai. 폼 빌더, 에디터, 칸반보드처럼 개별 아이템의 상태가 세밀하게 쪼개지는 프로젝트.
물론 두 라이브러리 모두 대부분의 상황을 커버할 수 있다. 위의 구분은 "이쪽이 더 자연스럽다" 정도의 차이다. 잘못된 선택을 해도 프로젝트가 망하지는 않는다. 다만 특정 상황에서 "아, 이건 다른 걸 썼으면 더 편했겠다"라는 느낌이 올 뿐이다.
요즘은 두 라이브러리를 한 프로젝트에서 같이 쓸 수도 있지 않을까 생각하고 있다. 전역 상태는 Zustand로, 특정 페이지의 세밀한 상태는 Jotai로. 아직 시도해보지는 않았는데, 의존성이 늘어나는 트레이드오프를 감수할 만한 상황이 있을 것 같기도 하다.
