리액트 상태관리 도구를 검색하면 열 개가 쏟아진다. Context, Redux, Zustand, Recoil, Jotai, MobX, React Query. 신입 때 나는 이 목록을 보고 다 배워야 하는 줄 알았다. 아니었다. 도구를 고르기 전에 던져야 할 질문은 딱 하나다. 지금 다루려는 게 대체 무슨 상태인가. 이걸 먼저 나누면 도구 절반이 저절로 걸러진다. 도구 이름을 외우는 것보다 이 분류 하나를 손에 쥐는 게 훨씬 오래 남는다.
서버 상태와 클라이언트 상태는 다른 물건이다. 상태를 성격으로 나눠 보자. 서버 상태(server state)는 원본이 서버에 있고 우리는 사본을 캐시할 뿐인 데이터다. 게시글 목록, 사용자 프로필 같은 것. 최신인지 확인해야 하고, 로딩과 에러가 따라붙고, 여러 화면이 같은 데이터를 다시 받는다. 클라이언트 상태(client state)는 원본이 브라우저에만 있는 데이터다. 다크모드 설정, 모달이 열렸는지, 입력 중인 폼 값. 서버는 이걸 모르고 알 필요도 없다. 이 둘을 한 도구로 억지로 다루면 반드시 어딘가 뒤틀린다. 흔한 사고가 게시글 목록 같은 서버 데이터를 전역 저장소에 통째로 복사해 두고 손으로 동기화하려다, 서버와 화면 값이 슬금슬금 어긋나는 것이다. 아래 도구들은 사실 이 두 부류에 나뉘어 속한다.
Context API는 값을 내려주는 통로다. 리액트에 내장돼 있어 따로 설치할 게 없다. 엄밀히는 상태관리 도구라기보다, 값을 여러 단계 아래로 직접 꽂아주는 배선(주입) 장치다. 테마, 로그인한 사용자, 언어 설정처럼 거의 안 바뀌면서 앱 전체가 봐야 하는 값에 적합하다. 함정은 갱신 빈도다. Context 값이 바뀌면 그 값을 구독하는 컴포넌트가 전부 다시 그려진다(리렌더). 자주 바뀌는 값을 여기 넣으면 화면 절반이 매번 다시 그려진다. 통로로는 훌륭하지만 자주 바뀌는 값의 저장소로는 부적합하다. 로그인 정보를 Context로 내려두면 깊이 박힌 컴포넌트도 props를 층층이 넘겨받는 사슬 없이 바로 꺼내 쓴다.
const ThemeContext = createContext('light')
function App() {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider value={theme}>
<Page />
</ThemeContext.Provider>
)
}
function Page() {
const theme = useContext(ThemeContext)
return <div className={theme}>본문</div>
}
Redux는 예측 가능성을 보일러플레이트로 산다. 모든 상태 변화를 액션(action, 무슨 일이 일어났는지 적은 객체)이라는 명시적 사건으로만 일으키고, 리듀서(reducer, 사건을 받아 새 상태를 돌려주는 순수 함수)가 그 사건을 처리한다. 상태가 언제 왜 바뀌었는지 전부 기록으로 남아, 과거 상태로 되감는 시간여행 디버깅까지 된다. 대규모 팀, 복잡한 상태 흐름, 엄격한 추적이 필요한 앱에서 강하다. 대가는 코드량이다. 예전 Redux는 액션 타입, 액션 생성자, 리듀서를 파일마다 따로 쓰느라 악명이 높았다. 요즘 표준인 Redux Toolkit(RTK)은 createSlice 하나로 그 군더더기를 대부분 걷어냈다. 그래도 아래 Zustand 같은 경량 도구보다는 개념이 무겁다. 규모가 작은 앱에 Redux를 얹으면, 얻는 예측 가능성보다 치르는 코드값이 더 크다.
Zustand는 훅 하나로 끝나는 전역 저장소. store를 만들고 컴포넌트에서 훅처럼 꺼내 쓴다. Provider로 앱을 감쌀 필요도 없고, 액션이니 리듀서니 하는 격식도 없다. 필요한 조각만 골라 구독하면 그 조각이 바뀔 때만 리렌더된다. 가볍고 배우기 쉬워서 중소 규모 클라이언트 상태에는 요즘 가장 무난한 선택이다.
import { create } from 'zustand'
const useCounter = create(set => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 }))
}))
function Button() {
const increment = useCounter(s => s.increment)
return <button onClick={increment}>+</button>
}
Recoil과 Jotai는 상태를 잘게 쪼갠다. 이 둘은 원자(atom, 최소 상태 단위)를 여러 개 두고, 컴포넌트가 필요한 원자만 골라 구독하는 방식이다. 큰 저장소 하나가 아니라 작은 상태 조각이 흩어져 있는 그림이다. 여러 원자를 조합해 파생 값(derived, 계산된 상태)을 만드는 것도 자연스럽다. 상태 조각 사이 의존 관계가 복잡하게 얽힌 앱에서 빛난다. Recoil은 메타(옛 페이스북)가 시작했지만 관리가 뜸해졌고, 같은 원자 발상을 더 가볍게 구현한 Jotai가 실무에서 더 자주 보인다. 다만 원자가 수십 개로 흩어지면 어떤 원자가 어디서 쓰이는지 추적하기 어려워지는 반대급부도 있다.
React Query는 서버 상태 전문가다. 정확한 이름은 TanStack Query다. 이건 전역 저장소가 아니라 서버 데이터 캐시 관리자다. 요청을 캐시하고, 로딩과 에러 상태를 알아서 만들어 주고, 데이터가 오래되면(stale) 자동으로 다시 받아오고, 같은 데이터를 여러 컴포넌트가 요청해도 네트워크는 한 번만 탄다. 서버 상태를 useState와 useEffect로 직접 주무르던 사람이 이걸 처음 쓰면 코드 절반이 사라진다. 로딩 스피너를 켜고 끄는 처리, 실패 시 재요청 로직을 손으로 짜던 자리가 옵션 몇 줄로 바뀐다.
import { useQuery } from '@tanstack/react-query'
function PostList() {
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json())
})
if (isLoading) return <p>로딩 중</p>
if (error) return <p>에러</p>
return <ul>{data.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
언제 무엇을 쓰나. 위 도구들을 용도로 줄 세우면 이렇게 정리된다.
서버 데이터(목록, 상세, 프로필): React Query. 다른 무엇보다 먼저 이걸 떠올린다.
거의 안 바뀌는 전역 값(테마, 로그인 사용자): Context API. 내장이라 이걸로 충분하다.
중소 규모 클라이언트 상태(모달, 필터, 장바구니): Zustand. 가볍고 빠르다.
대규모 복잡 상태에 엄격한 추적이 필요: Redux Toolkit. 무겁지만 예측 가능하다.
잘게 쪼갠 상태와 파생 값이 많은 앱: Jotai. 원자 단위로 다룬다.
핵심은 도구를 겹겹이 쌓지 않는 것이다. 서버 상태는 React Query에 맡기고, 남는 소량의 클라이언트 상태만 Zustand나 Context로 다루면 대부분의 앱은 충분하다. Redux부터 깔고 시작하던 시절의 습관으로 작은 앱에까지 큰 저장소를 얹을 이유는 없다. 상태의 성격을 먼저 가르면, 도구는 거의 저절로 정해진다. 도구는 문제를 따라오는 것이지, 도구를 먼저 정해 놓고 문제를 거기 맞추는 순간 상태관리는 꼬이기 시작한다.