내 프로젝트 중 하나는 Zustand를 쓰고 하나는 Redux를 쓴다 — 같은 사람이 왜 다르게 골랐나
Redux Toolkit, Zustand, React Query를 프로젝트 성격에 따라 다르게 고른 기준
#Redux #zustand #React Query #상태관리 #React
내 프로젝트 중 하나는 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 (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는 배치 처리로 바꾸면서 안 쓰게 됐는데 지우지 않고 그냥 뒀다. 지울 때 뭐가 딸려 나올지 확인하기가 귀찮았다. 이름 뒤에 2가 붙은 함수가 남아 있는 코드베이스 가 되어버린 거다. Zustand냐 Redux냐는 나중에도 바꿀 수 있다. 근데 이렇게 쌓인 건 잘 안 지워진다.