Published on

TanStack Query와 Zustand의 역할을 어떻게 나눌까?

Authors
  • avatar
    Name
    GW

개요

React 프로젝트를 시작하면 한 번쯤 이런 고민을 하게 됩니다.

서버에서 받아온 사용자 정보를 어디에 저장해야 할까?

예전에는 Redux 같은 전역 상태 관리 라이브러리에 저장하는 경우가 많았습니다.

하지만 TanStack Query(구 React Query)가 등장하면서 이런 질문이 생기기 시작했습니다.

서버에서 받아온 데이터를 정말 전역 상태에 저장해야 할까?

처음에는 둘 다 상태를 관리하는 라이브러리처럼 보입니다.

그래서

  • TanStack Query만 사용하면 되는지
  • Zustand만 사용하면 되는지
  • 아니면 둘을 함께 사용해야 하는지

헷갈리는 경우가 많습니다.

사실 이 질문의 답은 라이브러리보다 상태(State)의 종류를 먼저 이해하면 자연스럽게 알 수 있습니다.

이번 글에서는 TanStack Query와 Zustand의 차이를 단순히 기능으로 비교하는 것이 아니라,

각 라이브러리가 어떤 문제를 해결하기 위해 만들어졌는지, 그리고 실무에서는 어떻게 역할을 나누는지 함께 살펴보겠습니다.

상태(State)를 다시 생각해 보기

React에서는 모든 상태가 같은 성격을 가지는 것은 아닙니다.

예를 들어 다음과 같은 상태를 생각해 보겠습니다.

  • 로그인한 사용자 정보
  • 상품 목록
  • 알림 모달
  • 다크 모드
  • 검색창 입력값

이 상태들을 모두 하나의 전역 상태 관리 라이브러리에 저장해야 할까요?

겉보기에는 모두 "상태"이지만 사실은 두 종류로 나눌 수 있습니다.

        State(상태)
    ┌────────┴────────┐
    ▼                 ▼
Client State     Server State

Client State

클라이언트 상태는 현재 사용자의 UI를 표현하기 위한 상태입니다.

예를 들어

  • 다크 모드
  • 사이드바 열림 여부
  • 모달 표시 여부
  • 선택한 탭
  • 드롭다운 상태

등이 여기에 해당합니다.

이 상태들은 브라우저 안에서만 의미가 있으며, 새로고침하면 사라져도 큰 문제가 없는 경우가 많습니다.

Server State

반면 서버 상태는 조금 다릅니다.

예를 들어

  • 사용자 정보
  • 상품 목록
  • 댓글 목록
  • 주문 내역

같은 데이터는 서버가 실제 소유하고 있습니다.

클라이언트는 이 데이터를 잠시 가져와 화면에 보여줄 뿐입니다.

즉,

Server
↓
React App
↓
UI

라는 흐름으로 동작합니다.

여기서 중요한 점은 클라이언트가 이 데이터를 소유하는 것이 아니라는 것입니다.

서버의 데이터는 언제든 변경될 수 있으며, 다른 사용자가 수정할 수도 있고, 일정 시간이 지나면 다시 가져와야 할 수도 있습니다.

이처럼 소유권이 서버에 있는 상태를 Server State라고 합니다.

이 차이를 이해하면 TanStack Query와 Zustand의 역할도 자연스럽게 나뉩니다.

Zustand가 잘하는 일

Zustand는 Client State를 관리하기 위한 라이브러리입니다.

예를 들어 다음과 같은 상태는 Zustand가 관리하기에 적합합니다.

  • 다크 모드
  • 모달 열림 여부
  • 현재 선택한 메뉴
  • 사이드바 상태
  • 드래그 중인 아이템
  • 여러 컴포넌트가 함께 사용하는 UI 상태

예를 들어 로그인 모달을 열고 닫는다고 생각해 보겠습니다.

import { create } from 'zustand';

const useModalStore = create((set) => ({
  isOpen: false,
  open: () => set({ isOpen: true }),
  close: () => set({ isOpen: false }),
}));

여러 컴포넌트에서

const { isOpen, open, close } = useModalStore();

처럼 사용할 수 있습니다.

여기서 중요한 점은 이 상태는 서버와 동기화할 필요가 없다는 것입니다.

모달이 열렸는지 닫혔는지는 오직 현재 브라우저에서만 의미가 있습니다.

즉,

UI를 표현하기 위한 상태

라면 Zustand가 가장 잘하는 영역입니다.

또한 Zustand는 필요한 상태만 선택해서 구독할 수 있는 Selector 기반 API를 제공합니다.

const isOpen = useModalStore((state) => state.isOpen);

이처럼 필요한 값만 구독하면 해당 값이 변경될 때만 컴포넌트가 다시 렌더링됩니다.

그래서 전역 상태를 비교적 단순하고 효율적으로 관리할 수 있습니다.

TanStack Query가 잘하는 일

TanStack Query 역시 상태를 관리합니다.

하지만 관리하는 대상이 다릅니다.

TanStack Query는 Server State를 관리하기 위해 만들어진 라이브러리입니다.

예를 들어 상품 목록을 가져온다고 해보겠습니다.

const { data } = useQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
});

겉보기에는 단순히 데이터를 가져오는 코드처럼 보입니다.

하지만 실제로 TanStack Query는 이보다 훨씬 많은 일을 수행합니다.

  • 서버 요청
  • 응답 캐싱
  • 동일한 요청 중복 제거
  • 백그라운드 재요청
  • 데이터 최신 상태 유지
  • 로딩 및 에러 상태 관리

즉, TanStack Query는 단순한 Fetch 라이브러리가 아니라, 비동기 서버 상태를 관리하는 캐시 엔진에 가깝습니다.

이 때문에 공식 문서에서도 TanStack Query를 서버 상태(Server State)를 가져오고, 캐싱하고, 동기화하는 라이브러리로 설명하고 있습니다.

TanStack Query는 서버의 데이터를 대신 소유하는 것이 아니라, 잠시 캐시에 저장해 효율적으로 관리하는 역할을 합니다.

TanStack Query와 Zustand를 어떻게 나눠야 할까?

여기까지 읽었다면 이제 이런 질문이 생길 수 있습니다.

둘 다 상태를 관리한다면, 실제 프로젝트에서는 어떻게 나눠서 사용해야 할까?

가장 중요한 원칙은 하나입니다.

상태의 원본(Source of Truth)이 어디에 있는지 생각해 보자.

만약 서버가 데이터를 소유하고 있다면 TanStack Query가 관리하는 것이 적합합니다.

반대로 현재 사용자의 UI를 표현하기 위한 상태라면 Zustand가 관리하는 것이 적합합니다.

예를 들어 상품 목록 화면을 생각해 보겠습니다.

상품 목록은 서버에서 가져오는 데이터이므로 TanStack Query가 관리하는 것이 자연스럽습니다.

반면 검색창이 열려 있는지, 필터 패널이 펼쳐져 있는지 같은 UI 상태는 서버와 관계가 없으므로 Zustand가 관리하는 것이 적합합니다.

즉,

Server State
↓
TanStack Query
---------------------
Client State
↓
Zustand

처럼 역할을 명확하게 나누는 것이 좋습니다.

여기서 중요한 점은 누가 이 상태를 실제로 소유하고 있는가입니다.

예를 들어 상품 목록의 원본 데이터는 서버에 있습니다.

TanStack Query는 서버에서 데이터를 가져와 캐시에 저장하고 관리하지만, 데이터의 실제 소유자는 여전히 서버입니다.

조금 단순하게 표현하면 다음과 같습니다.

  Server
(원본 데이터)
    │
    ▼
TanStack Query
  (Cache)
    │
    ▼
    UI

그래서 TanStack Query는 상태를 직접 소유(Own)하는 라이브러리라기보다, 서버와 상태를 동기화(Synchronize)하는 라이브러리라고 이해하는 것이 더 정확합니다.

반면 Zustand는 다릅니다.

모달의 열림 여부나 다크 모드처럼 브라우저 안에서만 의미가 있는 상태는 서버가 관리하지 않습니다.

이러한 상태는 Zustand가 직접 소유하고 변경합니다.

TanStack Query
→ 서버와 상태를 동기화
---------------------
Zustand
→ 클라이언트 상태를 직접 관리

처음 TanStack Query를 접하면

TanStack Query도 상태를 가지고 있으니까 상태 관리 라이브러리 아닌가?

라고 생각하기 쉽습니다.

물론 TanStack Query도 내부적으로 상태를 관리합니다.

하지만 TanStack Query가 관리하는 것은 서버 데이터를 저장한 캐시이며, 데이터의 원본은 여전히 서버에 있습니다.

이 차이를 이해하면 왜 서버 데이터를 그대로 Zustand나 useState에 복사하지 않는 것이 좋은지도 자연스럽게 이해할 수 있습니다.

상태를 이렇게 구분하면 각 라이브러리가 자신의 역할에만 집중하게 되고, 데이터의 흐름도 훨씬 단순해집니다.

실무에서 자주 하는 실수

TanStack Query를 처음 사용할 때 가장 많이 하는 실수는 Server State를 다시 Client State로 복사하는 것입니다.

다음은 실무에서 흔히 볼 수 있는 전형적인 안티패턴 코드입니다.

// ❌ Anti-Pattern: 서버 데이터를 가져와서 Zustand 스토어 또는 useState에 또 저장함
export const UserProfile = () => {
  const { data: serverUser } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser,
  });
  const { user, setUser } = useUserStore();

  useEffect(() => {
    if (serverUser) {
      setUser(serverUser); // TanStack Query 캐시 데이터를 클라이언트 스토어로 복사
    }
  }, [serverUser, setUser]);

  return <div>{user?.name}</div>;
};

이 코드는 얼핏 보면 자연스러워 보입니다.

TanStack Query로 서버 데이터를 가져온 뒤, 여러 컴포넌트에서 사용하기 쉽도록 Zustand에 저장하는 방식입니다.

하지만 이렇게 되면 같은 데이터를 TanStack Query와 Zustand가 동시에 관리하는 구조가 됩니다.

이 구조는 실무에서 다양한 문제를 만들 수 있습니다.

1. 하나의 데이터를 두 곳에서 관리하게 됩니다.

가장 큰 문제는 하나의 데이터를 두 개의 Source of Truth가 관리하게 된다는 점입니다.

원래 사용자 정보의 원본은 서버이고, TanStack Query는 이를 캐시에 저장하여 관리합니다.

그런데 같은 데이터를 다시 Zustand에 복사하면 데이터의 주인이 TanStack Query와 Zustand 두 곳으로 나뉘게 됩니다.

그러면 어떤 데이터가 최신인지를 항상 신경 써야 하는 상황이 발생합니다.

2. 데이터가 서로 달라질 수 있습니다.

예를 들어 다른 사용자가 사용자 정보를 수정하거나, invalidateQueries 또는 백그라운드 재검증을 통해 TanStack Query의 캐시가 최신 데이터로 갱신되었다고 가정해 보겠습니다.

TanStack Query의 캐시는 최신 상태가 되지만, Zustand에 복사해 둔 데이터는 자동으로 함께 변경되지 않습니다.

결국

  • TanStack Query에는 최신 데이터가 있고
  • Zustand에는 이전 데이터가 남아

두 상태가 서로 다른 값을 가지는 동기화 불일치 문제가 발생할 수 있습니다.

3. 불필요한 동기화 코드가 늘어납니다.

두 상태를 항상 동일하게 유지하려면 결국 useEffect 같은 동기화 코드가 계속 필요해집니다.

useEffect(() => {
  if (serverUser) {
    setUser(serverUser);
  }
}, [serverUser, setUser]);

처음에는 간단해 보이지만, 프로젝트가 커질수록 이런 동기화 코드가 늘어나면서 유지보수가 어려워지고 예측하기 힘든 사이드 이펙트가 발생할 가능성도 커집니다.

TanStack Query는 이미 서버 상태를 캐시에 저장하고 관리합니다.

그렇다면 같은 데이터를 다시 Zustand나 useState로 복사할 이유는 대부분 없습니다.

대부분의 경우에는 TanStack Query가 제공하는 데이터를 그대로 사용하는 것이 가장 단순하고 안전한 방법입니다.

export const UserProfile = () => {
  const { data: user } = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser,
  });

  return <div>{user?.name}</div>;
};

이처럼 Server State는 TanStack Query가 관리하고, 필요한 곳에서 그대로 사용하는 것이 권장되는 패턴입니다.

물론 서버 데이터를 Client State로 복사하는 것이 항상 잘못된 것은 아닙니다.

예를 들어 수정 폼에서 사용자의 입력을 임시로 관리하거나, 드래그 상태처럼 UI를 위한 별도의 상태가 필요한 경우에는 새로운 Client State를 만드는 것이 자연스러운 선택입니다.

중요한 것은 Server State를 또 다른 Source of Truth로 만들지 않는 것입니다.

자주 하는 오해

TanStack Query는 데이터를 가져오는 라이브러리일 뿐이다?

TanStack Query는 단순히 데이터를 가져오는 도구가 아닙니다.

응답 데이터를 캐싱하고, 동일한 요청을 중복해서 보내지 않으며, 서버 데이터와 동기화를 유지하는 Server State Manager입니다.

서버 데이터를 Zustand에 저장하면 안 된다?

반드시 안 되는 것은 아닙니다.

예를 들어 사용자가 서버 데이터를 수정하는 동안 임시 편집 상태를 관리해야 하는 경우처럼, 특정 목적을 위해 Client State로 사용하는 것은 가능합니다.

다만 TanStack Query가 이미 관리하는 서버 데이터를 단순히 전역 상태에 복사하는 것은 대부분 불필요하며, 동기화 문제를 만들 가능성이 높습니다.

상태를 복사하기 전에 정말 새로운 Client State가 필요한 상황인지 먼저 고민해 보는 것이 좋습니다.

정리

TanStack Query와 Zustand는 모두 상태를 관리하는 라이브러리입니다.

하지만 관리하는 대상이 다르기 때문에 경쟁 관계가 아니라 서로를 보완하는 관계에 가깝습니다.

핵심 내용을 정리하면 다음과 같습니다.

  • 상태는 크게 Server State와 Client State로 나눌 수 있습니다.
  • TanStack Query는 서버 상태를 가져오고, 캐싱하고, 동기화하는 역할을 합니다.
  • Zustand는 UI와 관련된 클라이언트 상태를 관리하는 역할을 합니다.
  • 같은 데이터를 여러 곳에서 관리하면 동기화 문제가 발생하기 쉽습니다.
  • 상태의 소유자가 누구인지를 기준으로 역할을 나누면 아키텍처가 훨씬 단순해집니다.

React 프로젝트가 커질수록 중요한 것은 어떤 라이브러리를 사용하는지가 아니라 상태를 올바르게 분류하는 것입니다.

Server State와 Client State를 명확하게 구분하면 데이터의 흐름이 단순해지고, 각 라이브러리가 자신의 역할에만 집중할 수 있는 구조를 만들 수 있습니다.

참고 자료