☁️ goormTIL | TanStack Query #50

매루·2025년 11월 19일

goormTIL

목록 보기
48/67
post-thumbnail

📅 2025-11-19

➡️ TanStack Query 생명 주기 및 주요 옵션에 대해 새롭게 알게 된 것 또는 헷갈리는 부분 정리


🔎 학습 리마인드

📌 TanStack Query

  • 서버 상태(Server State) 를 효율적으로 관리하는 라이브러리

서버 상태가 갖는 특징

  • 클라이언트 내부에서만 존재하는 상태와 달리 아래 요소를 계속 고려해야 함

    • 서버와의 요청/응답
    • 네트워크 상황
    • 데이터 최신 여부
    • 재요청 시점 관리
    • 캐시 유지 / 무효화 관리
  • TanStack Query는 이런 복잡한 과정을 아래처럼 자동화함

    • Fetching : 서버에서 데이터 가져오기
    • Caching : 가져온 데이터 캐싱
    • Synchronizing: 캐시와 서버 데이터를 동기화
    • Updating: 서버 데이터 수정 후 캐시 업데이트

▶️ 개발자는 “언제, 무엇을 요청할지”에만 집중하면 되고 요청 타이밍, 캐시 갱신은 TanStack Query가 대신 해줌


💡 SWR 전략 (Stale-While-Revalidate)

🔗 https://web.dev/articles/stale-while-revalidate?hl=ko

  • 캐시 데이터(조금 오래된 데이터)를 먼저 화면에 보여주고, 그 뒤 백그라운드에서 최신 데이터를 다시 가져와 갱신하는 전략
    • 화면이 즉시 캐시 데이터로 렌더링되기 때문에 반응이 빠르게 느껴짐
    • 그와 동시에 백그라운드에서 최신 데이터를 다시 받아와서 준비가 되면 화면이 자연스럽게 최신 상태로 갱신
  • stale: 신선하지 않은, 오래된

예시: HTTP의 Cache-Control 헤더

Cache-Control: max-age=1, stale-while-revalidate=59
  • 0~1초 사이에 다시 요청이 들어오면 → 서버 호출 없이, 캐시를 “최신”으로 간주하고 그대로 사용
  • 1~60초 사이에 다시 요청이 들어오면 → 일단 캐시 데이터를 먼저 사용해서 빠르게 응답하고, → 동시에 서버에 새 데이터를 요청한 후, 응답이 오면 그걸로 캐시와 화면을 교체

▶️ TanStack Query는 이 SWR 전략을 내부적으로 활용하여 데이터를 효율적으로 관리함

💡 캐시 데이터 저장 방식

  • TanStack Query는 전역 캐시 저장소를 만들어 데이터 관리
  • React Context API를 사용
    • QueryClientProvider로 전체 앱을 감싸 캐시 공유

      import ReactDOM from "react-dom/client";
      import App from "./App.jsx";
      import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
      
      const queryClient = new QueryClient();
      
      ReactDOM.createRoot(document.getElementById("root")).render(
        <QueryClientProvider client={queryClient}>
          <App />
        </QueryClientProvider>
      );
    • Provider 하위 모든 컴포넌트가 같은 캐시를 공유

      • Context 내부 → 캐시 컨텍스트(cache context)
      • 그 안의 실제 데이터 → 캐시 데이터(cache data)
    • Redux처럼 전역 상태를 제공하는 것과 비슷하지만 서버 상태에 특화된 형태로 자동화되어 있음


💡 그림으로 이해하는 TanStack Query 데이터 흐름

  • A 컴포넌트가 처음 데이터를 요청할 때

    1. 캐시 조회
      • ["todos"] 에 대한 캐시 데이터 요청
        useQuery({ queryKey: ["todos"], queryFn: fetchTodos });
        • A가 마운트되면 useQuery 실행
        • Context(캐시)"todos" 키로 데이터가 있는지 먼저 확인 → 아직 없음 → 초기 상태

    1. 초기 상태: 캐시 없음 → 로딩 시작
      • 캐시에 값이 없으므로 A는 다음과 같은 상태를 받음
        { data: undefined, isLoading: true, isFetching: true }
        • A는 로딩 UI 렌더링

    1. fetchTodos 실행 (API 요청)
      • 캐시가 비어 있으므로 실제 API 호출 발생 (queryFn)
        fetchTodos(); // GET /todos
        • 서버는 내부적으로 DB 조회 후 리스트 응답 반환

    1. 서버 응답을 캐시에 저장
      • 서버 응답을 Context(캐시)에 ["todos"] 키로 저장
        {
          todos: [ /* 서버 데이터 */ ] // fetchTodos의 반환값
        }

    1. A 컴포넌트에 새 데이터 전달 → 리렌더링
      • 캐시가 채워졌으므로 A는 다음과 같은 상태를 받음
        { data: todos, isLoading: false, isFetching: false }
        • A 컴포넌트 UI에 실제 todo 목록 렌더링

  • B 컴포넌트가 렌더될 때
    1. B도 동일한 쿼리 실행
      • 다시 ["todos"] 에 대한 캐시 데이터 요청
        useQuery({ queryKey: ["todos"], queryFn: fetchTodos });
        • 다시 "todos"를 캐시에 조회

    1. 이미 캐시가 있으므로, 바로 캐시 데이터 반환 (로딩 없음)
      • A에서 이미 데이터를 불러왔기 때문에 B는 서버 호출 없이 바로 캐시된 데이터를 받음
        { data: todos, isLoading: false, isFetching: false }
        • B는 처음 렌더링부터 로딩 없이 바로 바로 데이터 표시
          (필요에 따라 백그라운드 refetch 시 isFetchingtrue 일 수도 있음)

  • C 컴포넌트가 mutation으로 Todo 추가할 때
    const mutation = useMutation({
      mutationFn: createTodo,
      onSuccess: () => {
        queryClient.invalidateQueries(["todos"]);
      },
    });
    1. createTodo 실행 → 서버 업데이트
      • 사용자가 C에서 추가 버튼을 클릭 → mutation.mutate() 실행
      • createTodo 함수가 실행되어 새 todo 생성 요청
      • 서버의 실제 데이터(server state) 변경됨

  • onSuccess: invalidate → 최신 데이터로 전체 갱신
    queryClient.invalidateQueries(["todos"]);
    • "todos" 캐시를 stale(오래됨)로 표시
    • TanStack Query가 자동으로 fetchTodos 재실행
    • API에서 최신 리스트 다시 가져와 캐시 업데이트
    • "todos"를 구독 중인 컴포넌트 A·B·C 모두 자동으로 최신 UI로 리렌더링
      → 즉, 처음 1~5번 과정이 전체적으로 반복됨

📌 React Query Devtools

🔗 https://tanstack.com/query/v5/docs/framework/react/devtools

  • TanStack Query는 내부적으로 캐시를 관리하고, 각 쿼리가 fresh / stale / active / inactive 상태를 오가게 됨
  • Devtools를 사용하면 이 흐름을 시각적으로 확인할 수 있어, 상태를 파악할 때 도움이 됨

설치 및 적용

npm i @tanstack/react-query-devtools
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

const queryClient = new QueryClient();

<QueryClientProvider client={queryClient}>
	{/* Devtools UI */}
  <ReactQueryDevtools initialIsOpen={false} />
  <App />
</QueryClientProvider>;

💡 Devtools에서 확인할 수 있는 정보

  • 현재 캐시에 어떤 쿼리가 저장되어 있는지
  • 각 쿼리가 fresh / stale 상태인지
  • active / inactive 여부
  • isFetching 여부(로딩 표시)
  • 캐시된 데이터 구조(JSON)
  • Refetch 버튼으로 재요청 가능

📌 TanStack Query의 생명주기(Life Cycle)

  • 쿼리가 생성 → fresh 유지 → stale → inactive → gcTime 지나면 삭제

💡 주요 상태 개념

상태언제?특징
fresh방금 패칭했거나 staleTime 안쪽“지금은 다시 요청할 필요 없음”
stalestaleTime이 지남필요 시 자동/수동으로 refetch 가능
active1개 이상의 컴포넌트가 해당 쿼리를 사용 중gcTime이 카운트되지 않음 (유지됨)
inactive어떤 컴포넌트도 사용하지 않을 때Devtools에서 회색 표시, gcTime 카운트 시작
deletedinactive 상태로 gcTime이 지나면캐시에서 완전히 제거됨 → 다음 요청은 “처음 요청”처럼 동작
fetchingqueryFn이 실제 서버 요청 중isFetching: true이며, Devtools에서 해당 쿼리에 작은 회전 아이콘 표시

📌 TanStack Query 주요 옵션

💡 staleTime

  • 데이터가 fresh로 유지되는 시간
  • staleTime 안에서는 fresh
  • staleTime 이후에는 stale
  • stale이 되어도 바로 refetch되진 않음 → refetch할 수 있는 조건이 되었다 정도의 의미

글로벌 설정

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 5000 } }
});

쿼리 개별 설정

const { data } = useQuery({
  queryKey: ["todos"],
  queryFn: getTodos,
  staleTime: 5000,
});

💡 refetchOnMount / WindowFocus / Reconnect

  • 특정 이벤트 발생 시 stale 상태라면 자동으로 다시 불러올지 결정
  • 공통적으로 fresh일 때는 refetch 안 함
    • stale이어야만 동작

글로벌 설정 예시

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 2000,
      refetchOnMount: true,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
    }
  }
});
  1. refetchOnMount

    • 컴포넌트가 마운트될 때 stale이면 자동 refetch
  2. refetchOnWindowFocus

    • 브라우저 탭을 다시 활성화했을 때 stale이면 refetch
  3. refetchOnReconnect

    • 네트워크가 offline → online으로 전환될 때 stale이면 refetch

💡 gcTime (cacheTime)

  • inactive 상태가 된 쿼리가 캐시에 얼마나 남아 있을지
  • 컴포넌트에서 더 이상 사용하지 않으면 → inactive
  • 이 때부터 gcTime 카운트 시작
  • 시간이 지나면 → 캐시에서 삭제

글로벌 설정

const queryClient = new QueryClient({
  defaultOptions: { queries: { gcTime: 2000 }
});

쿼리 개별 설정

const { data } = useQuery({
  queryKey: ["todos"],
  queryFn: getTodos,
  gcTime: 2000, // 2초 후 삭제
});

💡 retry

  • 요청 실패 시 몇 번 재시도할 것인가 (기본 3회)
    • 네트워크 불안정, 일시적인 서버 에러 등에 대비하기 위한 옵션

글로벌 설정

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: 5 } }
});

쿼리 개별 설정

const { data } = useQuery({
  queryKey: ["todos"],
  queryFn: getTodos,
  retry: 10,
});

💡 staleTime과 fresh/stale관계

staleTime동작
> 0패칭 직후 일정 시간 fresh → 자동 재요청 X
0패칭 직후 바로 stale → 필요 시(refetch 이벤트에 의해) 재요청 가능
  • stale이어도 “항상 refetch하지는 않음” → refetch 조건이 만족될 때만 수행됨

💡 isPending vs isFetching

케이스캐시 존재서버 요청 중?isPendingisFetching설명
첫 진입, 캐시 없음truetrue초기 로딩 상태
캐시 있고, refetch 없음falsefalse화면은 캐시 기반, 백그라운드 패칭 없음
캐시 있고, 백그라운드 refetchfalsetrueSWR 패턴, UI는 캐시 → 뒤에서 새 데이터 패칭
캐시 없음인데 retry 중truetrue네트워크 에러 등으로 재시도 중

💡 enabled

  • 쿼리를 자동으로 실행할지 여부
  • true → 컴포넌트 마운트 시 자동 실행
  • false → 자동 실행하지 않고, 수동 refetch할 때만 실행 (검색, 버튼 액션에 유용)

사용 패턴 별 예시

  1. 버튼 클릭 시에만 서버 요청하고 싶은 경우
    • 검색 폼처럼 사용자가 버튼을 클릭할 때만 패칭 하는 형태에 적합
      const [keyword, setKeyword] = useState("");
      
      const { data, refetch, isFetching } = useQuery({
        queryKey: ["search", keyword],
        queryFn: () => searchApi(keyword),
        enabled: false, // 자동 실행 X
      });
      
      return (
        <div>
          <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
          <button onClick={() => refetch()}>검색</button>
      
          {isFetching && <p>검색 중...</p>}
          {data && <SearchResult data={data} />}
        </div>
      );
  1. 의존 쿼리(Dependent Query)
    • 앞선 쿼리 결과가 있어야만 의미가 있는 경우
      const { data: user } = useQuery({ queryKey: ["user"], queryFn: fetchUser });
      
      const { data: todos } = useQuery({
        queryKey: ["todos", user?.id],
        queryFn: () => fetchTodos(user.id),
        enabled: !!user, // user가 있을 때만 실행
      });
      • user가 로딩 중일 때는 todos 쿼리가 실행되지 않음
      • user가 준비된 뒤에야 todos 쿼리 실행

💡 select

  • 캐시에는 원본 저장 + UI에서는 가공된 형태만 사용 가능
  • UI에서 필요한 형태로 데이터를 변환해 쓸 때 유용

사용 패턴 별 예시

  1. 특정 필드만 꺼내 쓰기 (username만 선택)

    const { data: username } = useQuery({
      queryKey: ["user"],
      queryFn: fetchUser,
      select: (user) => user.username,
    });
    
    // JSX
    <div>이름: {username}</div>
  1. 목록에서 필요한 값만 매핑 (todos에서 content만 추출)

    const { data: todoTexts } = useQuery({
      queryKey: ["todos"],
      queryFn: fetchTodos,
      select: (todos) => todos.map((t) => t.content),
    });
  1. 서버 데이터 → 화면에서 필요한 형태로 변환 (정렬/필터링 등 데이터 가공)

    const { data: todosData } = useQuery({
      queryKey: ["todos"],
      queryFn: fetchTodos,
      select: (todos) => {
        const sorted = [...todos].sort(
          (a, b) => new Date(b.createdAt) - new Date(a.createdAt)
        );
        const completed = sorted.filter((t) => t.completed);
        const pending = sorted.filter((t) => !t.completed);
        return { completed, pending };
      },
    });
    
    // JSX
    <TodoSection title="할 일" list={todosData.pending} />
    <TodoSection title="완료" list={todosData.completed} />

0개의 댓글