React의 선택, "뷰 레이어만 담당한다"
React는 Vue, Angular와 근본적으로 다른 선택을 했습니다. 상태 관리를 프레임워크에 포함시키지 않았습니다. "UI를 렌더링하는 라이브러리"라는 정체성을 유지하면서, 상태 관리는 생태계에 맡겼습니다.
이 결정은 의도적이었습니다. 하나의 해법을 강제하는 대신, 각 팀이 자신의 상황에 맞는 도구를 선택할 수 있게 했습니다. 하지만 그 결과, React 생태계에서는 수십 개의 상태 관리 라이브러리가 등장했고, "어떤 걸 써야 하는가"가 그 자체로 논쟁이 되었습니다.
이 글에서는 그 라이브러리들이 왜 등장했는지, 각각 어떤 기술적 문제 를 해결하려 했는지를 추적합니다.
Context API, 첫 번째 공식 답
React 16.3 (2018) 에서 Context API가 도입되었습니다. prop drilling 없이 컴포넌트 트리 전체에 데이터를 전달할 수 있는 공식 메커니즘입니다.
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<Header />
<Main />
<Footer />
</ThemeContext.Provider>
);
}
function Header() {
const theme = useContext(ThemeContext);
return <header className={theme}>...</header>;
}prop drilling이 해결되었습니다. 하지만 범용 상태 관리 도구 로 사용하기에는 근본적인 한계가 있습니다.
Context의 리렌더링 문제
Provider의 value가 바뀌면, 해당 Context를 구독하는 모든 컴포넌트가 리렌더링 됩니다.
const AppContext = createContext();
function App() {
const [user, setUser] = useState({ name: 'Kim' });
const [theme, setTheme] = useState('light');
// user 또는 theme이 바뀔 때마다 value 객체가 새로 생성됨
return (
<AppContext.Provider value={{ user, theme, setUser, setTheme }}>
<UserProfile /> {/* user만 사용 */}
<ThemeToggle /> {/* theme만 사용 */}
</AppContext.Provider>
);
}theme만 바뀌어도 UserProfile이 리렌더링됩니다. UserProfile은 user만 사용하지만, Context의 value 객체가 새로 생성되었기 때문입니다. Context에는 "value의 특정 필드만 구독한다"는 개념이 없습니다.
이 문제를 회피하려면 Context를 여러 개로 쪼개야 합니다. 하지만 그러면 Provider가 중첩되어 Provider Hell 이 발생합니다.
// Provider Hell
function App() {
return (
<AuthProvider>
<ThemeProvider>
<I18nProvider>
<RouterProvider>
<NotificationProvider>
<Main />
</NotificationProvider>
</RouterProvider>
</I18nProvider>
</ThemeProvider>
</AuthProvider>
);
}Context는 "자주 변하지 않는 값" (테마, 로케일, 인증 정보 등) 에는 적합합니다. 하지만 자주 변하는 상태를 위한 범용 도구로는 부적합합니다.
Zustand, 클로저 기반의 미니멀 스토어
Zustand (2019, Paul Henschel / Poimandres) 는 Redux의 보일러플레이트와 Context의 리렌더링 문제를 동시에 해결하려 했습니다. 핵심 아이디어는 놀라울 정도로 단순합니다, 클로저 하나에 상태를 담는다.
// Zustand의 핵심 아이디어 (단순화)
function createStore(createState) {
let state;
const listeners = new Set();
const getState = () => state;
const setState = (partial) => {
const nextState = typeof partial === 'function'
? partial(state)
: partial;
state = { ...state, ...nextState };
listeners.forEach(listener => listener(state));
};
state = createState(setState, getState);
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
return { getState, setState, subscribe };
}이것이 Zustand의 거의 전부입니다. 클로저에 state를 담고, setState로 변경하면 구독자에게 알립니다. Redux 같은 Action 타입, Action Creator, Reducer, Dispatcher가 없습니다.
실제 사용
import { create } from 'zustand';
const useTodoStore = create((set, get) => ({
todos: [],
add: (text) => set((state) => ({
todos: [...state.todos, { text, done: false }]
})),
toggle: (index) => set((state) => ({
todos: state.todos.map((todo, i) =>
i === index ? { ...todo, done: !todo.done } : todo
)
})),
doneCount: () => get().todos.filter(t => t.done).length
}));
// 컴포넌트에서 사용
function TodoList() {
const todos = useTodoStore(state => state.todos);
const add = useTodoStore(state => state.add);
// ...
}selector (state => state.todos) 를 사용하면, 해당 selector의 반환값이 바뀔 때만 컴포넌트가 리렌더링됩니다. Context와 달리 "필요한 부분만 구독"이 가능합니다.
Tearing 문제와 useSyncExternalStore
Zustand가 React 외부에 상태를 두는 접근은 잘 작동했습니다, React 18의 Concurrent Mode가 등장하기 전까지는.
Concurrent Mode에서 React는 렌더링을 중간에 멈추고 다시 시작 할 수 있습니다. 이때 문제가 생깁니다.
렌더링 시작
ComponentA가 store.count를 읽음 → 5
(React가 렌더링을 일시 중단)
(이 사이에 store.count가 6으로 변경됨)
ComponentB가 store.count를 읽음 → 6
렌더링 완료
→ 같은 렌더링인데 A는 5, B는 6을 보여줌, tearing같은 렌더링 사이클 안에서 어떤 컴포넌트는 이전 값을, 어떤 컴포넌트는 새 값을 읽게 됩니다. 화면이 찢어지는 현상입니다. 이것이 tearing 입니다.
React 팀은 이 문제를 해결하기 위해 React 18 (2022.03) 에서 useSyncExternalStore 를 제공했습니다.
import { useSyncExternalStore } from 'react';
function useStore(selector) {
return useSyncExternalStore(
store.subscribe, // 구독 함수
() => selector(store.getState()) // 스냅샷 함수
);
}useSyncExternalStore는 렌더링 중 외부 스토어의 값이 변경되면 동기적으로 재렌더링을 강제 하여, 모든 컴포넌트가 같은 스냅샷을 읽도록 보장합니다.
Zustand는 v4에서 이 API를 채택했고 (React 17 이하에서는 use-sync-external-store shim 사용), v5 (2024) 에서 shim을 걷어내고 React 18 이상의 네이티브 API로 완전히 전환했습니다. 사용자 입장에서 API는 거의 바뀌지 않았지만, 내부적으로 구독 메커니즘이 교체되어 Concurrent Mode에서도 안전하게 동작합니다.
Jotai, 원자 단위의 상태
Zustand가 "하나의 스토어에 여러 상태를 모으는" 방식이라면, Jotai (2020, Daishi Kato) 는 정반대입니다, 상태를 원자 (atom) 단위로 쪼갭니다.
import { atom, useAtom } from 'jotai';
// atom, 상태의 최소 단위
const countAtom = atom(0);
const textAtom = atom('hello');
// 파생 atom, 다른 atom에서 계산
const doubledAtom = atom((get) => get(countAtom) * 2);
const summaryAtom = atom((get) => `${get(textAtom)}: ${get(countAtom)}`);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
function Display() {
const [doubled] = useAtom(doubledAtom);
return <span>{doubled}</span>;
}각 atom은 독립적이고, 해당 atom을 구독하는 컴포넌트만 리렌더링됩니다. countAtom이 변경되면 Counter와 Display (doubled를 통해 의존) 만 리렌더링되고, textAtom을 사용하는 컴포넌트는 영향받지 않습니다.
Recoil과의 관계
atom 기반 상태 관리의 아이디어는 Recoil (2020, Meta) 에서 먼저 제안되었습니다. Recoil은 "atom"과 "selector"라는 개념으로 React의 상태를 그래프 구조로 관리하는 접근을 보여주었습니다.
하지만 Recoil은 Meta 내부의 우선순위 변화로 팀이 해체되었습니다. 마지막 릴리스는 0.7.7 (2023.04) 이며, 2025년 1월에 GitHub 레포가 아카이브되었습니다. React 18의 Concurrent 기능과의 호환도 완성되지 못한 채 종료되었습니다.
Jotai는 이 atom 모델을 더 단순하게, 더 작은 번들 사이즈로 구현했습니다. Recoil이 문자열 key를 요구하는 반면 Jotai는 객체 참조로 atom을 식별하여 보일러플레이트가 적습니다.
Zustand vs Jotai, 같은 커뮤니티, 다른 철학
Zustand는 Paul Henschel이 만들고, Jotai는 Daishi Kato 가 만들었습니다. Daishi Kato는 이후 Zustand의 리드 메인테이너도 맡게 되었고, 두 라이브러리 모두 Poimandres (pmndrs) 커뮤니티 아래에 있습니다. 창시자는 달라도 같은 커뮤니티에서 유지되는 셈입니다. 하지만 설계 철학은 정반대에 가깝습니다.
| Zustand | Jotai | |
|---|---|---|
| 모델 | 하나의 스토어, 여러 상태 | 여러 atom, 각각 독립 |
| 상태 위치 | React 외부 (모듈 스코프) | React 내부 (Provider) |
| 적합한 곳 | 전역 상태, React 밖에서도 접근 필요 | 컴포넌트 간 공유 상태, 파생 상태 |
| 구독 최적화 | selector를 직접 작성 | atom 단위로 자동 |
| 비유 | Redux의 단순화 | Context의 대체 |
Zustand 는 React 밖에서도 스토어에 접근할 수 있습니다. WebSocket 핸들러, 유틸리티 함수 등 React 컴포넌트가 아닌 곳에서 상태를 읽고 쓸 수 있습니다. 모듈 스코프에 스토어가 존재하기 때문입니다.
Jotai 는 React의 렌더링 사이클 안에서 동작합니다. Provider 내부에서 atom을 통해 상태를 공유하므로, 같은 앱에서 여러 Provider를 두어 독립적인 상태 영역을 만들 수도 있습니다. 파생 상태 (computed) 를 atom으로 자연스럽게 표현할 수 있어, 상태 간 의존 관계가 복잡한 경우에 강점이 있습니다.
어떤 것을 선택할지는 "상태가 React 안에 있어야 하는가, 밖에도 있어야 하는가"로 결정됩니다.
왜 이렇게 많은가
React 상태 관리 라이브러리가 이렇게 많은 이유를 정리하면 결국 하나입니다, React가 상태 관리를 프레임워크에 포함시키지 않았기 때문입니다. Vue에는 Pinia가 있고, Angular에는 Signals가 있지만, React에는 "공식 상태 관리 솔루션"이 없습니다.
각 라이브러리는 특정 문제를 해결하기 위해 등장했습니다.
- Redux , 예측 가능성과 디버깅 (보일러플레이트라는 대가)
- Context , prop drilling 해결 (리렌더링 문제)
- Zustand , 미니멀한 API, React 외부 접근 (tearing → useSyncExternalStore)
- Jotai , 원자 단위 구독, 파생 상태 (Recoil의 단순화)
다음 단계
지금까지 Vue, Angular, React 각각의 상태 관리 발전사를 살펴봤습니다. 경로는 달랐지만 방향은 비슷합니다, 더 적은 코드로, 더 정밀하게, 변경된 곳만 업데이트합니다. 마지막 글에서는 이 세 갈래가 어떻게 같은 지점으로 수렴하고 있는지를 정리합니다.