⚛️ 08. React Query와 UI 상태 관리(Zustand / Jotai)의 결합 전략

2026년 3월 4일 수정됨

📋 개요

React Query를 전역 상태 관리자처럼 쓰는 것에 대한 오해를 바로잡고, Server State와 Client State를 완벽하게 분리하는 아키텍처를 설계합니다.

📋 목차

"영철 님, 게시글 목록을 useQuery로 받아와 놓고 다시 Zustand의 setPosts 스토어에 꾸역꾸역 집어넣는 이유가 뭔가요?"

☕️ 영철이의 고민: "데이터는 무조건 상태 도구 안에 있어야 안심이 되는데요?"

(목요일 오후, Zustand 스토어 코드를 수백 줄째 짜고 있는 영철)

🐣 영철: 아 리드 님, 메인 화면 지시대로 React Query 도입해서 캐싱 잘 되게 만들었거든요? 근데 가져온 데이터를 Zustand 스토어의 posts 전역 상태에 다시 담아주고 있어요. 그래야 다른 깊은 컴포넌트에서도 쉽게 꺼내 쓰고, 모달창 떴을 때 필터 조건 적용하기도 편하잖아요.

🦁 영호: 영철 님, 그건 최악의 안티 패턴 (Anti-Pattern) 입니다. 방금 그 코드는 React Query가 뼈 빠지게 뒤에서 뺑뺑이 돌려가며 최신화해 놓은 데이터(Server State)의 목을 졸라서, 업데이트가 안되는 과거의 데이터(Outdated Client State)로 고립시켜 버리는 짓이에요.

🐣 영철: 헉! 전 데이터는 무조건 전역 스토어 한 곳에 모여있어야 프론트엔드 아키텍처라고 생각했거든요... 그럼 Zustand나 Jotai 같은 애들은 아예 쓰면 안 되는 건가요?


🤔 왜 알아야 하는가: 두 개의 주머니

Redux가 세상을 지배하던 시절(2018년 이전), 우리는 브라우저 안의 모든 데이터(서버에서 온 유저 정보든, 클라이언트에서 토글한 다크모드 값이든)를 거대한 하나의 스토어 주머니에 다 때려 넣었습니다.

하지만 React Query가 등장하면서 프론트엔드의 상태 분류표가 재편되었습니다.

  1. Server State (서버 상태): 서버 DB에 저장되어 있고, 여러 사용자가 언제든 바꿀 수 있으며, 우리는 "잠시 그 복사본 중 최신 상태를 화면에 그리기 위해 빌려온 상태". 👉 오직 React Query의 QueryCache에만 존재해야 합니다!
  2. Client State (클라이언트 상태): 다크 모드 토글, 현재 띄워진 모달 창 ID, 검색창에 입력 중인 텍스트 필터 값 등 내 프론트엔드 브라우저 화면 안에서만 유효한 상태. 👉 Zustand나 Jotai, Context API 의 역할입니다.

이 두 가지 주머니를 명확히 쪼개고, Client State를 요리조리 바꿔서 Server State를 호출하는 열쇠(Query Key) 로 써먹는 것이 5년 차의 구조 설계법입니다.


1. 최악의 안티 패턴: 서버 데이터를 클라이언트 스토어에 "복사"하기

영철이가 짜던 끔찍한 코드를 직접 봅시다.

// ❌ 영철이의 데이터 복사본(Sync) 감옥
const usePostStore = create((set) => ({
  posts: [], // 이걸 왜 여기 만듭니까!
  setPosts: (newPosts) => set({ posts: newPosts }),
}));
 
function Dashboard() {
  const setPosts = usePostStore((state) => state.setPosts);
 
  // 1. 쿼리로 최신 데이터 받아옴
  const { data } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
 
  // 2. 받아오자마자 useEffect로 전역 스토어에 복사 💣 💣 💣
  useEffect(() => {
    if (data) setPosts(data);
  }, [data, setPosts]);
 
  return <div>...</div>
}

이 코드가 안 좋은 이유는 동기화 파괴(State Sync Breaking) 때문입니다.

  1. 만약 React Query가 백그라운드 리패치(isFetching)를 통해 데이터를 조용히 업데이트했다 치면, 다시 useEffect가 발동하며 스토어를 엎어치기 합니다. 불필요한 연쇄 렌더링 폭발이죠.
  2. 반대로 다른 컴포넌트에서 queryClient.setQueryData로 낙관적 캐시를 만졌을 때, zustand 스토어가 그걸 제때 복사하지 못하면 두 화면이 완전 다른 데이터를 보여주는 최악의 렌더 버그가 터집니다.

2. 해결책: 각자의 영역을 분리하고 조합(Composition)하라

그렇다면 Zustand/Jotai는 어디에 써야 할까요?
바로 "Query를 호출하기 위한 조건(매개변수)" 을 담는 데 씁니다.

import { create } from 'zustand';
 
// ✅ 영호 리드의 깔끔한 스토어 (오직 Client State만 관리)
// 이 스토어에는 절대 서버 데이터 원본(게시글 배열)이 들어가지 않습니다.
type PostFilterState = {
  activeTab: 'all' | 'popular' | 'recent';
  keyword: string;
  setTab: (tab: 'all' | 'popular' | 'recent') => void;
  setKeyword: (kw: string) => void;
};
 
export const usePostFilterStore = create<PostFilterState>((set) => ({
  activeTab: 'all', // 난 클라이언트 상태야!
  keyword: '',      // 난 검색창 입력값이야!
  setTab: (tab) => set({ activeTab: tab }),
  setKeyword: (kw) => set({ keyword: kw }),
}));

이제 화면 단을 볼까요? 상태 두 개가 기가 막히게 조화됩니다.

import { useQuery } from '@tanstack/react-query';
import { usePostFilterStore } from './store';
 
function PostList() {
  // 1. 클라이언트 상태 도구(Zustand)로부터 현재 '필터 조건'을 꺼내온다.
  const activeTab = usePostFilterStore(state => state.activeTab);
  const keyword = usePostFilterStore(state => state.keyword);
 
  // 2. 그 상태를 그대로 Query Key로 찔러 넣는다. (의존성 연결 완료!)
  const { data: posts, isLoading } = useQuery({
    // activeTab이나 keyword가 Zustand에서 바뀌면?
    // 여기서 자동으로 감지하고 백그라운드 Fetch 폭발!
    queryKey: ['posts', { tab: activeTab, search: keyword }],
    queryFn: () => fetchPostsByFilter(activeTab, keyword),
  });
 
  if (isLoading) return <Spinner />;
 
  return (
    <ul>
      {posts?.map(post => <li key={post.id}>{post.title}</li>)}
    </ul>
  );
}

🔥 시니어의 조언:
"데이터를 전역에서 꺼내 쓰고 싶어서 zustand 쓴다고요? React Query 자체가 가장 빠르고 위대한 전역 비동기 상태 스토어 입니다."
다른 컴포넌트에서 똑같은 useQuery({ queryKey: ['posts'] }) 를 호출해보세요. 네트워크 요청 없이(staleTime이 살아있다면) 즉시 0.001초 만에 캐시 데이터만 똑 빼줍니다. 굳이 빙글빙글 돌아서 Zustand에 담을 필요가 0%라는 거죠!


🐣 영철이의 퇴근 일기

아... 내가 Redux 쓰던 버릇을 못 고쳐서 자꾸 서버 데이터를 Zustand에 집어넣으려고 아등바등했던 거구나.
React Query 캐시통 자체가 '거대한 전역 상태 저장소'라는 개념을 깨달으니까 머리가 진짜 맑아졌다.

💡 오늘의 교훈: "서버 데이터(결과물)는 React Query에, 클라이언트 조작 데이터(필터 조건)는 Zustand에! Zustand의 상태를 React Query의 Key로 건네주면 알아서 완벽하게 동기화된다!"

생각해 보니까 내 방 서랍(Zustand)에 마트(서버)에서 사 온 우유(게시글 데이터)를 일일이 옮겨 담으면서 유통기한(staleTime) 체크하던 내 모습이 너무 바보 같았다 ㅋㅋㅋ. 그냥 마트 창고(QueryCache)에서 바로 쏙쏙 빼먹으면 되는데!! 오늘 집 가면 스토어 다이어트부터 쫙 해야지.


📝 배운 내용 점검하기 (Quiz)

Q. 영철이는 다음과 같이 "서버에서 특정 데이터를 처음에 한 번 페칭하여(Default Value), 사용자가 폼에서 자유롭게 수정할 수 있도록" 만들고 싶어 합니다.
이때, Zustand를 사용하지 않고도 React Query와 로컬 상태(useState)만 활용하여 초기값을 안전하게 렌더링하는 가장 정석적인 방법은 무엇입니까?

const { data: userProfile } = useQuery({ queryKey:['profile'], queryFn: ... })
// 영철은 userProfile을 폼의 초깃값으로 넣고 싶다.
  • A) 렌더링 스코프 안에서 data가 있으면 무조건 setState(data)를 덮어씌운다.
  • B) useEffect 를 사용하여 data 가 도착했을 때 단 한 번만 setState 한다.
  • C) initialData나 데이터가 로드된 이후 자식 컴포넌트로(Props) 넘기면서 자식 내에서 상태를 격리하고 staleTime: Infinity를 걸어 불필요한 백그라운드 리패칭을 막는다.

정답: C

💡 상세 해설:

  • 원리 설명: TkDodo 아티클 #10에 따르면, 만약 서버 데이터가 유저의 수정을 위한 "임시 초깃값" 용도로 쓰인다면, 백그라운드 업데이트가 절대 일어나선 안 됩니다. 백그라운드 업데이트가 발생해 뷰가 다시 덮어씌워지면 유저가 타이핑 중이던 폼 내용이 리셋될 테니까요! 자식 컴포넌트로 격리하여 useState의 초기값 파라미터로 넘겨주고, 해당 useQuerystaleTime: Infinity를 설정하는 패턴이 유일한 권장사항입니다.
  • 오답 피드백: "영철 님, B번처럼 useEffect로 뿌려대기 시작하면 상태 동기화가 엄청나게 꼬이기 시작해요. 폼 데이터(내가 타자 치는 중인 Client State)와 내 프로필 원본(Server State)은 컴포넌트를 부모-자식으로 쪼개서 분리해내는 것이 React의 생존법입니다."
  • 📌 핵심 기억법: 폼(Form) 초깃값 용도라면 자식으로 넘기고 staleTime: Infinity!