리액트 상태 관리 심화 #2 TanStack Query로 다루는 서버 상태

5 분 소요

#1에서 상태를 클라이언트 상태와 서버 상태로 나눴습니다. 이번 글은 그중 서버 상태를 다룹니다. 게시글 목록, 사용자 정보, 검색 결과처럼 서버에 원본이 있고 화면은 사본을 보여줄 뿐인 데이터입니다.

서버 상태의 표준 도구는 TanStack Query입니다. 과거 React Query라는 이름으로 알려졌고, 지금은 React 외 프레임워크도 지원하면서 TanStack Query로 이름이 바뀌었습니다. 이 글의 코드는 v5 기준입니다.

useEffect로 짜던 방식의 문제 #

먼저 우리가 기초 강좌 #10에서 배운 익숙한 방식을 떠올려 보겠습니다.

useEffect + useState 데이터 페칭
function PostList() {
  const [posts, setPosts] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setIsLoading(true);
    fetch("/api/posts")
      .then((res) => {
        if (!res.ok) throw new Error("요청 실패");
        return res.json();
      })
      .then((data) => setPosts(data))
      .catch((err) => setError(err))
      .finally(() => setIsLoading(false));
  }, []);

  if (isLoading) return <p>불러오는 ...</p>;
  if (error) return <p>에러: {error.message}</p>;
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

동작은 합니다. 그런데 이 코드에는 빠진 것이 많습니다.

  • 캐싱이 없습니다. 이 컴포넌트를 떠났다가 다시 들어오면 매번 처음부터 다시 가져옵니다.
  • 중복 요청을 막지 못합니다. 같은 데이터를 두 컴포넌트가 쓰면 요청이 두 번 나갑니다.
  • 신선도 관리가 없습니다. 한 번 가져온 데이터가 낡았는지 알 길이 없습니다.
  • 상태 변수 세 개(데이터, 로딩, 에러)를 매번 손으로 관리해야 합니다.

이 모든 것을 기본으로 제공하는 것이 TanStack Query입니다.

설치와 Provider 설정 #

설치
npm install @tanstack/react-query

앱 최상단을 QueryClientProvider로 감쌉니다. QueryClient 하나가 모든 캐시를 관리합니다.

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

const queryClient = new QueryClient();

createRoot(document.getElementById("root")).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

useQuery — 데이터 읽기 #

위의 PostList를 TanStack Query로 다시 쓰면 이렇게 됩니다.

useQuery로 다시 쓴 PostList
import { useQuery } from "@tanstack/react-query";

function PostList() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ["posts"],
    queryFn: async () => {
      const res = await fetch("/api/posts");
      if (!res.ok) throw new Error("요청 실패");
      return res.json();
    },
  });

  if (isPending) return <p>불러오는 ...</p>;
  if (isError) return <p>에러: {error.message}</p>;
  return <ul>{data.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

상태 변수 세 개가 사라지고 useQuery 한 번으로 정리됐습니다. 두 가지만 넘기면 됩니다.

  • queryKey — 이 데이터를 식별하는 키입니다. ["posts"]처럼 배열로 씁니다. 이 키가 캐싱의 핵심입니다. 같은 키를 가진 요청은 캐시를 공유하고, 중복 요청이 하나로 합쳐집니다.
  • queryFn — 실제로 데이터를 가져오는 비동기 함수입니다. Promise를 반환하기만 하면 fetchaxios든 상관없습니다.

이제 이 컴포넌트를 떠났다가 돌아와도 캐시에 있던 데이터가 즉시 보이고, 백그라운드에서 조용히 최신 데이터를 다시 확인합니다.

queryKey에 변수를 담기 #

특정 게시글 하나를 가져온다면 키에 id를 함께 넣습니다.

파라미터가 있는 쿼리
function PostDetail({ postId }) {
  const { data, isPending } = useQuery({
    queryKey: ["posts", postId],
    queryFn: async () => {
      const res = await fetch(`/api/posts/${postId}`);
      return res.json();
    },
  });

  if (isPending) return <p>불러오는 ...</p>;
  return <article><h1>{data.title}</h1><p>{data.body}</p></article>;
}

postId가 바뀌면 queryKey도 바뀌고, TanStack Query는 이를 다른 데이터로 인식해 자동으로 새로 가져옵니다. 그리고 각 id별 결과를 따로 캐싱합니다. useEffect의 의존성 배열을 신경 쓰던 일이 queryKey로 자연스럽게 흡수됩니다.

staleTime — 얼마나 신선하다고 볼 것인가 #

#1에서 서버 상태는 가만히 두면 낡는다고 했습니다. TanStack Query는 가져온 데이터를 일정 시간 동안 “신선하다(fresh)“고 보고, 그 시간이 지나면 “낡았다(stale)“고 표시합니다. 낡은 데이터는 화면에 그대로 보여 주되, 적절한 시점(창에 다시 포커스가 올 때 등)에 백그라운드에서 다시 가져옵니다.

staleTime 설정
useQuery({
  queryKey: ["posts"],
  queryFn: fetchPosts,
  staleTime: 60 * 1000, // 60초 동안은 다시 가져오지 않음
});

staleTime의 기본값은 0이라, 기본 설정에서는 데이터를 받자마자 낡은 것으로 간주해 자주 다시 확인합니다. 자주 바뀌지 않는 데이터라면 staleTime을 늘려 불필요한 요청을 줄일 수 있습니다.

노트
staleTime과 헷갈리기 쉬운 값으로 gcTime(가비지 컬렉션 시간, 기본 5분)이 있습니다. staleTime은 “언제 다시 가져올까"를, gcTime은 “화면에서 안 쓰이게 된 캐시를 언제 메모리에서 버릴까"를 정합니다. v5에서 과거의 cacheTimegcTime으로 이름이 바뀌었습니다.

useMutation — 데이터 바꾸기 #

읽기가 useQuery라면, 쓰기(생성, 수정, 삭제)는 useMutation입니다. 새 게시글을 등록하는 예시입니다.

useMutation으로 게시글 추가
import { useMutation, useQueryClient } from "@tanstack/react-query";

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

  const mutation = useMutation({
    mutationFn: async (newPost) => {
      const res = await fetch("/api/posts", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(newPost),
      });
      return res.json();
    },
    onSuccess: () => {
      // 목록 캐시를 무효화해 자동으로 다시 가져오게 함
      queryClient.invalidateQueries({ queryKey: ["posts"] });
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const title = e.target.title.value;
        mutation.mutate({ title });
      }}
    >
      <input name="title" />
      <button disabled={mutation.isPending}>
        {mutation.isPending ? "등록 중..." : "등록"}
      </button>
    </form>
  );
}

여기서 가장 중요한 줄은 onSuccess 안의 invalidateQueries입니다. 게시글을 추가한 뒤 ["posts"] 키의 캐시를 무효화하면, 그 데이터를 보고 있던 모든 컴포넌트가 자동으로 최신 목록을 다시 가져옵니다. 손으로 목록 상태를 갱신할 필요가 없습니다. 이것이 서버 상태를 한곳에서 일관되게 관리한다는 것의 실제 모습입니다.

무엇이 사라졌는지 정리 #

useEffect 방식과 비교해 TanStack Query가 자동으로 처리해 주는 것들입니다.

항목useEffect + useStateTanStack Query
로딩,에러 상태손으로 변수 관리isPending / isError 제공
캐싱없음queryKey 기준 자동
중복 요청 합치기없음자동
창 포커스 시 갱신직접 구현기본 제공
변경 후 목록 갱신손으로 상태 수정invalidateQueries 한 줄

마무리 #

서버 상태는 TanStack Query에 맡기면 캐싱과 재요청, 신선도 관리가 따라옵니다.

  • useQuery({ queryKey, queryFn }) — 읽기, 캐싱과 로딩,에러 상태 자동
  • queryKey — 캐시를 식별하는 키, 변수를 담아 파라미터별로 캐싱
  • staleTime — 데이터를 얼마나 신선하다고 볼지
  • useMutation + invalidateQueries — 쓰기 후 관련 캐시를 무효화해 자동 갱신

#1에서 말한 “서버 데이터를 전역 저장소에 통째로 넣는 안티패턴"을 피하는 가장 현실적인 방법이 바로 이 도구입니다. 서버 상태가 TanStack Query로 빠지고 나면, 전역 저장소에 남는 것은 진짜 클라이언트 상태뿐입니다. 그 클라이언트 상태를 가볍게 다루는 도구를 다음 글부터 봅니다.

다음 글인 “리액트 상태 관리 심화 #3 Zustand로 다루는 가벼운 클라이언트 상태"에서는 최소한의 코드로 전역 클라이언트 상태를 공유하는 Zustand를 다룹니다.

X