내 프로젝트 중 하나는 Zustand를 쓰고 하나는 Redux를 쓴다. 같은 사람이 왜 다르게 골랐나
내 서버에 돌아가는 React 프로젝트들 store 폴더를 하나씩 열어봤다. 상태 관리가 프로젝트마다 다르다.
K-Geul (교육 서비스) -> React Query + Zustand 코인 (실시간 시세) -> React Query + Redux Toolkit 블로그 -> React Query + Zustand 포트폴리오 -> 아무것도 안 씀 (useState)
같은 사람이 만든 건데 왜 다를까. 예전엔 나도 "그때그때 손에 잡히는 걸 썼나 보다" 싶었는데, 다시 보니 이유가 있었다. 이 글에서 그 기준을 정리해본다.
먼저 나눠야 할 건 라이브러리가 아니라 상태의 성격이다
상태 관리 라이브러리를 고르기 전에 내가 다루는 상태가 어떤 종류인지부터 나눠야 한다. 이걸 안 나누면 Redux 하나에 다 밀어넣게 된다. 예전에 내가 그랬다.
서버 상태 서버가 원본을 갖고 있고 내 화면은 사본이다
예: 글 목록, 사용자 정보, 코인 목록
특징: 낡을 수 있다. 다시 받아와야 한다. 로딩/에러가 있다.
클라이언트 상태 브라우저 안에서 태어나고 죽는다
예: 모달 열림, 다크모드, 로그인 토큰, 선택한 필터
특징: 서버는 모른다. 로딩이라는 개념이 없다.이 둘은 필요한 게 완전히 다르다. 서버 상태는 캐싱, 재요청, 무효화, 중복 요청 제거가 필요하고, 클라이언트 상태는 그냥 값을 넣고 빼기만 하면 된다.
그런데 Redux에 서버 상태를 넣으면 캐싱과 재요청을 전부 내가 손으로 짜야 한다. createAsyncThunk에 loading, error, data를 매번 세 개씩 만들어본 사람은 안다. 그 코드는 슬라이스마다 똑같이 복사된다.
그래서 내 기본값은 이렇게 됐다. 서버 상태는 React Query 에 맡기고, 남는 클라이언트 상태만 놓고 어떤 도구를 쓸지 고른다.
서버 상태 -> React Query (TanStack Query) 클라이언트 상태 -> Zustand 또는 useState
K-Geul에서 Redux를 안 쓴 이유
레거시를 React로 갈아엎을 때 Redux를 쓸지 말지 고민했다. 안 썼다. 이유는 그 서비스에 진짜 전역이어야 하는 상태가 몇 개 안 됐기 때문이다.
K-Geul에서 전역이 필요했던 것 - 로그인 사용자 정보 - 선택된 언어 (온보딩에서 정한 값) - 학습 진행 중 여부 끝이었다. 나머지는 전부 화면 안에서 끝나거나 서버 데이터였다.
이 세 개를 위해 Redux를 깔면 store, slice, action, reducer, provider, selector, dispatch가 따라온다. 팀이 그 규칙을 다 알아야 하고, 상태 하나 추가할 때마다 파일 여러 개를 건드려야 한다. 당시 인원과 일정을 생각하면 그 비용이 얻는 것보다 컸다.
Zustand는 이게 전부다.
export const useAuthStore = create((set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
}));
// 쓸 때
const user = useAuthStore((s) => s.user);Provider도 없고 파일도 하나다. 그리고 셀렉터로 구독 단위를 잡아주면 그 값이 바뀔 때만 리렌더된다. 작은 전역 몇 개를 다루기에는 이게 딱 맞았다.
그런데 코인 프로젝트에서는 Redux Toolkit을 썼다
여기서 뒤집혔다. 김치프리미엄 서비스는 Redux Toolkit을 쓴다. Zustand로 시작했다가 옮긴 게 아니라 처음부터 그렇게 골랐다.
이유는 상태의 모양이 완전히 달랐기 때문이다. 두 프로젝트의 전역 상태를 나란히 놓고 보면 바로 보인다.
K-Geul의 전역 상태 코인의 전역 상태
- 로그인 사용자 - 코인 200개의 원화 시세
- 선택 언어 - 코인 200개의 달러 시세
- 학습 진행 여부 - 환율
- 모의투자 보유 내역
- 가격 알림 설정
값 몇 개 초당 수백 번 갱신되는 큰 객체코인 쪽은 상태가 크고, 자주 바뀌고, 갱신 경로가 여러 개다. WebSocket으로도 들어오고 REST 초기 로딩으로도 들어오고 사용자 조작으로도 바뀐다. 이런 건 "누가 언제 이 값을 바꿨는지" 추적이 안 되면 디버깅이 지옥이 된다.
Redux DevTools로 액션이 시간순으로 쌓이는 걸 보는 게 여기서는 진짜 도움이 됐다. 그리고 갱신 경로가 여러 개라는 건 리듀서를 명시적으로 나눠 쓰는 게 유리하다는 뜻이기도 하다.
syncKRWPrice: 전체 교체 (초기 로딩) syncKRWPrice2: 한 종목만 교체 (초기 구현, 지금은 안 씀) syncKRWPriceBatch: 여러 종목 한 번에 교체 (rAF 배치) initBinanceSuccess: 소스를 바이낸스로 바꾸면서 통째로 교체
같은 coins 객체를 건드리는 경로가 네 개다. Zustand로도 못 할 건 없지만, 이 정도로 경로가 많으면 액션 이름이 붙어 있는 쪽이 반년 뒤의 나에게 친절하다.
참고로 여기서도 서버 상태는 React Query가 맡는다. Redux는 실시간 스트림으로 들어오는 것만 갖고 있다. REST로 한 번 받아오면 끝인 뉴스 목록, 코인 메타데이터 같은 건 React Query다.
그래서 기준이 뭐냐
정리하면 이렇게 갈렸다.
Zustand가 낫다 Redux Toolkit이 낫다 전역 상태 개수 적다 (5개 안팎) 많다 갱신 빈도 사용자 조작 정도 초당 수십~수백 번 같은 상태 갱신 경로 한두 개 여러 개 디버깅 콘솔로 충분 타임라인이 필요 팀 규모 작다 크거나 자주 바뀐다 보일러플레이트 거의 없음 있지만 규칙이 명확
둘 다 안 쓰는 경우도 있다. 지금 이 포트폴리오 사이트는 상태 관리 라이브러리가 하나도 없다. 테마(다크모드)랑 열린 모달 정도가 전부인데, 그건 useState와 URL로 충분했다. 프로젝트 모달은 아예 상태로 안 들고 있고 /projects/:id 라우트에서 파생시킨다.
// 모달 열림 여부를 state로 들지 않는다. URL이 곧 상태다.
const selectedProject =
section === 'projects' && Number.isInteger(projectIndex)
? projects[projectIndex] ?? null
: null;이러면 뒤로가기로 모달이 닫히고, 링크를 복사하면 그 프로젝트가 열린 채로 공유된다. 전역 상태로 만들었으면 둘 다 직접 짜야 했을 것이다.
한 가지 후회
K-Geul에서 React Query를 도입할 때 queryKey 규칙을 먼저 정하지 않은 것이 아쉽다. 처음엔 각자 편한 대로 문자열을 넣었는데, 나중에 무효화를 걸려고 보니 어떤 키가 어디에 쓰이는지 모아보기가 어려웠다.
// 이렇게 시작했다가
useQuery(['words', level], ...)
useQuery(['wordList', level], ...) // 같은 걸 다르게 부름
// 키를 한 곳에 모아두는 게 나았다
export const queryKeys = {
words: (level) => ['words', level],
wordDetail: (id) => ['words', 'detail', id],
};지금 코인 프로젝트에도 같은 문제가 남아 있다. store 폴더를 열어보면 슬라이스에 이런 게 같이 있다.
syncKRWPrice 전체 교체 syncKRWPrice2 한 종목만 교체 ← 지금은 아무도 안 부름 syncKRWPriceBatch 여러 종목 한 번에
syncKRWPrice2는 배치 처리로 바꾸면서 쓰지 않게 됐다. 이 글을 다시 고치는 지금 확인해봐도 coinKrwPriceSlice.ts 에 정의와 export 만 남아 있고 부르는 곳은 한 군데도 없다. 지우려면 참조를 전부 훑어야 해서 슬라이스를 정리할 때 같이 걷어내려고 남겨뒀는데, 그 정리를 아직 안 했다. 이름 뒤에 2가 붙은 함수가 남아 있는 코드베이스가 되어버린 거다.
Zustand냐 Redux냐는 나중에도 바꿀 수 있다. 근데 이렇게 쌓인 건 잘 안 지워진다.