☁️ 04. Zustand + React Query 상생 아키텍처: "왜 API 응답을 스토어에 넣으시나요?"

📋 개요

클라이언트 상태와 서버 상태를 명확히 분리하고, Zustand와 React Query를 함께 사용하는 실무 아키텍처

🎯 이 섹션을 읽고 나면:

  • 서버 상태(Server State)와 클라이언트 상태(Client State)의 본질적 차이를 구분할 수 있다.
  • API 페칭 결과를 Zustand 스토어 내부가 아닌 React Query로 위임해야 하는 이유(SoT 유지)를 알 수 있다.
  • 두 라이브러리를 결합하여 렌더링에 필요한 파생 상태를 안전하게 추출하는 설계 패턴을 적용할 수 있다.

📋 목차


📌 이 문서를 읽기 전에

⏱️ 예상 읽기 시간: 15분 / 핵심 파트: 8분

🗺️ 이 문서의 흐름
Redux 시절의 안 좋은 습관 진단 → 서버 상태 vs 클라이언트 상태 분리 → Zustand와 React Query 상호 참조 전략

🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'

  • 🐣 영철 ( 신입 ): "리드 님! 커뮤니티 게시글 목록 API에서 받아온 데이터를 전부 useAppStoreposts 배열에 저장했어요! 이제 컴포넌트 어디서든 스토어만 구독하면 게시글 데이터를 꺼내 쓸 수 있습니다. 완전 짱이죠?"
  • 🦁 영호 ( 리드 ): "영철 님... 프론트엔드 아키텍처에서 가장 피해야 할 안티패턴이 바로 '서버 응답 데이터를 전역 상태에 캐싱하려는 시도'입니다. 그 데이터가 서버에서 수정되면 영철 님의 스토어는 어떻게 그걸 알고 동기화하나요? 서버 상태는 React Query에게, 클라이언트 상태는 Zustand에게 맡겨야 합니다."

🤔 왜 알아야 하는가

과거 Redux 전성시대에는 백엔드 API에서 받아온 데이터를 액션(Action)으로 디스패치(Dispatch)하여 단일 스토어 안에 저장하는 것이 국룰이었습니다. (Thunk, Saga 등)

하지만 이 패턴은 치명적인 한계를 가집니다. 스토어에 담긴 데이터는 그 순간 과거의 낡은 스냅샷(Stale Data) 이 되어버립니다. 다른 유저가 게시글을 지웠는지, 좋아요가 올랐는지 Zustand는 스스로 알아낼 재간이 없습니다.

따라서 5년 차 실무 환경에서는 데이터의 주인이 누구인가에 따라 상태를 철저히 분리합니다.

  1. 서버 상태 (Server State): 데이터베이스가 소유권자이며 비동기적입니다. (게시글 목록, 사용자 프로필) -> React Query (TanStack Query)
  2. 클라이언트 상태 (Client State): 브라우저(사용자)가 소유권자이며 동기적입니다. (다크모드, 폼 입력 중인 텍스트, 아코디언 열림 여부) -> Zustand

이 둘의 책임 경계를 명확히 긋지 않으면, 소위 "단일 진실 공급원(Single Source of Truth)"이 파괴되어 화면마다 데이터가 따로 노는 대참사가 발생합니다.


🏗️ 1. 영철이의 안티패턴 탈출기

영철이가 처음 작성한 안 좋은 코드를 먼저 보겠습니다.

❌ 서버 상태를 Zustand에 우겨넣은 코드 (Redux 스타일)

// 🐣 영철: API 호출부터 데이터 저장까지 스토어가 다 해준다! 완벽해!
const useBoardStore = create((set) => ({
  posts: [],
  isLoading: false,
  fetchPosts: async () => {
    set({ isLoading: true });
    const response = await api.get('/posts');
    // 🚨 문제점: 이 순간 데이터는 서버와 단절된 '죽은' 캐시가 됩니다.
    set({ posts: response.data, isLoading: false }); 
  }
}));

위 코드는 다음과 같은 결함을 유발합니다.

  • isLoading 처리를 모든 비동기 함수마다 수동으로 해줘야 합니다.
  • 다른 브라우저 탭을 갔다 왔을 때(포커스 복귀) 최신 게시글 갱신이 불가능합니다(Stale/Cache 관리 부재).

💎 2. 진정한 역할 분담: 역할 분리 코드

서버 데이터는 철저히 React Query로 관리하고, 유저 브라우저에만 존재하는 UI 조작 상태만 Zustand가 가져가도록 설계를 바꿉니다.

// 1. 서버 상태는 React Query 담당
// 🦁 영호: 데이터 페칭, 캐싱, Stale 타임 관리는 무조건 Query에 맡깁니다.
export const usePostsQuery = () => {
  return useQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const { data } = await api.get('/posts');
      return data;
    },
    staleTime: 1000 * 60, // 1분간은 신선한 캐시로 간주
  });
};
 
// 2. 클라이언트 상태는 Zustand 담당
// 🦁 영호: 유저가 화면에서 조작한 '정렬 필터'나 '선택한 게시글 ID'만 여기에 넣어요.
const useBoardUIStore = create((set) => ({
  sortBy: 'latest', // 클라이언트 렌더링용 임시 뷰포인트
  setSortBy: (sortType) => set({ sortBy: sortType }),
  
  selectedPostId: null, // 모달을 띄우기 위한 임시 선택 값
  selectPost: (id) => set({ selectedPostId: id }),
}));

이렇게 하면 서버 데이터 원본은 Query의 자체 캐시 메모리에 안전하게 존재하며 언제든 자동 갱신됩니다.


🧩 3. 상태의 결합 (상호 참조)

그럼 API에서 받은 posts(서버 데이터)와 유저가 스토어에 지정한 sortBy = 'popular'(클라이언트 상태)는 어떻게 합쳐서 화면에 그릴까요?

절대 Zustand set() 안에 Query 응답을 밀어 넣지 않습니다. 파생 상태(Derived State) 를 컴포넌트 렌더링 시점에 계산하는 것이 정석입니다.

function BoardList() {
  // 1. 서버에서 원본 배열 읽어오기
  const { data: posts } = usePostsQuery(); 
  
  // 2. Zustand에서 유저의 현재 렌더링 뷰포인트 읽어오기
  const sortBy = useBoardUIStore((state) => state.sortBy);
  
  // 3. 렌더링 타이밍에 파생 상태 계산하기 (필요시 useMemo)
  const sortedPosts = useMemo(() => {
    if (!posts) return [];
    if (sortBy === 'popular') return [...posts].sort((a, b) => b.likes - a.likes);
    return posts;
  }, [posts, sortBy]);
 
  return (
    <ul>
      {sortedPosts.map(post => <li key={post.id}>{post.title}</li>)}
    </ul>
  );
}

🦁 영호의 꿀팁: "데이터가 흘러가는 방향을 한쪽으로만 유지하세요. React Query -> Component <- Zustand 로 컴포넌트가 둘의 데이터를 만나게 하는 깔때기 역할을 해야 합니다. 둘을 억지로 서로의 메모리 영역에 주입하려고 하면 싱크로 에러 납니다."


📝 마무리 퀴즈

Q1. 현대 프론트엔드 아키텍처에서 '서버 상태(Server State)'와 '클라이언트 상태(Client State)'를 나누는 가장 중요한 기준(데이터의 소유권)은 무엇인가요?

  • A) 데이터의 용량 크기
  • B) 데이터가 브라우저의 전원 여부에 따라 날아가는가의 유무
  • C) 데이터의 진정한 소유권이자 원본이 데이터베이스(백엔드)에 있는지, 브라우저 메모리에 있는지의 구분
  • D) 데이터가 REST API로 왔는지 GraphQL로 왔는지의 차이

정답: C

💡 상세 해설:

  • 원리 설명: 상태 관리의 진실의 원천(Source of Truth)을 어디에 두느냐의 문제입니다. 게시글, 프로필, 알림 리스트 모두 '서버'에 원본이 있으므로 서버 상태에 해당하며 React Query에게 관리(캐싱, 동기화)를 맡겨야 합니다.
  • 오답 피드백: "영철 님, 서버 데이터는 우리 브라우저에 임대 온 세입자입니다. 그걸 영원한 내 데이터처럼 로컬 스토어에 가둬두면 동기화 오류가 터집니다."
  • 📌 핵심 기억법: 🦁 영호: "소유권자가 다르면 금고(Store)도 분리해야 합니다."

Q2. React Query가 가져온 서버 데이터와 Zustand가 가진 UI 상태를 결합(예: 리스트 정렬 조작)하려고 할 때, 올바른 설계 방향은 무엇인가요?

  • A) API 응답을 받는 즉시 Zustand의 set을 호출해 스토어 내부에 복사본을 만든 뒤 거기서 정렬한다.
  • B) React Query 캐시를 직접 조작(setQueryData)하여 렌더링마다 서버 원본을 변경해버린다.
  • C) 컴포넌트 렌더링 사이클 내에서 useQuery 데이터와 useStore 데이터를 각각 불러와 useMemo 등으로 파생(Derived)하여 화면에 그린다.
  • D) 뷰는 Zustand 하나만 구독하도록 아키텍처를 단순화하기 위해 무조건 상태를 Zustand에 넘긴다.

정답: C

💡 상세 해설:

  • 원리 설명: 데이터를 한 공간에 섞으면 Stale 현상이 생깁니다. 따라서 Component를 깔때기 삼아, 원본 서버 뷰(React Query)와 사용자 조작 렌즈(Zustand)를 렌더링 시점에 결합(파생 상태 도출) 하는 것이 정석입니다.
  • 오답 피드백: "영철 님, 왜 자꾸 둘을 기성복처럼 박음질하려고 하세요? 레이어드 룩처럼 입을 때(Render) 겹쳐 보이게 하는 거예요!"
  • 📌 핵심 기억법: 🦁 영호: "React Query의 원본 데이터, Zustand의 UI 필터를 결합하는 공장은 오로지 컴포넌트(useMemo)뿐입니다."

Q3. 🏋️‍♂️ 영철의 테스트 타임 (Q&A)

영철이가 커뮤니티 글쓰기 폼을 만들고 있습니다. 사용자가 제목과 내용을 치는 도중 뒤로가기를 눌러도 날아가지 않게 임시 저장하려고 합니다. 이 데이터는 React Query와 Zustand 중 어디에 저장하는 것이 맞을까요?

정답: Zustand (또는 컴포넌트 useState)

💡 상세 해설:

  • 원리 설명: 사용자가 폼에 타이핑하고 있는 임시 데이터 세트는(아직 제출 버튼을 눌러 API 통신을 하기 전이므로) 완전히 '클라이언트 소유'입니다. 데이터베이스에는 아직 존재하지 않습니다. 따라서 Zustand의 지속성(Persist) 미들웨어를 붙여서 로컬 스토리지 등에 백업을 걸어두거나, UI 스토어에서 관리하는 것이 올바른 아키텍처입니다.
  • 오답 피드백: "영철 님, 이걸 React Query 캐시에 임의로 queryClient.setQueryData 해서 욱여넣으려 하지 마세요. Query는 서버가 내려준 데이터를 브라우저에 투영하는 거울입니다."
  • 📌 핵심 기억법: 🦁 영호: "작성 중인 글은 내 수첩(Zustand), 발행된 글은 도서관 책(React Query)."

🐣 영철이의 퇴근 일기

오늘 그동안 내가 Redux 하면서 "야호! API 데이터 싹 다 스토어에 캐싱해야지!!" 했던 행동이 얼마나 위험한Stale Data 제조기 짓이었는지 깨달았다. Zustand랑 Query를 묶어 쓰라길래 둘을 짬뽕시키라는 줄 알았는데, 오히려 철저히 남남으로 두고 컴포넌트에서 파생시키는 거라니. 아키텍처라는 게 이런 거구나. 오늘 많이 혼났지만 엄청 뿌듯하다. 내일 출근하면 기존에 작성해둔 뚱뚱한 API 페칭 로직 싹 다 useQuery로 뜯어고쳐야겠다!

💡 "서버가 주인이면 React Query, 브라우저가 주인이면 Zustand. 진실의 원천(Source of Truth)을 해치지 말자."