Home React Query
Post
X

React Query

React Query

React 애플리케이션에서 서버 상태(Server State)를 관리하는 라이브러리입니다.

  • 설치

    1
    
    npm i @tanstack/react-query
    

주요 기능

  • 캐싱 Cache

    같은 queryKey로 호출하면 네트워크 요청 없이 캐시된 데이터 사용 가능

  • 자동 리패치 Refetching

    윈도우 포커스 전환 시, 네트워크 연결 복구 시 자동으로 데이터 최신화

    refetchInterval 옵션으로 주기적인 폴링 가능

  • 쿼리 무효화 Invalidate

    mutation(POST/PUT/DELETE) 이후 관련 queryKey를 무효화하여 최신 데이터 자동 반영

  • 로딩 & 에러 상태 관리

    isLoading, isError, isFetching 같은 상태를 기본 제공

  • SSR 지원

    Next.js에서 서버사이드 데이터 prefetch 후, Hydrate로 클라이언트에 전달 가능


React Query가 필요한 이유

  • 일반적인 API 요청 방식

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);
    
    useEffect(() => {
      setLoading(true);
      fetch("/api/posts")
        .then((res) => res.json())
        .then(setData)
        .catch(setError)
        .finally(() => setLoading(false));
    }, []);
    

    로딩, 에러, 상태 업데이트 등 중복 코드가 많음.

  • React Query 방식

    1
    2
    3
    4
    5
    6
    
    import { useQuery } from "@tanstack/react-query";
    
    const { data, error, isLoading } = useQuery({
      queryKey: ["posts"],
      queryFn: () => fetch("/api/posts").then((res) => res.json()),
    });
    

    단 몇 줄로 데이터 가져오기 + 캐싱 + 로딩/에러 관리 가능.


Hook


useQuery

기본 데이터 조회 처리 (읽기 전용)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useQuery } from "@tanstack/react-query";
import { getUserInfo } from "@/lib/api/user";

export function Profile() {
  const { data, isLoading, error } = useQuery({
    queryKey: ["user"],
    queryFn: getUserInfo,
    staleTime: 1000 * 60, // 1분 동안 fresh
  });

  if (isLoading) return <p>로딩중...</p>;
  if (error) return <p>에러 발생!</p>;

  return <p>안녕하세요, {data.name}</p>;
}

useMutation

데이터 변경 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { addTodo } from "@/lib/api/todo";

export function AddTodoForm() {
  const queryClient = useQueryClient();
  const mutation = useMutation({
    mutationFn: addTodo,
    onSuccess: () => {
      // todo 목록 쿼리 무효화 → 자동 리패치
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  return (
    <button
      onClick={() => mutation.mutate({ title: "새로운 할 일" })}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? "추가중..." : "추가하기"}
    </button>
  );
}

useInfiniteQuery

페이지네이션 API + 무한 스크롤 UI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useInfiniteQuery } from "@tanstack/react-query";
import { getPosts } from "@/lib/api/post";

export function PostList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ["posts"],
      queryFn: ({ pageParam = 1 }) => getPosts(pageParam),
      getNextPageParam: (lastPage) => lastPage.nextPage ?? false,
    });

  return (
    <div>
      {data?.pages.flatMap((page) =>
        page.items.map((post) => <p key={post.id}>{post.title}</p>)
      )}

      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? "불러오는 중..." : "더보기"}
        </button>
      )}
    </div>
  );
}

useIsFetching, useIsMutating

전역 로딩 상태 제어

1
2
3
4
5
6
7
8
9
10
11
import { useIsFetching, useIsMutating } from "@tanstack/react-query";

function GlobalLoading() {
  const isFetching = useIsFetching();
  const isMutating = useIsMutating();

  if (isFetching || isMutating) {
    return <div className="overlay">로딩중...</div>;
  }
  return null;
}

HydrationBoundary

Next.js 서버에서 prefetch → 클라이언트에서 재사용 (SSR + Hydration)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// layout.tsx (서버 컴포넌트)
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import getQueryClient from "@/lib/queryClient";
import { prefetchUser } from "@/lib/api/user";

export default async function RootLayout({ children }) {
  const queryClient = getQueryClient();
  await queryClient.prefetchQuery({
    queryKey: ["user"],
    queryFn: prefetchUser,
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      {children}
    </HydrationBoundary>
  );
}

useQueryClient

useQueryClient 훅은 React Query에서 핵심적인 역할을 합니다.

React Query의 전역 상태 관리자(queryClient 인스턴스)React 컴포넌트 안에서 접근할 수 있게 해주는 훅입니다.

  • QueryClientProvider로 감싸진 곳에서만 사용 가능
  • 반환값은 queryClient 객체이며, 이 객체에는 캐시/요청을 직접 제어하는 메서드들이 들어 있음

사용 구조

  • 최상단에서 QueryClientProviderqueryClient 공급
1
2
3
4
5
6
7
8
9
10
// app/providers.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

export function Providers({ children }) {
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}
  • 컴포넌트에서 useQueryClient()로 접근
1
2
3
4
5
6
7
8
9
10
11
import { useQueryClient } from "@tanstack/react-query";

function MyComponent() {
  const queryClient = useQueryClient(); // 중앙 queryClient 가져오기

  const refreshTodos = () => {
    queryClient.invalidateQueries({ queryKey: ["todos"] });
  };

  return <button onClick={refreshTodos}>할 일 새로고침</button>;
}

queryClient 주요 메서드

조회 계열

  • getQueryData(queryKey) : 특정 queryKey의 캐시 데이터를 반환 (undefined 가능)

  • getQueriesData(filter) : 여러 쿼리의 캐시 데이터를 배열로 반환

    1
    
    const allTodos = queryClient.getQueriesData({ queryKey: ["todos"] });
    
  • getQueryState(queryKey) : 해당 쿼리의 상태(status, fetchStatus, dataUpdatedAt 등 메타데이터)를 반환

    1
    2
    
    const state = queryClient.getQueryState(["todos"]);
    console.log(state?.status); // 'pending' | 'success' | 'error'
    

수정 계열

  • setQueryData(queryKey, updater) : 캐시 데이터를 직접 수정

  • setQueriesData(filter, updater) : 여러 쿼리 데이터를 한 번에 수정


무효화 & 리패치 계열

  • invalidateQueries(filter) : 캐시 무효화 후, 관련 쿼리들을 재요청

    로그아웃, CRUD 후 갱신 시 유용

  • refetchQueries(filter) : 무효화하지 않고, 즉시 다시 요청

    1
    
    await queryClient.refetchQueries({ queryKey: ["todos"] });
    
  • resetQueries(filter) : 해당 쿼리를 초기 상태로 되돌림 (캐시 삭제 + 재요청 대기 상태)


취소 계열

  • cancelQueries(filter) : 진행 중인 요청 취소

삭제 계열

  • removeQueries(filter) : 특정 쿼리를 완전히 제거 (캐시까지 삭제)

  • clear() : 모든 쿼리와 캐시 초기화 (앱 전체 초기화 수준)


프리패치 & 유틸 계열

  • fetchQuery(queryKey, queryFn, options?) : 미리 데이터를 가져와 캐시에 저장 후 데이터를 직접 받음

    1
    2
    3
    4
    5
    6
    
    const user = await queryClient.fetchQuery({
      queryKey: ["user"],
      queryFn: fetchUserWithRefresh,
      staleTime: 1000 * 60 * 5, // 5분 동안 fresh
    });
    setUser(user); // Zustand에 동기화
    
  • prefetchQuery(queryKey, queryFn, options?) : 데이터를 가져와 캐시에만 저장 (결과 반환 ❌)

    SSR/SSG에서 HydrationBoundary에 넘길 때 주로 사용

    1
    2
    3
    4
    5
    
    await queryClient.prefetchQuery({
      queryKey: ["todos"],
      queryFn: fetchTodos,
      staleTime: 1000 * 60, // 1분
    });
    
  • ensureQueryData(queryKey) : 캐시에 데이터가 있으면 사용, 없으면 자동 fetch 후 캐시에 저장 + 결과 반환

    fetchQuery + 캐시 체크 결합판

    1
    2
    3
    4
    
    const user = await queryClient.ensureQueryData({
      queryKey: ["user"],
      queryFn: fetchUserWithRefresh,
    });
    
  • isFetching(filter?) : 현재 진행 중인 fetch 개수 반환

    1
    2
    3
    
    if (queryClient.isFetching({ queryKey: ["todos"] })) {
      console.log("todos 불러오는 중...");
    }
    
  • isMutating(filter?) : 현재 진행 중인 mutation 개수 반환


옵션 최적화

훅에서는 옵션(staleTime, cacheTime, enabled, retry, select)을 직접 넣어 데이터 흐름을 제어
메서드에서는 옵션을 인자로 넣어 캐시 조작, prefetch, 무효화 전략을 세밀하게 제어

  • staleTime : 데이터를 fresh 상태로 유지할 시간
  • cacheTime : 비사용 상태로 바뀐 후, 캐시를 메모리에 보관하는 시간

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    // 훅
    useQuery({
      queryKey: ["user", id],
      queryFn: fetchUser,
      staleTime: 1000 * 60, // 1분 동안 fresh
      cacheTime: 1000 * 60 * 30, // 사용 안 되면 30분 후 가비지 컬렉션
    });
    
    // 메서드
    queryClient.prefetchQuery({
      queryKey: ["todos"],
      queryFn,
      staleTime: 1000 * 60,
    });
    queryClient.setQueryDefaults(["todos"], { cacheTime: 1000 * 60 * 5 });
    
  • enabled : 쿼리 자동 실행을 켜/끄는 boolean, 의존 변수가 준비될 때까지 fetch를 막음

    훅 전용 옵션

    1
    2
    3
    4
    5
    
    useQuery({
      queryKey: ["user", userId],
      queryFn: () => fetch(`/api/users/${userId}`),
      enabled: Boolean(userId), // userId가 있어야만 실행
    });
    
  • retry, retryDelay : 쿼리/뮤테이션이 실패했을 때 몇 번 재시도할지 또는 조건부 재시도를 어떻게 할지 설정

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    // 훅
    useQuery({
      queryKey: ["profile"],
      queryFn: fetchProfile,
      retry: 2, // 실패 시 총 2번 재시도 (기본은 0~3 등 버전별 다름)
      retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000), // 지수백오프
    });
    
    // 메서드
    useMutation({ mutationFn, retry: 1 });
    
  • select : 원본 data에서 필요한 부분만 골라 컴포넌트에 전달

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    // 훅
    useQuery({
      queryKey: ["posts"],
      queryFn: fetchPosts,
      select: (data) => data.items.map((i) => ({ id: i.id, title: i.title })),
    });
    
    // 메서드
    queryClient.setQueryData(["todos"], (old) => old.map((todo) => todo.title));
    

예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { useQuery } from "@tanstack/react-query";
import axios from "axios";

const fetchTodos = async () => {
  const { data } = await axios.get("/api/todos");
  return data;
};

function TodoList() {
  const { data, error, isLoading } = useQuery({
    queryKey: ["todos"],
    queryFn: fetchTodos,
    staleTime: 1000 * 60, // 1분 동안은 fresh → 재요청 안 함
    cacheTime: 1000 * 60 * 5, // 5분 지나면 캐시 삭제
    enabled: true, // false면 아예 자동 실행 안 함
    retry: 2, // 실패 시 2번만 재시도
    select: (data) => data.map((todo) => todo.title), // 데이터 변환
  });

  if (isLoading) return <p>로딩 중...</p>;
  if (error) return <p>에러 발생!</p>;

  return (
    <ul>
      {data?.map((title, idx) => (
        <li key={idx}>{title}</li>
      ))}
    </ul>
  );
}
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.