TanStack Query로 React 서버 상태 관리하기

React로 앱을 만들다 보면 서버에서 데이터를 가져오는 코드를 정말 자주 작성하게 됩니다. useEffect() 안에서 fetch()를 호출하고 useState()로 로딩 상태와 에러 상태를 관리하고 데이터가 오면 상태를 업데이트하고… 이 패턴을 몇 번이고 반복하다 보면 문득 이런 생각이 들지 않나요?

“매번 같은 코드를 쓰고 있는데, 이걸 더 잘 처리하는 방법은 없을까?” 🤔

여기에 캐싱, 재시도, 백그라운드 갱신, 낙관적 업데이트까지 고려하면 상황은 더 복잡해집니다. 이런 문제를 정면으로 해결하기 위해 만들어진 라이브러리가 바로 TanStack Query입니다. 원래 React Query라는 이름으로 유명했는데, v5부터 여러 프레임워크를 지원하면서 TanStack Query로 이름이 바뀌었습니다.

이번 포스팅에서는 TanStack Query의 핵심 개념과 사용법을 실전 예제를 통해 차근차근 살펴보겠습니다.

직접 데이터를 가져올 때의 문제

TanStack Query가 왜 필요한지 이해하려면 먼저 기존 방식의 한계를 살펴봐야 합니다. React에서 원격 API를 호출할 때 보통 이런 식으로 코드를 작성하는데요.

import { useState, useEffect } from "react";

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <p>불러오는 중...</p>;
  if (error) return <p>오류가 발생했습니다</p>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

언뜻 보기엔 나쁘지 않아 보이지만, 실제 프로덕션 앱에서는 여러 문제가 생기기 시작합니다.

우선 같은 사용자 정보를 여러 컴포넌트에서 보여줘야 한다면 어떻게 될까요? 각 컴포넌트가 독립적으로 API를 호출하기 때문에 동일한 데이터를 여러 번 요청하게 됩니다. 사용자가 다른 페이지에 갔다가 돌아오면 데이터를 처음부터 다시 가져와야 하니 불필요한 로딩 화면이 나타나고요. 네트워크 요청이 실패했을 때 자동으로 재시도하는 기능도 직접 구현해야 합니다. 게다가 서버의 데이터가 변경되었는지 확인하고 화면을 최신 상태로 유지하는 것도 개발자가 일일이 신경 써야 하죠.

이런 문제를 하나하나 해결하려면 결국 상당히 복잡한 데이터 패칭 레이어를 직접 만들어야 합니다. TanStack Query는 바로 이 지점에서 등장합니다.

서버 상태 vs 클라이언트 상태

TanStack Query를 이해하는 열쇠는 서버 상태(server state)클라이언트 상태(client state)의 차이를 아는 것입니다.

클라이언트 상태는 브라우저에서만 존재하는 데이터입니다. 모달이 열려 있는지, 사이드바가 접혀 있는지, 사용자가 폼에 입력한 값이 무엇인지 같은 것들이죠. 이런 데이터는 우리가 완전히 통제할 수 있고, 동기적으로 바로 접근할 수 있습니다.

반면 서버 상태는 서버에 저장된 데이터를 클라이언트에서 보여주기 위해 가져온 것입니다. 사용자 목록, 게시글, 댓글 같은 데이터가 여기에 해당합니다. 이 데이터는 비동기적으로만 접근할 수 있고, 다른 사용자가 언제든 변경할 수 있기 때문에 내가 가진 데이터가 최신인지 보장할 수가 없습니다.

useState()useReducer()는 클라이언트 상태를 관리하기에 충분하지만, 서버 상태를 다루기에는 부족합니다. Context API로 전역 상태를 관리하더라도 캐싱이나 백그라운드 갱신 같은 서버 상태 특유의 요구사항을 해결하기는 어렵습니다. TanStack Query는 바로 이 서버 상태 관리에 특화된 도구입니다.

설치 및 기본 설정

TanStack Query를 사용하려면 먼저 패키지를 설치합니다.

$ npm install @tanstack/react-query

그런 다음 앱의 최상위 컴포넌트에서 QueryClientProvider를 설정해야 합니다. 이 Provider가 쿼리 캐시를 관리하고 하위 컴포넌트에서 TanStack Query의 훅을 사용할 수 있게 해줍니다.

App.jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserProfile userId={1} />
    </QueryClientProvider>
  );
}

QueryClient 인스턴스는 앱 전체에서 하나만 만들어 공유합니다. 컴포넌트 바깥에서 생성해야 리렌더링할 때마다 새로운 인스턴스가 만들어지는 것을 방지할 수 있습니다.

useQuery로 데이터 가져오기

TanStack Query의 핵심은 useQuery 훅입니다. 앞에서 작성한 UserProfile 컴포넌트를 useQuery로 다시 작성해 보겠습니다.

import { useQuery } from "@tanstack/react-query";

function UserProfile({ userId }) {
  const { data: user, isPending, isError, error } = useQuery({
    queryKey: ["user", userId],
    queryFn: () =>
      fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
        .then((res) => res.json()),
  });

  if (isPending) return <p>불러오는 중...</p>;
  if (isError) return <p>오류: {error.message}</p>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

useState() 세 개와 useEffect() 하나가 useQuery() 하나로 줄었습니다. 코드가 간결해진 것도 좋지만 진짜 이점은 눈에 보이지 않는 곳에 있습니다.

이 코드만으로도 자동 캐싱이 됩니다. 같은 queryKey로 요청하면 네트워크 호출 없이 캐시에서 데이터를 바로 보여줍니다. 요청이 실패하면 기본적으로 3번까지 자동 재시도합니다. 사용자가 브라우저 탭을 벗어났다가 다시 돌아오면 백그라운드에서 데이터를 갱신합니다. 같은 쿼리 키를 사용하는 컴포넌트가 여러 개 마운트되더라도 네트워크 요청은 한 번만 보냅니다.

이 모든 기능이 별도의 설정 없이 기본으로 동작합니다.

쿼리 키

queryKey는 TanStack Query에서 각 쿼리를 식별하는 고유한 키입니다. 이 키를 기준으로 캐시가 관리되기 때문에 올바르게 설계하는 것이 중요합니다.

쿼리 키는 반드시 배열 형태로 지정합니다.

// 모든 할 일 목록
useQuery({ queryKey: ["todos"], queryFn: fetchTodos });

// 특정 할 일
useQuery({ queryKey: ["todos", todoId], queryFn: () => fetchTodo(todoId) });

// 필터가 적용된 할 일 목록
useQuery({
  queryKey: ["todos", { status: "done", page: 1 }],
  queryFn: () => fetchTodos({ status: "done", page: 1 }),
});

배열의 내용이 바뀌면 TanStack Query는 새로운 쿼리로 인식하고 데이터를 다시 가져옵니다. 예를 들어 ["todos", 1]["todos", 2]는 서로 다른 쿼리로 취급되어 각각 독립된 캐시를 가집니다.

쿼리 키를 설계할 때는 범위가 넓은 것에서 좁은 것 순으로 배치하는 것이 좋습니다. ["todos"]는 모든 할 일 관련 쿼리의 상위 키가 되고, ["todos", todoId]는 특정 할 일의 키가 됩니다. 이렇게 하면 나중에 쿼리 무효화를 할 때 ["todos"]로 모든 할 일 관련 캐시를 한 번에 무효화할 수 있어 편리합니다.

useQuery의 반환값 활용하기

useQuery는 데이터 외에도 여러 가지 상태 정보를 반환합니다. 이를 활용하면 사용자 경험을 크게 개선할 수 있습니다.

function TodoList() {
  const {
    data: todos,
    isPending,     // 데이터를 아직 한 번도 가져오지 못한 상태
    isError,       // 에러가 발생한 상태
    error,         // 에러 객체
    isFetching,    // 백그라운드에서 데이터를 가져오는 중
    isRefetching,  // 캐시된 데이터가 있는 상태에서 다시 가져오는 중
  } = useQuery({
    queryKey: ["todos"],
    queryFn: () =>
      fetch("https://jsonplaceholder.typicode.com/todos")
        .then((res) => res.json()),
  });

  if (isPending) return <p>불러오는 중...</p>;
  if (isError) return <p>오류: {error.message}</p>;

  return (
    <div>
      {isRefetching && <p>최신 데이터를 확인하는 중...</p>}
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            {todo.completed ? "✅" : "⬜"} {todo.title}
          </li>
        ))}
      </ul>
    </div>
  );
}

여기서 isPendingisFetching의 차이를 이해하는 게 중요합니다. isPending은 캐시에 데이터가 없는 상태에서 처음 데이터를 가져올 때 true입니다. 반면 isFetching은 캐시에 데이터가 있든 없든 네트워크 요청이 진행 중일 때 항상 true입니다. 이전에는 isLoading이라는 이름이었는데, TanStack Query v5부터 의미를 더 정확하게 반영하기 위해 isPending으로 바뀌었습니다.

이 차이를 활용하면 캐시된 데이터가 있을 때는 일단 그 데이터를 보여주면서 백그라운드에서 최신 데이터를 가져오는 UX를 쉽게 구현할 수 있습니다. 사용자 입장에서는 화면이 깜빡이지 않고 부드럽게 업데이트되니까 훨씬 좋은 경험이 됩니다.

useMutation으로 데이터 변경하기

데이터를 가져오는 것만큼이나 중요한 것이 서버 데이터를 변경하는 것입니다. 새 할 일을 추가하거나 게시글을 수정하거나 댓글을 삭제하는 것처럼요. TanStack Query는 이런 변경 작업을 위해 useMutation 훅을 제공합니다.

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

function TodoApp() {
  const queryClient = useQueryClient();

  const { data: todos, isPending } = useQuery({
    queryKey: ["todos"],
    queryFn: () =>
      fetch("https://jsonplaceholder.typicode.com/todos?_limit=5")
        .then((res) => res.json()),
  });

  const addTodo = useMutation({
    mutationFn: (newTodo) =>
      fetch("https://jsonplaceholder.typicode.com/todos", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(newTodo),
      }).then((res) => res.json()),
    onSuccess: () => {
      // 할 일 목록 캐시를 무효화하여 최신 데이터를 다시 가져옴
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    const title = e.target.elements.title.value;
    addTodo.mutate({ title, completed: false });
    e.target.reset();
  };

  if (isPending) return <p>불러오는 중...</p>;

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input name="title" placeholder="새 할 일" required />
        <button type="submit" disabled={addTodo.isPending}>
          {addTodo.isPending ? "추가 중..." : "추가"}
        </button>
      </form>

      {addTodo.isError && <p>추가 실패: {addTodo.error.message}</p>}

      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </div>
  );
}

useMutationuseQuery처럼 isPending, isError, error 같은 상태를 제공합니다. 덕분에 버튼을 비활성화하거나 에러 메시지를 보여주는 것이 간단합니다.

mutate() 함수를 호출하면 mutationFn에 정의한 비동기 작업이 실행되고 성공하면 onSuccess 콜백이 호출됩니다. 여기서 queryClient.invalidateQueries()를 호출하여 관련 캐시를 무효화하면 TanStack Query가 자동으로 최신 데이터를 다시 가져옵니다.

쿼리 무효화

invalidateQueries는 TanStack Query에서 가장 자주 쓰이는 메서드 중 하나입니다. 캐시된 데이터를 “오래된 것(stale)“으로 표시하고, 해당 쿼리를 사용하는 컴포넌트가 화면에 있으면 자동으로 데이터를 다시 가져옵니다.

const queryClient = useQueryClient();

// "todos"로 시작하는 모든 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ["todos"] });

// 특정 할 일만 무효화
queryClient.invalidateQueries({ queryKey: ["todos", 5] });

// 필터 조건으로 무효화
queryClient.invalidateQueries({
  queryKey: ["todos"],
  exact: true, // 정확히 ["todos"]인 쿼리만
});

쿼리 키가 계층 구조로 되어 있기 때문에 ["todos"]를 무효화하면 ["todos"], ["todos", 1], ["todos", { status: "done" }]"todos"로 시작하는 모든 쿼리가 무효화됩니다. 만약 정확히 해당 키만 무효화하고 싶다면 exact: true 옵션을 추가하면 됩니다.

이 메커니즘 덕분에 데이터 변경 후 화면을 최신 상태로 유지하는 것이 훨씬 간단해집니다. useMutationonSuccess에서 관련 쿼리를 무효화하기만 하면 나머지는 TanStack Query가 알아서 처리해 줍니다.

캐싱 동작 이해하기

TanStack Query의 캐싱은 staleTimegcTime 두 가지 설정으로 제어됩니다.

staleTime은 데이터를 “신선한(fresh)” 것으로 취급하는 시간입니다. 기본값은 0이라서 데이터를 가져오자마자 바로 stale 상태가 됩니다. stale 상태의 데이터는 캐시에서 바로 보여주긴 하지만, 특정 조건(창 포커스, 컴포넌트 마운트 등)에서 백그라운드 갱신이 일어납니다. staleTime을 늘리면 그 시간 동안은 캐시 데이터를 그대로 사용하고 추가 네트워크 요청을 하지 않습니다.

useQuery({
  queryKey: ["user", userId],
  queryFn: () => fetchUser(userId),
  staleTime: 5 * 60 * 1000, // 5분 동안 신선한 데이터로 취급
});

gcTime은 사용되지 않는 캐시 데이터가 메모리에 남아 있는 시간입니다. 이전에는 cacheTime이라는 이름이었는데 v5에서 gcTime(garbage collection time)으로 변경되었습니다. 기본값은 5분이고, 해당 쿼리를 사용하는 컴포넌트가 모두 언마운트된 시점부터 카운트됩니다. 이 시간이 지나면 캐시에서 완전히 제거됩니다.

예를 들어 사용자 프로필 페이지를 방문했다가 다른 페이지로 이동한 뒤 5분 이내에 다시 프로필 페이지를 방문하면 캐시된 데이터가 즉시 표시됩니다. 5분이 지난 후에 다시 방문하면 캐시가 제거된 상태이므로 처음부터 데이터를 다시 가져옵니다.

낙관적 업데이트

사용자 경험을 한 단계 더 끌어올리고 싶다면 낙관적 업데이트(optimistic update)를 고려해 볼 만합니다. 서버 응답을 기다리지 않고 UI를 먼저 업데이트한 뒤 요청이 실패하면 이전 상태로 되돌리는 방식이에요.

TanStack Query v5에서는 useMutation이 반환하는 variables를 활용하여 이를 간단하게 구현할 수 있습니다.

function TodoItem({ todo }) {
  const queryClient = useQueryClient();

  const toggleTodo = useMutation({
    mutationFn: (updatedTodo) =>
      fetch(`/api/todos/${updatedTodo.id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ completed: updatedTodo.completed }),
      }).then((res) => res.json()),
    onMutate: async (updatedTodo) => {
      // 진행 중인 쿼리를 취소하여 덮어쓰지 않도록 함
      await queryClient.cancelQueries({ queryKey: ["todos"] });

      // 이전 데이터를 저장
      const previousTodos = queryClient.getQueryData(["todos"]);

      // 캐시를 낙관적으로 업데이트
      queryClient.setQueryData(["todos"], (old) =>
        old.map((t) =>
          t.id === updatedTodo.id
            ? { ...t, completed: updatedTodo.completed }
            : t
        )
      );

      return { previousTodos };
    },
    onError: (err, updatedTodo, context) => {
      // 에러 발생 시 이전 데이터로 복원
      queryClient.setQueryData(["todos"], context.previousTodos);
    },
    onSettled: () => {
      // 성공이든 실패든 최신 데이터를 다시 가져옴
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  return (
    <label>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() =>
          toggleTodo.mutate({ ...todo, completed: !todo.completed })
        }
      />
      {todo.title}
    </label>
  );
}

onMutate 콜백에서 캐시를 먼저 업데이트하기 때문에 사용자는 체크박스를 클릭하면 즉시 변경된 상태를 볼 수 있습니다. 만약 서버 요청이 실패하면 onError에서 이전 상태로 되돌립니다. 그리고 onSettled에서 쿼리를 무효화하여 성공 여부와 관계없이 서버의 실제 데이터와 동기화합니다.

실전에서 유용한 옵션들

TanStack Query에는 실무에서 유용한 옵션이 꽤 많은데요. 자주 사용하는 몇 가지를 살펴보겠습니다.

enabled 옵션으로 쿼리 실행을 제어할 수 있습니다. 예를 들어 사용자가 선택한 값이 있을 때만 데이터를 가져오고 싶다면 이렇게 합니다.

function UserPosts({ userId }) {
  const { data: posts } = useQuery({
    queryKey: ["posts", userId],
    queryFn: () => fetchPostsByUser(userId),
    enabled: !!userId, // userId가 있을 때만 실행
  });

  return posts ? (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  ) : (
    <p>사용자를 선택해주세요</p>
  );
}

select 옵션으로 서버에서 가져온 데이터를 변환할 수 있습니다. 원본 데이터는 캐시에 그대로 보존되고, 컴포넌트에는 변환된 데이터가 전달됩니다.

const { data: completedTodos } = useQuery({
  queryKey: ["todos"],
  queryFn: fetchTodos,
  select: (todos) => todos.filter((todo) => todo.completed),
});

retryretryDelay 옵션으로 재시도 동작을 세밀하게 제어할 수도 있습니다.

useQuery({
  queryKey: ["todos"],
  queryFn: fetchTodos,
  retry: 2,                     // 최대 2번 재시도 (기본값: 3)
  retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
});

refetchInterval 옵션을 사용하면 주기적으로 데이터를 자동 갱신할 수 있습니다. 실시간에 가까운 데이터가 필요한 대시보드 같은 곳에서 유용합니다.

useQuery({
  queryKey: ["notifications"],
  queryFn: fetchNotifications,
  refetchInterval: 30 * 1000, // 30초마다 갱신
});

DevTools 활용하기

TanStack Query는 개발할 때 큰 도움이 되는 DevTools도 제공합니다. 별도의 패키지를 설치해야 합니다.

$ npm install @tanstack/react-query-devtools

앱에 DevTools 컴포넌트를 추가하면 브라우저 하단에 플로팅 패널이 나타납니다.

App.jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <TodoApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

DevTools에서는 현재 캐시된 모든 쿼리를 한눈에 볼 수 있습니다. 각 쿼리의 상태(fresh, stale, inactive, fetching)가 색상으로 구분되어 표시되고, 쿼리 데이터의 내용도 바로 확인할 수 있습니다. 쿼리를 수동으로 무효화하거나 제거할 수도 있어서 디버깅할 때 정말 유용하고요. 프로덕션 빌드에서는 자동으로 제거되니 안심하고 쓰면 됩니다.

마치며

TanStack Query를 사용하면 useState + useEffect 조합으로 직접 구현했던 데이터 패칭 로직을 훨씬 깔끔하고 견고하게 작성할 수 있습니다. 자동 캐싱이나 백그라운드 갱신, 재시도, 쿼리 무효화 같은 기능을 직접 구현하려면 상당한 노력이 필요한데 TanStack Query를 쓰면 몇 줄이면 해결되거든요.

이번 포스팅에서 다룬 useQueryuseMutation만으로도 대부분의 서버 상태 관리를 처리할 수 있습니다. 여기에 익숙해지고 나면 useSuspenseQueryuseInfiniteQuery 같은 고급 훅도 한번 살펴보시면 좋겠습니다.

TanStack Query에 대해 더 자세히 알고 싶다면 공식 문서를 참고하세요. 같은 TanStack 생태계에서 테이블 UI를 구현하는 방법도 함께 살펴보시면 TanStack이 추구하는 헤드리스(headless) 철학을 이해하는 데 도움이 될 것입니다.

This work is licensed under CC BY 4.0 CC BY

Discord