[React] React.js 강좌 27. React Query와 Zustand를 함께 사용하는 구조 설계
프로젝트가 커질수록 “상태”는 점점 복잡해집니다.
서버에서 가져온 데이터도 있고, 클라이언트 내부에서만 쓰는 상태도 있죠.
문제는 이 둘을 한데 섞어버리면 유지보수가 어려워진다는 겁니다.
그래서 저는 요즘 대부분의 프로젝트에서
React Query + Zustand 조합을 기본 구조로 사용합니다.
서버 상태는 React Query가, 클라이언트 상태는 Zustand가 관리하게 분리하는 방식입니다.
두 라이브러리는 서로 간섭하지 않으면서, 필요한 시점에 자연스럽게 연결됩니다.
React Query는 “서버 상태 관리자”
React Query는 서버와 관련된 모든 데이터를 담당합니다.
API 요청, 캐싱, 리패치, 에러 처리 같은 서버 중심의 상태를 자동으로 관리해주죠.
예를 들어, 게시글 목록을 불러온다고 가정해봅시다.
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: async () => {
const { data } = await axios.get('/api/posts');
return data;
},
});
}
이렇게 하면 usePosts를 호출하는 컴포넌트에서는
별도의 로딩/에러 처리를 직접 하지 않아도 됩니다.
function PostList() {
const { data, isLoading, error } = usePosts();
if (isLoading) return <div>불러오는 중...</div>;
if (error) return <div>문제가 발생했습니다.</div>;
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
React Query는 서버와의 비동기 통신을 완전히 추상화합니다.
개발자는 데이터를 어떻게 가져올지보다,
“어떻게 보여줄지”에 집중할 수 있습니다.
Zustand는 “클라이언트 상태 관리자”
Zustand는 사용자 인터랙션, UI 상태, 임시 데이터 등
서버와 관계없는 상태를 관리하는 데 적합합니다.
예를 들어, 현재 선택된 게시글 ID를 Zustand로 관리한다고 하면:
import { create } from 'zustand';
const useUIStore = create((set) => ({
selectedPostId: null,
selectPost: (id) => set({ selectedPostId: id }),
}));
이제 PostList에서 게시글을 클릭할 때 Zustand 상태를 업데이트할 수 있습니다.
function PostList() {
const { data } = usePosts();
const { selectPost } = useUIStore();
return (
<ul>
{data.map((post) => (
<li key={post.id} onClick={() => selectPost(post.id)}>
{post.title}
</li>
))}
</ul>
);
}
이 상태는 서버와 무관하기 때문에
React Query의 캐싱이나 리패치 로직과도 독립적으로 움직입니다.
결과적으로 React Query와 Zustand가 각각의 역할을 명확히 나누게 됩니다.
두 상태 관리의 경계 정하기
이 조합을 효율적으로 쓰기 위해서는 경계를 명확히 정하는 것이 중요합니다.
제가 실무에서 사용하는 기준은 다음과 같습니다.
- 서버에서 가져온 데이터 → React Query
- 사용자 인터랙션, UI 관련 상태 → Zustand
- 캐싱이 필요하지 않은 일시적 값 → useState
예를 들어, “게시글 리스트”는 React Query가 관리하고,
“현재 선택된 게시글”은 Zustand가 담당하며,
“모달 열림 여부” 같은 임시 상태는 useState로 처리합니다.
이렇게 세 영역을 분리하면
데이터 흐름이 훨씬 명확해지고,
불필요한 리렌더링을 줄일 수 있습니다.
React Query와 Zustand의 연동 예시
가끔은 두 시스템이 맞물려야 하는 경우도 있습니다.
예를 들어, 게시글 상세 페이지를 Zustand로 관리된 selectedPostId를 기반으로 불러오려면,
React Query의 queryKey를 Zustand 값과 연결해주면 됩니다.
function PostDetail() {
const { selectedPostId } = useUIStore();
const { data, isLoading } = useQuery({
queryKey: ['post', selectedPostId],
queryFn: async () => {
const { data } = await axios.get(`/api/posts/${selectedPostId}`);
return data;
},
enabled: !!selectedPostId, // 선택된 ID가 있을 때만 실행
});
if (!selectedPostId) return <div>게시글을 선택해주세요.</div>;
if (isLoading) return <div>불러오는 중...</div>;
return (
<div>
<h2>{data.title}</h2>
<p>{data.content}</p>
</div>
);
}
이 패턴을 적용하면
서버 데이터와 클라이언트 상태가 자연스럽게 맞물려 돌아갑니다.
실무에서의 적용 경험
Zustand와 React Query를 함께 사용하기 시작한 건
대시보드형 SaaS 프로젝트에서였습니다.
여러 API를 동시에 호출하면서도
UI 상태를 각각 독립적으로 관리해야 했죠.
처음엔 Redux Toolkit + React Query 조합을 썼지만
Redux 코드가 너무 복잡해졌습니다.
반면 Zustand로 바꾼 후에는 전역 상태 로직이 절반 이상 줄었습니다.
코드의 의존 관계가 단순해지고,
협업 중에도 “이 상태 어디서 바꾸는 거야?” 같은 질문이 사라졌습니다.
특히 React Query의 캐시가 자동으로 갱신되기 때문에,
Zustand는 순수하게 UI 상태만 담당하도록 설계할 수 있었죠.
정리하며
React Query와 Zustand는 서로 경쟁하는 라이브러리가 아닙니다.
각자의 역할이 다를 뿐이고, 오히려 함께 쓸 때 가장 빛을 발합니다.
- 서버 상태 → React Query
- 클라이언트 상태 → Zustand
- 임시 상태 → useState
이 구조를 명확히 지키면
React 앱은 훨씬 유연하고 유지보수하기 쉬워집니다.
다음 글에서는 이 조합 위에 “성능 최적화”를 더해보겠습니다.
React.memo, useCallback, useMemo를 활용해
리렌더링을 최소화하고 쾌적한 사용자 경험을 만드는 방법을 다뤄보겠습니다.