잘못된 질문
블로그를 돌아다니다 보면 "Jotai vs Zustand 어떤 게 더 좋은가"라는 글을 자주 본다. 댓글을 보면 늘 비슷한 패턴이다. 누군가는 Jotai가 더 React스럽다고 하고, 누군가는 Zustand가 더 단순하다고 하고, 또 누군가는 자기 회사에서 이걸 썼더니 좋더라고 한다.
나는 이 질문 자체가 잘못되었다고 생각한다. 두 라이브러리는 어느 쪽이 더 우월한지 가 아니라, 언제 어떤 것이 적합한지 의 문제다. 그리고 그 답은 라이브러리의 기술적 차이가 아니라, 프로젝트가 어떤 환경에서 동작하는지에 달려 있다.
둘은 본질적으로 다른 도구다
먼저 기술적인 차이를 짧게 짚자. 둘 다 React 상태 관리 라이브러리로 묶이지만, 출발점이 다르다.
Zustand는 JavaScript 자체다. 모듈 스코프의 클로저 안에 상태를 담는다. React가 있든 없든 동작한다. WebSocket 핸들러, 서비스 함수, 어디서든 store.getState()로 상태를 읽고 store.setState()로 쓸 수 있다. React는 그저 이 클로저를 구독하는 한 가지 방식일 뿐이다.
Jotai는 React 안에서 동작한다. atom이라는 단위가 React의 Provider 트리 안에 있다. atom 자체는 모듈 스코프에 있지만, 값은 Provider 안에 보관되고 컴포넌트의 렌더링 사이클을 통해서만 접근된다. React 밖에서 atom의 값을 직접 꺼내는 건 자연스럽지 않다.
이 차이는 단순한 구현 디테일이 아니라, 어떤 종류의 앱에 어울리는가 를 결정한다.
프로젝트가 선택을 결정한다
대시보드라면, Jotai
대시보드를 떠올려보자. 차트, 테이블, KPI 카드, 필터, 여러 위젯이 같은 raw 데이터를 다른 형태로 보여준다. 필터나 기간이 바뀌면 여러 위젯이 동시에 영향받지만, 영향의 범위는 위젯마다 다르다.
이건 정확히 의존성 그래프 다. Jotai의 atom이 가장 자연스럽게 표현하는 형태다.
const dateRangeAtom = atom({ from: '...', to: '...' });
const filterAtom = atom({ region: 'KR' });
const salesDataAtom = atom(async (get) => {
const range = get(dateRangeAtom);
const filter = get(filterAtom);
return fetchSales({ ...range, ...filter });
});
const totalRevenueAtom = atom((get) => {
const data = get(salesDataAtom);
return data.reduce((sum, x) => sum + x.amount, 0);
});필터 atom 하나만 바꾸면 의존하는 모든 atom이 자동으로 재계산되고, 그 결과를 보여주는 위젯만 리렌더된다. 개발자가 의존성을 일일이 관리할 필요가 없다. atom의 그래프가 곧 데이터의 흐름이다.
같은 걸 Zustand로도 만들 수는 있다. 하지만 selector를 일일이 작성해야 하고, shallow 비교 같은 것도 신경 써야 한다. 의존성 추적이 자동이 아니라 수동이 된다. 위젯이 많아질수록 이 수동 작업이 부담이 된다.
WebSocket이나 Web Worker가 있다면, Zustand
반대로 실시간 데이터를 다루는 앱을 생각해보자. WebSocket으로 서버에서 메시지가 계속 들어오고, 그걸 받아서 상태를 업데이트하고, UI에 반영해야 한다. 또는 무거운 계산을 Web Worker로 보내고, 워커가 결과를 보내면 메인 스레드의 상태를 업데이트해야 한다.
이런 경우 상태를 업데이트하는 코드가 React 컴포넌트 밖에 있다. WebSocket의 onmessage 핸들러는 React와 무관한 곳이다. Web Worker의 postMessage 응답을 처리하는 코드도 마찬가지다. 이런 곳에서 상태에 접근하려면, 상태가 React 밖에 있어야 한다.
// Zustand, React 밖에서 직접 접근 가능
const useChatStore = create((set) => ({
messages: [],
addMessage: (msg) => set((state) => ({ messages: [...state.messages, msg] }))
}));
// WebSocket 핸들러, React 컴포넌트가 아닌 곳
const ws = new WebSocket('...');
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
useChatStore.getState().addMessage(msg);
};이건 Zustand의 핵심 강점이다. 같은 걸 Jotai로 하려면 atom의 값을 외부에서 직접 다루는 우회 로직을 만들어야 하고, 그건 Jotai가 의도한 사용 방식이 아니다.
이미 쓰고 있다면 바꾸지 마라
여기서 중요한 이야기를 하나 하고 싶다. 위에서 어떤 경우에 어느 쪽이 더 적합한지 정리했지만, 이미 팀이 둘 중 하나를 잘 쓰고 있다면 굳이 바꿀 이유가 없다.
Jotai와 Zustand는 둘 다 충분히 정교하고 잘 만들어진 라이브러리다. 어느 쪽을 쓰든 대부분의 앱을 잘 만들 수 있다. 둘 사이의 차이는 "한쪽이 더 우아하게 풀린다"는 정도이지, "한쪽으로는 절대 풀리지 않는다"가 아니다. selector를 잘 쓰는 Zustand 코드는 Jotai만큼 효율적이고, 외부 접근을 우회로 처리하는 Jotai 코드도 동작은 한다.
라이브러리를 교체하는 비용은 늘 생각보다 크다. 학습 곡선, 코드 마이그레이션, 새로운 패턴에 익숙해지는 시간, 마이그레이션 중에 생기는 버그, 이 모든 것이 "조금 더 우아한 코드"보다 훨씬 비싸다. 트위터에서 본 비교 글 하나에 영향받아 멀쩡히 돌아가는 시스템을 갈아엎는 건 좋은 엔지니어링이 아니다.
그러나 한계와 충돌한다면
다만 예외가 있다. 프로젝트의 요구사항이 라이브러리의 한계와 부딪힐 때다.
Jotai를 쓰던 팀이 새 기능으로 WebSocket 기반 실시간 알림을 추가해야 한다고 해보자. WebSocket 핸들러에서 상태를 업데이트해야 하는데, atom은 React 안에서만 자연스럽게 동작한다. 우회 로직을 만들 수도 있지만, 그건 Jotai의 결을 거스르는 코드가 된다. 이런 코드가 한두 군데가 아니라 점점 늘어난다면, 그건 도구를 잘못 선택한 것이다.
이 시점에는 바꿔야 한다. 라이브러리의 한계와 싸우면서 우회 로직을 쌓아가는 것보다, 프로젝트의 본질에 맞는 도구로 옮기는 것이 길게 보면 더 싸다.
반대 방향도 마찬가지다. Zustand를 쓰던 팀이 점점 복잡한 파생 상태를 다루게 되고, selector가 점점 늘어나고, 메모이제이션을 관리하는 게 부담이 된다면, 그건 atom 기반 도구가 더 적합한 영역에 들어선 것일 수 있다.
결국은 시각의 문제다
상태 관리 라이브러리를 선택하는 일은 결국 트렌드를 따르는 게 아니라, 프로젝트의 본질을 보는 시각 의 문제다.
우리 앱은 어떤 데이터를 다루는가? 그 데이터의 의존 관계는 그래프인가, 단일 도메인인가? 상태에 접근해야 하는 주체는 React 컴포넌트뿐인가, 아니면 그 밖에도 있는가? 팀의 멘탈 모델은 어디에 더 가까운가? 이런 질문에 답할 수 있어야, "Jotai와 Zustand 중 무엇이 우리에게 맞는가"라는 질문에 답할 수 있다.
"Jotai가 더 좋아요" 같은 단정에 휘둘리지 말자. 필요한 건 남이 좋다고 한 도구가 아니라, 우리 프로젝트가 요구하는 도구 다.