리액트쿼리 자습서

share · 2026-6-16

← 리스트로

React Query / TanStack Query 정리본

기준: @tanstack/react-query v5


1. React Query의 기본 관점

React Query는 서버 상태(server state) 를 관리하는 도구입니다.

서버 상태
→ API로 가져오는 데이터
→ 내가 직접 소유하지 않는 데이터
→ 언제든 서버에서 바뀔 수 있는 데이터

예:

게시글 목록
댓글 목록
사용자 정보
검색 결과
상품 목록

React Query가 해주는 일:

데이터 가져오기
캐싱
로딩/에러 상태 관리
중복 요청 제거
백그라운드 refetch
stale/fresh 판단
무한 스크롤
mutation 이후 캐시 갱신
SSR hydration

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

클라이언트 상태 (useState, zustand, redux 등)
→ 내 앱이 소유하고 내가 동기적으로 통제

서버 상태 (React Query)
→ 비동기, 원격, 누가 언제 바꿀지 모름
→ 캐싱/동기화/무효화가 핵심 관심사

2. 셋업 — QueryClient / QueryClientProvider

React Query를 쓰려면 앱 최상단에 QueryClient를 만들고 QueryClientProvider로 감쌉니다.

import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60,
      gcTime: 1000 * 60 * 5,
      retry: 1,
      refetchOnWindowFocus: true,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Root />
    </QueryClientProvider>
  );
}

핵심:

QueryClient
→ 캐시를 들고 있는 객체

QueryClientProvider
→ 하위 컴포넌트가 queryClient에 접근하게 해줌

defaultOptions
→ 모든 query/mutation에 적용될 기본값

useQueryClient() 훅으로 어디서든 꺼낼 수 있습니다.

const queryClient = useQueryClient();

queryClient.invalidateQueries({ queryKey: ['todos'] });

SSR에서는 요청마다 새 QueryClient를 만들어야 합니다(사용자 간 데이터 격리).


3. useQuery — 가장 기본

useQuery서버에서 데이터를 조회할 때 쓰는 훅입니다.

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

function Todos() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });

  if (isPending) return <div>로딩 중...</div>;
  if (isError) return <div>에러: {error.message}</div>;

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

필수 두 가지:

queryKey
→ 이 데이터를 캐시에서 식별할 키

queryFn
→ 실제 데이터를 가져오는 함수 (Promise 반환)

useQuery가 돌려주는 주요 값 (v5 기준)

data            → 성공 응답 데이터
error           → 실패 시 에러 객체

isPending       → 아직 데이터가 없고 fetch 중 (v4의 isLoading)
isLoading       → isPending && isFetching (초기 hard loading)
isFetching      → 백그라운드 포함, 어떤 종류든 fetch 중
isSuccess       → 데이터 있음
isError         → 에러 상태
isStale         → 캐시 데이터가 stale 상태인지

status          → 'pending' | 'error' | 'success'
fetchStatus     → 'fetching' | 'paused' | 'idle'

refetch()       → 수동 재조회

핵심 구분:

status
→ 데이터를 갖고 있느냐 (pending/success/error)

fetchStatus
→ 지금 네트워크가 일어나고 있느냐 (fetching/paused/idle)

예시:

캐시에 데이터 있음 + 백그라운드 refetch 중
→ status: 'success'
→ fetchStatus: 'fetching'
→ isFetching: true, isPending: false

이 구분을 알면 "데이터는 보여주면서 살짝 로딩 인디케이터"를 쉽게 만들 수 있습니다.

{isFetching && <SmallSpinner />}
<TodoList todos={data} />

4. Query Keys

query key는 이 데이터를 식별하는 고유 ID 입니다. 배열로 씁니다.

useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
useQuery({ queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId) });
useQuery({
  queryKey: ['todos', { status, page }],
  queryFn: () => fetchTodos({ status, page }),
});

규칙

1. queryKey가 같으면 같은 캐시 엔트리를 공유
2. queryKey가 바뀌면 React Query는 새 데이터로 간주하고 다시 fetch
3. queryFn에서 쓰는 모든 변수는 queryKey에 포함해야 함
   → 그래야 변수 바뀔 때 자동으로 다시 fetch됨

['todos', { page: 1 }]처럼 객체를 키에 넣어도 됩니다. 내부적으로 deterministic하게 직렬화됩니다(키 순서 달라도 같게 취급).

prefix 매칭

invalidateQueries, removeQueries 같은 캐시 조작은 기본적으로 prefix 매칭 입니다.

queryClient.invalidateQueries({ queryKey: ['todos'] });

이건 다음을 모두 매칭합니다.

['todos']
['todos', 1]
['todos', { page: 1 }]
['todos', 'detail', 3]

정확히 하나만 잡으려면:

queryClient.invalidateQueries({
  queryKey: ['todos'],
  exact: true,
});

Query Key Factory 패턴

규모가 커지면 key를 한 곳에서 관리합니다.

export const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: Filters) => [...todoKeys.lists(), filters] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: number) => [...todoKeys.details(), id] as const,
};

장점:

- 오타 방지
- invalidate 범위 조절 쉬움 (예: todoKeys.lists()만 무효화)
- 리팩토링 안전

5. Query Function

queryFn은 Promise를 반환하는 함수입니다.

useQuery({
  queryKey: ['todo', todoId],
  queryFn: () => fetchTodo(todoId),
});

context 인자

queryFn은 인자로 { queryKey, signal, meta, pageParam } 같은 context를 받습니다.

useQuery({
  queryKey: ['todo', todoId],
  queryFn: async ({ queryKey, signal }) => {
    const [, id] = queryKey;
    const res = await fetch(`/todos/${id}`, { signal });
    if (!res.ok) throw new Error('Network error');
    return res.json();
  },
});

에러는 throw로 알림

React Query는 Promise가 reject되거나 throw되면 에러로 판단 합니다.

queryFn: async () => {
  const res = await fetch('/todos');
  if (!res.ok) {
    throw new Error('Failed');
  }
  return res.json();
};

fetch는 4xx/5xx에서도 reject하지 않으므로 직접 throw해야 합니다. axios는 reject하므로 그대로 둬도 됩니다.


6. enabled — 조건부 쿼리

특정 조건이 만족돼야 fetch하고 싶을 때.

const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  enabled: !!userId,
});

enabled가 false면:

queryFn 실행 안 함
status는 'pending' 유지
isPending: true, fetchStatus: 'idle'

Dependent Queries (의존 쿼리)

A의 결과로 B를 가져와야 하는 경우.

const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: () => fetchUserByEmail(email),
});

const { data: projects } = useQuery({
  queryKey: ['projects', user?.id],
  queryFn: () => fetchProjects(user.id),
  enabled: !!user?.id,
});

흐름:

user fetch 끝 → user.id 생김 → enabled true → projects fetch

7. select — 데이터 변환

select로 캐시 원본은 그대로 두고, 컴포넌트에서 쓸 모양만 가공할 수 있습니다.

const { data: todoTitles } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  select: (todos) => todos.map((t) => t.title),
});

특징:

- 캐시에 저장되는 데이터는 원본
- select 결과만 컴포넌트로 전달
- select 함수가 같은 input에 대해 같은 output을 내면 (referential equality)
  불필요한 리렌더 방지됨

같은 캐시에서 컴포넌트마다 다른 모양을 뽑아 쓸 수 있다는 게 강점입니다.


8. staleTime과 gcTime

staleTime

staleTime데이터를 얼마 동안 fresh로 볼 것인가 입니다.

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 1000 * 60,
});

의미:

fetch 성공 후 1분 동안은 fresh
그동안 새로 마운트되거나 window focus가 돌아와도 보통 refetch 안 함

핵심:

fresh
→ 아직 믿을 만한 데이터

stale
→ 캐시에 있긴 하지만 최신인지 다시 확인할 필요가 있는 데이터

중요한 점:

stale이라고 해서 못 쓰는 데이터가 아님
stale이어도 캐시에 있으면 일단 화면에 보여줄 수 있음

즉:

staleTime: 0
→ 데이터를 받자마자 stale
→ 하지만 캐시 데이터는 있음
→ 화면에는 바로 보여주고 백그라운드 refetch 가능

gcTime

gcTimeinactive query를 캐시에 얼마 동안 보관할 것인가 입니다.

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  gcTime: 1000 * 60 * 5,
});

의미:

해당 쿼리를 쓰는 컴포넌트가 모두 사라짐
→ inactive 상태
→ 5분 동안 캐시에 보관
→ 5분 안에 다시 마운트되면 캐시 사용
→ 5분 지나면 캐시 삭제

v4에서는 cacheTime, v5에서는 gcTime이라고 부릅니다.

정리:

staleTime → 데이터의 신선도
gcTime    → 캐시 보관 시간

9. Refetch 옵션들

언제 자동으로 refetch가 일어나는가는 보통 다음 옵션으로 제어합니다.

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  refetchOnMount: true,
  refetchOnWindowFocus: true,
  refetchOnReconnect: true,
  refetchInterval: false,
});

refetchOnMount

true       → 컴포넌트 마운트 시 stale이면 refetch
false      → 마운트 시 refetch 안 함 (캐시만 보여줌)
'always'   → fresh여도 무조건 refetch

refetchOnWindowFocus

true       → 탭/창 포커스 돌아오면 stale 쿼리 refetch
false      → 끔
'always'   → 무조건 refetch

기본 true. SaaS UI에서 정말 유용한데, 로그/대시보드 같은 곳에선 자주 끕니다.

refetchOnReconnect

오프라인 → 온라인 복귀 시 refetch.

refetchInterval

폴링.

refetchInterval: 5000;

5초마다 자동 refetch.

refetchInterval: (query) =>
  query.state.data?.status === 'done' ? false : 3000;

조건에 따라 멈출 수도 있음.

refetchIntervalInBackground: true로 두면 탭이 백그라운드일 때도 폴링.

수동 refetch

const { refetch } = useQuery({ ... });

<button => refetch()}>새로고침</button>

10. retry / 에러 처리

retry

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  retry: 3,
  retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
});
retry: 3            → 실패 시 최대 3번 재시도
retry: false        → 재시도 안 함
retry: (n, error)   → 함수로 조건 제어

함수형 예:

retry: (failureCount, error) => {
  if (error.status === 404) return false;
  return failureCount < 3;
};

retry 전부 실패해야 비로소 isError: true가 됩니다. 그 전에는 계속 pending입니다.

Error Boundary로 처리

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  throwOnError: true,
});

throwOnError: true면 에러를 컴포넌트에서 throw하고 React <ErrorBoundary>가 잡습니다.

함수형도 가능:

throwOnError: (error) => error.status >= 500;

서버 5xx만 에러 바운더리로 보내는 식.

전역 에러 핸들러

QueryCacheonError를 줄 수 있습니다.

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      toast.error(`데이터 로딩 실패: ${error.message}`);
    },
  }),
});

각 컴포넌트마다 토스트 호출하지 않아도 됩니다.


11. Parallel Queries / useQueries

단순 병렬

같은 컴포넌트에서 useQuery를 두 번 부르면 자동으로 병렬 fetch입니다.

const users = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
const teams = useQuery({ queryKey: ['teams'], queryFn: fetchTeams });

동적 병렬 — useQueries

쿼리 개수가 동적일 때.

import { useQueries } from '@tanstack/react-query';

const results = useQueries({
  queries: userIds.map((id) => ({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id),
  })),
});

const isAllLoaded = results.every((r) => r.isSuccess);
const allUsers = results.map((r) => r.data);

훅 규칙(반복문 안에서 훅 못 부름) 때문에 동적 개수일 때는 useQueries가 필요합니다.

combine 옵션으로 결과를 한 번에 합칠 수도 있습니다.

useQueries({
  queries: [...],
  combine: (results) => ({
    data: results.map((r) => r.data),
    pending: results.some((r) => r.isPending),
  }),
});

12. Infinite Queries

useInfiniteQuery는 페이지를 계속 이어붙이는 쿼리입니다.

1페이지 가져옴
더보기 클릭
2페이지 가져옴
기존 1페이지 뒤에 2페이지 붙임

기본 형태:

const {
  data,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
} = useInfiniteQuery({
  queryKey: ['articles'],
  queryFn: ({ pageParam }) => fetchArticles(pageParam),
  initialPageParam: 1,
  getNextPageParam: (lastPage) => lastPage.nextPage,
});

핵심 옵션

initialPageParam     → 첫 요청에 사용할 pageParam
getNextPageParam     → 다음 페이지 요청에 사용할 pageParam 결정
fetchNextPage        → 다음 페이지 추가 요청
hasNextPage          → 다음 페이지가 있는지 여부
isFetchingNextPage   → 다음 페이지 가져오는 중인지

data 구조

{
  pages: [
    { items: [...], nextPage: 2 },
    { items: [...], nextPage: 3 },
  ],
  pageParams: [1, 2]
}

렌더링:

const articles = data?.pages.flatMap((page) => page.items) ?? [];

13. useInfiniteQuery의 refetch

fetchNextPage()refetch()는 다릅니다.

fetchNextPage(); // 다음 페이지 추가 조회
refetch();       // 현재 infinite query 데이터를 다시 조회

이미 1, 2, 3페이지까지 가져온 상태에서 refetch()하면 4페이지를 가져오는 게 아니라, 기존에 쌓인 페이지들을 다시 조회합니다.

첫 페이지만 다시 refetch하고 싶을 때

const handleRefresh = async () => {
  queryClient.setQueryData(queryKey, (oldData: any) => {
    if (!oldData) return oldData;
    return {
      ...oldData,
      pages: oldData.pages.slice(0, 1),
      pageParams: oldData.pageParams.slice(0, 1),
    };
  });

  await refetch();
};
1. 캐시의 infinite query 데이터를 첫 페이지만 남김
2. refetch()
3. React Query는 남아 있는 1페이지만 다시 조회

14. setQueryData와 oldData

queryClient.setQueryData(queryKey, (oldData) => {
  if (!oldData) return oldData;
  return {
    ...oldData,
    pages: oldData.pages.slice(0, 1),
    pageParams: oldData.pageParams.slice(0, 1),
  };
});

oldData는 현재 React Query 캐시에 들어 있는 데이터입니다.

...oldData를 쓰는 이유:

기존 객체의 다른 속성을 보존하는 습관적/방어적 패턴

일반 useQuery에서도 마찬가지:

return {
  ...oldData,
  items: newItems,
};

15. Placeholder Query Data

placeholderData진짜 API 응답이 오기 전까지 임시로 보여줄 데이터 입니다.

const { data, isPlaceholderData } = useQuery({
  queryKey: ['article', articleId],
  queryFn: () => fetchArticle(articleId),
  placeholderData: {
    id: articleId,
    title: '불러오는 중...',
    content: '',
  },
});

특징:

캐시에 저장되지 않음
임시 표시용 데이터
isPlaceholderData로 구분 가능

pagination에서

import { keepPreviousData } from '@tanstack/react-query';

const { data, isPlaceholderData } = useQuery({
  queryKey: ['articles', page],
  queryFn: () => fetchArticles(page),
  placeholderData: keepPreviousData,
});
page가 1 → 2로 바뀜
2페이지 요청 중
그동안 1페이지 데이터를 임시로 보여줌
→ 깜빡임 없음

목록 → 상세 이동

placeholderData: () => {
  const articles = queryClient.getQueryData<ArticleSummary[]>(['articles']);
  return articles?.find((a) => a.id === articleId);
};

목록 데이터가 상세보다 부족하면 initialData보다 placeholderData가 더 적절합니다.


16. Initial Query Data

initialData캐시에 처음부터 데이터를 넣고 시작하는 옵션 입니다.

const { data } = useQuery({
  queryKey: ['article', articleId],
  queryFn: () => fetchArticle(articleId),
  initialData: initialArticle,
});

특징:

캐시에 저장됨
실제 초기 데이터로 취급됨
data가 처음부터 있음

placeholderData와 차이:

placeholderData → 임시 표시용, 캐시에 저장 안 됨
initialData     → 실제 초기 데이터, 캐시에 저장됨

staleTime과의 관계

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  initialData: initialTodos,
  staleTime: 0,
});

staleTime이 0이어도 initialData를 안 쓰는 게 아닙니다.

1. initialData로 먼저 화면 그림
2. staleTime 0이라 즉시 stale
3. 백그라운드 refetch
4. 서버 응답 오면 데이터 교체

initialDataUpdatedAt

useQuery({
  queryKey: ['todo', todoId],
  queryFn: () => fetchTodo(todoId),
  initialData: initialTodo,
  initialDataUpdatedAt: initialTodoUpdatedAt,
  staleTime: 1000 * 60,
});

없으면 React Query는 "지금 들어온 데이터"로 간주합니다. 있으면 그 시각 기준으로 staleTime을 계산합니다.

목록 캐시에서 상세 초기값을 만들 때 자주 씁니다.

initialData: () =>
  queryClient
    .getQueryData<Todo[]>(['todos'])
    ?.find((todo) => todo.id === todoId),

initialDataUpdatedAt: () =>
  queryClient.getQueryState(['todos'])?.dataUpdatedAt,

17. Prefetching

Prefetching은 사용자가 실제로 보기 전에 데이터를 미리 가져와 캐시에 넣어두는 것 입니다.

queryClient.prefetchQuery({
  queryKey: ['todo', todoId],
  queryFn: () => fetchTodo(todoId),
});

링크 hover 시 미리 가져오기:

<a
  href={`/todos/${todo.id}`}
  => {
    queryClient.prefetchQuery({
      queryKey: ['todo', todo.id],
      queryFn: () => fetchTodo(todo.id),
      staleTime: 1000 * 60,
    });
  }}
>
  Todo 상세 보기
</a>

상세 페이지에서는 같은 queryKey를 씁니다.

useQuery({
  queryKey: ['todo', todoId],
  queryFn: () => fetchTodo(todoId),
  staleTime: 1000 * 60,
});

핵심:

prefetch와 useQuery의 queryKey가 같아야 캐시 재사용

prefetch의 staleTime

prefetch에 staleTime을 주면 그 시간 동안 fresh로 간주됩니다. 실제 화면 useQuery에도 같은 값을 두는 게 안전합니다.

const TODO_STALE_TIME = 1000 * 60;

queryClient.prefetchQuery({
  queryKey: ['todo', todoId],
  queryFn: () => fetchTodo(todoId),
  staleTime: TODO_STALE_TIME,
});

useQuery({
  queryKey: ['todo', todoId],
  queryFn: () => fetchTodo(todoId),
  staleTime: TODO_STALE_TIME,
});

staleTime: 0이어도 prefetch는 의미가 있습니다.

캐시에 데이터 있음 → 화면에 바로 보여줌 → 백그라운드 refetch

18. Mutations

useMutation은 서버 데이터를 생성/수정/삭제하거나 서버 side effect를 일으킬 때 씁니다.

const mutation = useMutation({
  mutationFn: async (newTodo) => {
    const res = await axios.post('/todos', newTodo);
    return res.data;
  },
});

실행:

mutation.mutate({ title: 'Do Laundry' });

Query와 Mutation 차이

useQuery      → 서버 데이터 조회
useMutation   → 서버 데이터 변경
GET /todos        → useQuery
POST /todos       → useMutation
PUT /todos/1      → useMutation
DELETE /todos/1   → useMutation

mutation 상태 (v5)

isIdle      → 아직 실행 안 됨
isPending   → 실행 중 (v4의 isLoading)
isSuccess   → 성공
isError     → 실패

mutation.data    → 성공 응답
mutation.error   → 실패 에러

mutate vs mutateAsync

mutation.mutate(input);                  // void 반환, 콜백으로 처리
const data = await mutation.mutateAsync(input); // Promise 반환

mutateAsync는 await으로 흐름을 이어쓰기 좋지만, 에러 처리를 잊으면 unhandled rejection이 됩니다.


19. Mutation 콜백

const mutation = useMutation({
  mutationFn: addTodo,

  onMutate: (variables) => {
    // mutationFn 실행 직전
  },
  onError: (error, variables, context) => {
    // 실패
  },
  onSuccess: (data, variables, context) => {
    // 성공
  },
  onSettled: (data, error, variables, context) => {
    // 성공/실패 관계없이 마지막
  },
});

성공 흐름:

mutate 호출 → onMutate → mutationFn → onSuccess → onSettled

실패 흐름:

mutate 호출 → onMutate → mutationFn → onError → onSettled

인자:

variables  → mutate(...)에 넘긴 값
data       → mutationFn 성공 결과
error      → mutationFn 실패 에러
context    → onMutate에서 return한 값

20. Mutation 이후 invalidateQueries / setQueryData

invalidateQueries

const mutation = useMutation({
  mutationFn: createTodo,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});
todo 생성 성공
→ todos 목록은 낡았을 수 있음
→ stale 처리
→ 활성 쿼리면 다시 fetch

가장 단순하고 안전한 방식.

setQueryData

const mutation = useMutation({
  mutationFn: createTodo,
  onSuccess: (createdTodo) => {
    queryClient.setQueryData(['todos'], (oldTodos = []) => [
      ...oldTodos,
      createdTodo,
    ]);
  },
});
invalidateQueries → 서버에서 다시 조회해서 맞춤
setQueryData      → 내가 직접 캐시를 수정

21. Optimistic Update

낙관적 업데이트는 서버 성공을 기다리지 않고 화면을 먼저 바꾸는 것 입니다.

const mutation = useMutation({
  mutationFn: addTodo,

  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });

    const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

    queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
      ...old,
      newTodo,
    ]);

    return { previousTodos };
  },

  onError: (_error, _newTodo, context) => {
    queryClient.setQueryData(['todos'], context?.previousTodos);
  },

  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

흐름:

1. mutation 실행
2. onMutate에서 기존 데이터 백업
3. 캐시에 새 todo를 먼저 추가
4. 화면에 즉시 반영
5. 실패하면 previousTodos로 rollback
6. 성공/실패와 관계없이 invalidateQueries로 최종 동기화

cancelQueries를 왜 하나?

await queryClient.cancelQueries({ queryKey: ['todos'] });
진행 중이던 todos refetch가 늦게 도착해서
방금 setQueryData로 넣은 optimistic update를 덮어쓰는 것 방지
→ 시간차 응답으로 인한 race condition 방어

22. mutate 호출 시 콜백

콜백은 두 군데에 줄 수 있습니다.

useMutation에 주는 콜백 (공통 처리)

const mutation = useMutation({
  mutationFn: addTodo,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});
캐시 갱신, 공통 에러 처리, 데이터 정합성

mutate 호출에 주는 콜백 (이번 호출 전용)

mutation.mutate(todo, {
  onSuccess: () => {
    closeModal();
    toast.success('추가 완료');
  },
});
모달 닫기, 토스트, 페이지 이동 같은 UI 처리

둘 다 있으면 useMutation의 콜백이 먼저, mutate 호출 콜백이 나중에 실행됩니다.

중요:

mutate 호출에 넘긴 콜백은 컴포넌트가 언마운트되면 실행되지 않을 수 있음
→ 중요한 캐시 처리는 useMutation 쪽에 두는 게 안전

23. mutation.reset()

mutation 상태를 초기화합니다.

{mutation.isError && (
  <div => mutation.reset()}>에러 발생</div>
)}
error 제거, data 제거, status idle로 초기화

서버 데이터를 되돌리는 게 아니라 mutation 객체 상태만 초기화입니다.


24. Mutation Persistence / Offline Mutation

오프라인 상태에서 mutation 실행
→ 요청이 paused 상태로 남음
→ 앱이 꺼져도 저장
→ 앱 재시작 후 복구
→ 온라인 되면 이어서 실행

setMutationDefaults

queryClient.setMutationDefaults(['addTodo'], {
  mutationFn: addTodo,
  retry: 3,
});

이유:

dehydrate로 저장되는 것은 mutation 상태와 variables
함수 자체는 저장할 수 없음
앱 재시작 후 mutationKey를 보고 어떤 mutationFn을 실행해야 하는지 알려줘야 함

컴포넌트에서:

const mutation = useMutation({ mutationKey: ['addTodo'] });
mutation.mutate({ title: 'title' });

dehydrate / hydrate / resumePausedMutations

const state = dehydrate(queryClient);    // 저장 가능한 객체로 꺼냄
hydrate(queryClient, state);             // 복구
queryClient.resumePausedMutations();     // paused mutation 재실행

실무에서는 직접 구현보다 persistQueryClient를 씁니다.

persistQueryClient({
  queryClient,
  persister,
});

이건 "앱 시작 시 자동 저장/복구 시스템을 켜는 코드"입니다.


25. Query Cancellation

기본적으로 React Query는 사용하지 않는 쿼리를 무조건 네트워크 취소하지 않습니다.

컴포넌트 언마운트
→ 요청이 계속 진행될 수 있음
→ 응답이 오면 캐시에 저장될 수 있음

이게 나쁜 건 아닙니다. 다시 돌아왔을 때 캐시 데이터로 바로 보여줄 수 있으니까.

실제 요청 취소하려면 signal 사용

useQuery({
  queryKey: ['todos'],
  queryFn: async ({ signal }) => {
    const res = await fetch('/todos', { signal });
    return res.json();
  },
});

수동 취소:

queryClient.cancelQueries({ queryKey: ['todos'] });
cancelQueries → React Query에게 이 쿼리 취소 요청
signal 사용   → 실제 네트워크 요청 abort 가능

26. Scroll Restoration

브라우저는 뒤로가기 시 스크롤 위치를 복원하려고 합니다. 하지만 SPA에서 비동기 데이터가 있으면:

뒤로가기
→ 리스트 페이지 렌더링
→ 데이터 없음
→ 높이 부족
→ 브라우저가 예전 scrollY 복원 실패
→ 데이터 나중에 도착
→ 위치가 어긋남

React Query를 쓰면:

뒤로가기
→ 캐시에 리스트 데이터 있음
→ 즉시 리스트 렌더링
→ DOM 높이가 빨리 복원
→ 브라우저 기본 스크롤 복원이 잘 작동

중요:

React Query가 직접 scrollY를 저장하거나 window.scrollTo 하는 건 아님
캐시 덕분에 브라우저의 기본 스크롤 복원이 잘 되도록 도와주는 것

잘 안 될 수 있는 경우:

내부 div 스크롤
가상 스크롤
이미지/광고로 높이가 나중에 바뀜
캐시가 사라짐
라우터가 스크롤을 강제로 top으로 보냄

27. QueryFilters / MutationFilters

필터는 캐시 안에서 어떤 query/mutation만 대상으로 할지 고르는 조건 객체 입니다.

QueryFilters

queryClient.invalidateQueries({ queryKey: ['posts'] });

기본적으로 prefix 매칭.

['posts']
['posts', 1]
['posts', { page: 1 }]

정확히 하나만:

queryClient.invalidateQueries({
  queryKey: ['posts'],
  exact: true,
});

자주 쓰는 필터:

queryClient.cancelQueries({ queryKey: ['todos'] });
queryClient.removeQueries({ queryKey: ['todos'], type: 'inactive' });
queryClient.refetchQueries({ type: 'active' });
queryClient.invalidateQueries({ queryKey: ['posts'], stale: true });

type:

'active'    → 현재 마운트된 쿼리만
'inactive'  → 마운트 안 된 쿼리만
'all'       → 전부 (기본)

predicate로 임의 조건:

queryClient.invalidateQueries({
  predicate: (query) => query.queryKey[0] === 'posts',
});

MutationFilters

const count = queryClient.isMutating({ mutationKey: ['post'] });

또는 렌더링에서:

const isSavingPost = useIsMutating({ mutationKey: ['post'] }) > 0;

post 관련 mutation이 하나라도 진행 중이면 true.


28. SSR / SSG에서 React Query 사용

크게 두 가지 방식:

1. initialData 방식
2. dehydrate / hydrate 방식

initialData 방식 (간단)

export async function getStaticProps() {
  const posts = await getPosts();
  return { props: { posts } };
}

function Posts({ posts }) {
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
    initialData: posts,
  });
}

장점/단점:

+ 간단함
- 깊은 컴포넌트까지 props 전달 필요
- 여러 쿼리가 있으면 번거로움

dehydrate / hydrate 방식 (정석)

서버:

export async function getStaticProps() {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  });

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
}

클라이언트:

<HydrationBoundary state={dehydratedState}>
  <Posts />
</HydrationBoundary>

컴포넌트는 그냥 useQuery:

function Posts() {
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  });
}

장점:

캐시를 그대로 복구
깊은 자식에서도 바로 사용 가능
여러 쿼리 prefetch 좋음

29. Custom SSR Framework Hydration

Next.js가 아닌 직접 SSR 서버.

서버:

const queryClient = new QueryClient();

await queryClient.prefetchQuery({
  queryKey: ['posts'],
  queryFn: getPosts,
});

const dehydratedState = dehydrate(queryClient);

const html = renderToString(
  <QueryClientProvider client={queryClient}>
    <HydrationBoundary state={dehydratedState}>
      <App />
    </HydrationBoundary>
  </QueryClientProvider>
);

HTML에 상태 포함:

<script>
  window.__REACT_QUERY_STATE__ = ...
</script>

클라이언트:

const dehydratedState = window.__REACT_QUERY_STATE__;
const queryClient = new QueryClient();

hydrateRoot(
  document.getElementById('root'),
  <QueryClientProvider client={queryClient}>
    <HydrationBoundary state={dehydratedState}>
      <App />
    </HydrationBoundary>
  </QueryClientProvider>
);

중요:

- 서버와 클라이언트가 같은 dehydratedState로 렌더 → hydration mismatch 방지
- SSR에서는 요청마다 새 QueryClient (사용자 간 데이터 격리)

30. SSR hydration 주의사항

성공한 쿼리만 dehydrate에 포함됨

서버에서 실패
→ dehydratedState에 없음
→ 클라이언트는 처음부터 fetch 가능

서버에서 404/500을 직접 처리하고 싶으면 fetchQuery와 try/catch:

try {
  await queryClient.fetchQuery({
    queryKey: ['post', postId],
    queryFn: () => getPost(postId),
  });
} catch (error) {
  return { notFound: true };
}

stale 여부는 서버 fetch 시점 기준

서버에서 10:00에 fetch
staleTime 1분
→ 10:01까지 fresh

staleTime 0이면 클라이언트에서 다시 fetch

서버에서 fetch → HTML 포함 → 클라이언트 hydrate
→ stale → 백그라운드 refetch 발생

이중 fetch 줄이려면:

new QueryClient({
  defaultOptions: {
    queries: { staleTime: 1000 * 60 },
  },
});

CDN 캐싱과 조합

마크업은 빠르게 제공
데이터 최신성은 백그라운드 refetch로 보완

31. 캐시 생명주기 예제

가정: staleTime: 0, gcTime: 5분.

1. useQuery(['todos']) 첫 마운트
   → 캐시 없음 → hard loading → fetch → 캐시 저장
   → staleTime 0이라 즉시 stale

2. 같은 queryKey의 두 번째 useQuery 마운트
   → 캐시 데이터 즉시 반환
   → stale이라 백그라운드 refetch
   → 같은 queryKey라 요청은 하나로 공유

3. 두 컴포넌트 모두 언마운트
   → active observer 없음 → inactive query → gcTime 타이머 시작

4. 5분 안에 다시 마운트
   → 캐시 데이터 즉시 반환
   → stale이면 백그라운드 refetch

5. 5분 동안 아무도 안 씀
   → garbage collection → 다음 마운트는 다시 hard loading

32. Default Query Function

앱 전체 기본 queryFn을 등록할 수 있습니다.

const defaultQueryFn = async ({ queryKey }) => {
  const { data } = await axios.get(
    `https://api.example.com${queryKey[0]}`
  );
  return data;
};

const queryClient = new QueryClient({
  defaultOptions: {
    queries: { queryFn: defaultQueryFn },
  },
});

queryFn 생략 가능:

useQuery({ queryKey: ['/posts'] });

특정 쿼리에 queryFn 주면 덮어씁니다. 실무에서는 default queryFn보다 queryOptions 패턴이 깔끔합니다.

import { queryOptions } from '@tanstack/react-query';

const postsOptions = queryOptions({
  queryKey: ['posts'],
  queryFn: getPosts,
  staleTime: 1000 * 60,
});

useQuery(postsOptions);
queryClient.prefetchQuery(postsOptions);

같은 쿼리 설정을 여러 곳에서 안전하게 재사용 가능.


33. DevTools

개발할 때 거의 필수:

import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

<QueryClientProvider client={queryClient}>
  <App />
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

볼 수 있는 것:

- 현재 캐시에 있는 모든 query/mutation
- 각 query의 상태 (fresh/stale/fetching/paused/inactive)
- queryKey, data, error
- 수동 invalidate, refetch, remove

stale인지 fresh인지가 안 잡힐 때 가장 먼저 켭니다.


34. 최종 핵심 요약

React Query는 서버 상태를 캐싱하고,
staleTime으로 신선도를 판단하고,
gcTime으로 캐시 보관 시간을 관리한다.
useQuery는 queryKey + queryFn 한 쌍.
queryKey가 바뀌면 자동으로 다시 fetch한다.
queryFn에서 쓰는 모든 변수는 queryKey에 들어가야 한다.
status는 데이터 유무, fetchStatus는 네트워크 진행 여부.
isFetching && data 있음 → 데이터 보여주면서 백그라운드 갱신.
enabled로 조건부 쿼리, select로 변환, 둘 다 캐시는 원본 유지.
의존 쿼리는 enabled로 체이닝.
refetchOnWindowFocus, refetchOnMount, refetchInterval로
자동 refetch 시점을 제어한다.
useInfiniteQuery는 pages/pageParams 구조로 페이지를 쌓는다.
fetchNextPage는 다음 페이지 추가, refetch는 현재 쌓인 페이지들을 다시 조회.
placeholderData는 임시 표시용이고 캐시에 저장되지 않는다.
initialData는 실제 초기 데이터로 캐시에 저장된다.
prefetchQuery는 나중에 쓸 데이터를 미리 캐시에 받아두는 것.
staleTime이 0이어도 캐시 데이터가 있으면 일단 보여줄 수 있다.
useMutation은 서버 데이터를 변경한다.
성공 후 invalidateQueries로 다시 조회하거나, setQueryData로 직접 수정.
optimistic update: onMutate에서 캐시를 먼저 바꾸고,
onError에서 rollback, onSettled에서 invalidateQueries로 최종 동기화.
cancelQueries는 늦게 도착한 응답이 optimistic update를 덮어쓰지 못하게 막는다.
QueryFilters는 캐시 안에서 대상을 고르는 조건.
queryKey는 prefix 매칭, exact: true면 정확 일치.
SSR은 initialData(간단) 또는 dehydrate/hydrate(정석).
dehydrate에는 성공한 쿼리만 들어가고,
SSR에서 staleTime 0이면 클라이언트에서 다시 refetch될 수 있다.
스크롤 복원은 React Query가 직접 하는 게 아니라,
캐시 데이터가 즉시 렌더링되면서 브라우저의 기본 스크롤 복원이 잘 작동하게 되는 것.

한 문장으로:

React Query는 "서버 데이터를 가져오는 라이브러리"라기보다,
서버 데이터의 캐시 생명주기, 신선도, 동기화, 변경 후 갱신까지 관리하는
서버 상태 관리 도구다.