⚛️ 08. React Query와 UI 상태 관리(Zustand / Jotai)의 결합 전략
📋 개요
React Query를 전역 상태 관리자처럼 쓰는 것에 대한 오해를 바로잡고, Server State와 Client State를 완벽하게 분리하는 아키텍처를 설계합니다.
📋 목차
- ☕️ 영철이의 고민: "데이터는 무조건 상태 도구 안에 있어야 안심이 되는데요?"
- 🤔 왜 알아야 하는가: 두 개의 주머니
- 1. 최악의 안티 패턴: 서버 데이터를 클라이언트 스토어에 "복사"하기
- 2. 해결책: 각자의 영역을 분리하고 조합(Composition)하라
- 📝 배운 내용 점검하기 (Quiz)
"영철 님, 게시글 목록을
useQuery로 받아와 놓고 다시 Zustand의setPosts스토어에 꾸역꾸역 집어넣는 이유가 뭔가요?"
☕️ 영철이의 고민: "데이터는 무조건 상태 도구 안에 있어야 안심이 되는데요?"
(목요일 오후, Zustand 스토어 코드를 수백 줄째 짜고 있는 영철)
🐣 영철: 아 리드 님, 메인 화면 지시대로 React Query 도입해서 캐싱 잘 되게 만들었거든요? 근데 가져온 데이터를 Zustand 스토어의 posts 전역 상태에 다시 담아주고 있어요. 그래야 다른 깊은 컴포넌트에서도 쉽게 꺼내 쓰고, 모달창 떴을 때 필터 조건 적용하기도 편하잖아요.
🦁 영호: 영철 님, 그건 최악의 안티 패턴 (Anti-Pattern) 입니다. 방금 그 코드는 React Query가 뼈 빠지게 뒤에서 뺑뺑이 돌려가며 최신화해 놓은 데이터(Server State)의 목을 졸라서, 업데이트가 안되는 과거의 데이터(Outdated Client State)로 고립시켜 버리는 짓이에요.
🐣 영철: 헉! 전 데이터는 무조건 전역 스토어 한 곳에 모여있어야 프론트엔드 아키텍처라고 생각했거든요... 그럼 Zustand나 Jotai 같은 애들은 아예 쓰면 안 되는 건가요?
🤔 왜 알아야 하는가: 두 개의 주머니
Redux가 세상을 지배하던 시절(2018년 이전), 우리는 브라우저 안의 모든 데이터(서버에서 온 유저 정보든, 클라이언트에서 토글한 다크모드 값이든)를 거대한 하나의 스토어 주머니에 다 때려 넣었습니다.
하지만 React Query가 등장하면서 프론트엔드의 상태 분류표가 재편되었습니다.
- Server State (서버 상태): 서버 DB에 저장되어 있고, 여러 사용자가 언제든 바꿀 수 있으며, 우리는 "잠시 그 복사본 중 최신 상태를 화면에 그리기 위해 빌려온 상태". 👉 오직 React Query의 QueryCache에만 존재해야 합니다!
- 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) 때문입니다.
- 만약 React Query가 백그라운드 리패치(
isFetching)를 통해 데이터를 조용히 업데이트했다 치면, 다시useEffect가 발동하며 스토어를 엎어치기 합니다. 불필요한 연쇄 렌더링 폭발이죠. - 반대로 다른 컴포넌트에서
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의 초기값 파라미터로 넘겨주고, 해당useQuery엔staleTime: Infinity를 설정하는 패턴이 유일한 권장사항입니다. - 오답 피드백: "영철 님, B번처럼
useEffect로 뿌려대기 시작하면 상태 동기화가 엄청나게 꼬이기 시작해요. 폼 데이터(내가 타자 치는 중인 Client State)와 내 프로필 원본(Server State)은 컴포넌트를 부모-자식으로 쪼개서 분리해내는 것이 React의 생존법입니다." - 📌 핵심 기억법: 폼(Form) 초깃값 용도라면 자식으로 넘기고
staleTime: Infinity!