본문 바로가기
JavaScript/React

React Query

by curious week 2025. 5. 15.

https://tanstack.com/

 

TanStack | High Quality Open-Source Software for Web Developers

Headless, type-safe, powerful utilities for complex workflows like Data Management, Data Visualization, Charts, Tables, and UI Components.

tanstack.com


1단계. React Query의 철학과 역할 이해


React Query 등장 배경

React Query는 기존 React 개발 환경에서 서버 상태 관리를 효율적으로 개선하기 위해 등장했습니다.

전통적인 React 앱에서는 API 호출과 데이터 관리를 useState나 useEffect를 사용해 직접 처리해왔습니다. 예를 들어, axios로 요청을 보내고, 로딩 상태, 에러 상태, 데이터 상태를 모두 수동으로 관리했습니다. 이런 방식은 단순한 프로젝트에서는 괜찮지만, 규모가 커질수록 다음과 같은 문제들이 발생합니다.

  1. 코드 중복이 심함
    매번 로딩, 에러, 데이터 상태를 관리하는 코드를 반복 작성해야 합니다.
  2. 비효율적인 캐시 처리
    동일한 API 호출이 여러 곳에서 발생할 경우, 중복 호출이 발생하거나 직접 캐시 전략을 관리해야 합니다.
  3. 서버 데이터 동기화 어려움
    서버 데이터가 바뀌었을 때 앱 내에서 자동으로 최신화되지 않아 수동으로 리페치를 구현해야 했습니다.
  4. 복잡한 클라이언트 상태와 서버 상태가 혼재
    서버에서 받아오는 데이터와 UI를 위한 상태가 같은 useState, Redux 같은 클라이언트 상태 관리 도구에 섞여 관리되었습니다. 이로 인해 코드 가독성이 떨어지고 책임이 불분명해졌습니다.

React Query는 이런 문제를 해결하기 위해 등장했습니다.
핵심 목적은 React 앱에서 서버 상태를 "자동으로, 효율적으로, 관리 부담 없이" 처리할 수 있도록 해주는 것입니다.


서버 상태 vs 클라이언트 상태의 근본적인 차이

React 앱에서는 상태를 크게 두 가지로 구분해야 합니다.

클라이언트 상태 (Client State)

React 애플리케이션 내부에서만 사용하는 상태입니다.
UI 렌더링, 입력 폼 데이터, 모달 열림/닫힘, 현재 탭 상태, 로그인 여부 등의 상태가 이에 해당합니다.
클라이언트 상태는 서버와 무관하게 클라이언트 앱 내부에서 직접 수정하거나 관리합니다.
이런 상태는 useState, Zustand, Redux 같은 도구로 관리하는 것이 적절합니다.

서버 상태 (Server State)

React 앱이 외부 서버에서 받아온 데이터입니다.
예를 들어 사용자 정보, 게시글 목록, 상품 목록, 주문 내역 등이 이에 해당합니다.
서버 상태는 클라이언트 앱이 직접 소유하고 있지 않으며, 항상 서버와 동기화되어야 하고, 변경하려면 API 요청을 통해서만 가능해야 합니다.
React Query는 바로 이 서버 상태를 관리하는 데 특화된 도구입니다.

핵심 차이는 소유권과 변경 흐름에 있습니다.
클라이언트 상태는 앱 내부에서 직접 즉시 변경할 수 있지만, 서버 상태는 서버에서만 관리되고, 클라이언트는 요청을 통해 가져오고 변경 요청을 보낼 수 있습니다.


React Query가 하는 일과 하지 않는 일

React Query가 하는 일은 다음과 같습니다.

  • 서버 API 요청을 관리하고 응답을 자동으로 캐싱합니다.
  • 동일한 쿼리 요청에 대해서는 캐시된 데이터를 우선 사용하고, 필요에 따라 자동으로 리페치를 수행합니다.
  • 로딩, 에러, 성공 상태를 자동으로 관리하여 클라이언트가 따로 useState로 관리할 필요가 없습니다.
  • 포커스 복귀, 네트워크 재연결 시 자동으로 데이터를 최신 상태로 동기화합니다.
  • 무효화(invalidate) 기능을 통해 서버 상태가 변경된 경우 캐시를 자동 갱신할 수 있습니다.
  • 낙관적 업데이트, Devtools 같은 고급 기능도 제공합니다.

반대로 React Query가 하지 않는 일은 다음과 같습니다.

  • UI 상태, 클라이언트 로컬 상태, 임시 입력값, 토큰 저장 같은 클라이언트 상태 관리는 하지 않습니다.
  • 사용자 입력 폼의 입력값, 모달 열림 상태, 클라이언트 내부 전역 상태 같은 것들은 useState나 Zustand로 관리해야 합니다.
  • 서버 상태가 아닌 앱 자체의 전역 상태나 비즈니스 로직 상태도 React Query의 역할이 아닙니다.

1. Axios 단독 사용 예제

// UserListAxios.tsx
import axios from 'axios';
import { useEffect, useState } from 'react';

const UserListAxios = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    axios.get('/api/users')
      .then((res) => {
        setUsers(res.data);
      })
      .catch((err) => {
        setError(err);
      })
      .finally(() => {
        setLoading(false);
      });
  }, []);

  if (loading) return <div>로딩 중...</div>;
  if (error) return <div>에러 발생</div>;

  return (
    <ul>
      {users.map((u) => <li key={u.id}>{u.name}</li>)}
    </ul>
  );
};

export default UserListAxios;

✔ 문제점

  • 로딩, 에러, 데이터 상태를 매번 수동 관리해야 함
  • 캐싱, 리페치, 무효화 없음
  • 상태 관리 책임이 개발자에게 집중됨

2. React Query 사용 예제

// UserListReactQuery.tsx
import axios from 'axios';
import { useQuery } from '@tanstack/react-query';

const fetchUsers = async () => {
  const { data } = await axios.get('/api/users');
  return data;
};

const UserListReactQuery = () => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

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

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

export default UserListReactQuery;

✔ 개선점

  • useQuery 하나로 로딩, 에러, 데이터 상태 자동 관리
  • 동일 쿼리에 대해 캐시 자동 적용
  • refetch, invalidateQueries로 쉽게 동기화 가능
  • 코드가 깔끔하고 재사용성이 높음

3. Zustand를 사용한 클라이언트 상태 관리 예제

// useUiStore.ts
import { create } from 'zustand';

export const useUiStore = create((set) => ({
  isModalOpen: false,
  openModal: () => set({ isModalOpen: true }),
  closeModal: () => set({ isModalOpen: false }),
}));
// ExampleComponent.tsx
import { useUiStore } from '@/store/useUiStore';

const ExampleComponent = () => {
  const { isModalOpen, openModal, closeModal } = useUiStore();

  return (
    <div>
      <button onClick={openModal}>모달 열기</button>
      {isModalOpen && (
        <div>
          모달이 열렸습니다.
          <button onClick={closeModal}>닫기</button>
        </div>
      )}
    </div>
  );
};

✔ 특징

  • 서버 상태와 무관한 UI 상태 관리 전용
  • 모달, 탭, 입력값, 필터 등 앱 내부 상태를 관리할 때 적합
  • 서버 상태인 users 같은 데이터는 절대 저장하지 않음

4. Zustand vs React Query 역할 구분 메모

Zustand의 역할

  • 클라이언트 앱 내부에서 사용하는 상태 관리
  • 예: 모달 열림, 로그인 세션 토큰, 임시 입력값, 전역 플래그, UI 탭, 테마 상태
  • 서버와 관계없는 데이터
  • 빠르게 바뀌고 즉각적인 UI 렌더링에 영향을 줌

React Query의 역할

  • 서버에서 받아오는 데이터 관리
  • 예: 사용자 목록, 게시글 목록, 주문 내역, 프로필 정보
  • 서버와 항상 동기화가 필요한 상태
  • 네트워크를 통해 변경되며 캐시가 필요

React + Zustand + React Query 프로젝트 구조 예시

src/
│
├── api/                  ← API 호출 함수만 정의 (axios)
│   └── user.ts           ← 사용자 관련 API (fetchUserList 등)
│
├── hooks/                ← 공통 hook 모음
│   └── useUserQuery.ts   ← React Query 쿼리 hook
│
├── pages/                ← 라우트 단위 페이지
│   └── users/            
│       └── UserPage.tsx  ← 사용자 목록 페이지 (React Query 사용)
│
├── components/           ← 재사용 가능한 UI 컴포넌트
│   └── UserCard.tsx
│
├── store/                ← Zustand 상태 저장소
│   └── useUiStore.ts     ← UI 상태 (모달, 탭 등)
│   └── useAuthStore.ts   ← 인증 상태 (accessToken, user)
│
├── services/             ← 비즈니스 로직 계층 (선택)
│   └── userService.ts    ← 쿼리 invalidate, mutation 래핑 등
│
├── lib/                  ← axios 설정, queryClient 등 공통 유틸
│   └── axios.ts
│   └── react-query.ts    ← QueryClient 인스턴스, Devtools 설정
│
├── App.tsx
└── main.tsx              ← ReactDOM + QueryClientProvider + Zustand devtools

폴더별 역할

api/

  • 순수하게 API 요청만 담당 (axios.get, axios.post)
  • try/catch, 상태 관리 X → 오직 요청만
  • 예: fetchUsers, createUser

hooks/

  • React Query의 useQuery, useMutation 훅 정의
  • 쿼리 키, 쿼리 함수, 옵션 캡슐화
  • View에서 비즈니스 로직을 분리하는 역할

store/

  • Zustand 기반 클라이언트 상태 저장소
  • UI 상태 (isModalOpen), 사용자 토큰 등
  • 서버와 무관한 앱 내부 상태만 저장

pages/

  • 라우팅 대상이 되는 실제 화면 컴포넌트
  • React Query 훅을 통해 데이터 조회
  • Zustand 훅으로 UI 상태 조작

services/ (선택)

  • mutation 이후 invalidate 처리, 비즈니스 로직 래핑
  • 예: loginUser → token 저장 + 쿼리 무효화 + 리다이렉트

lib/

  • axios 인스턴스 (인터셉터 포함)
  • QueryClient 설정 (staleTime, retry, Devtools 등)

예시 흐름: 사용자 목록 불러오기

  • UserPage.tsx (pages)
    • useUserQuery() (hooks)
      • fetchUsers() (api)
    • useUiStore() (store)
  • 리스트는 React Query로 불러오고, 모달은 Zustand로 열고 닫음

프로젝트 초기 설정 팁

  1. lib/react-query.ts에 QueryClient 설정
  2. main.tsx 또는 App.tsx에서 QueryClientProvider 설정
  3. Zustand devtools도 함께 설정하면 디버깅에 도움

2단계. useQuery 기초 익히기


핵심 개념 정리

useQuery

React Query의 가장 기본적인 훅.
특정 서버 데이터를 가져오고 상태(로딩, 에러, 데이터)를 자동 관리.


필수 옵션

  • queryKey: 이 쿼리를 고유하게 식별하는 키. 캐시 구분, refetch, invalidate 등에 사용.
  • queryFn: 실제 데이터를 불러오는 함수. 보통 axios, fetch를 사용.

반환 값

  • data: 성공적으로 가져온 데이터
  • isLoading: 데이터 로딩 중 여부 (처음 fetch 또는 refetch 시 true)
  • error: 요청 에러 발생 시 에러 객체

실습 예제

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

// queryFn: 데이터를 실제로 불러오는 함수
const fetchUsers = async () => {
  const { data } = await axios.get('/api/users');
  return data;
};

const UserList = () => {
  // useQuery: 쿼리 키와 쿼리 함수 전달
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],  // 고유 식별자
    queryFn: fetchUsers,  // 호출 함수
  });

  // 상태별 UI 처리
  if (isLoading) return <div>로딩 중...</div>;
  if (error) return <div>에러 발생: {error.message}</div>;

  // 성공적으로 데이터를 받은 경우
  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

export default UserList;

각 요소의 실제 의미

queryKey

React Query가 "이 쿼리는 users에 대한 것이다" 라고 기억하게 하는 ID.
캐싱, 무효화, 리페치 시 이 key를 기준으로 동작.
항상 배열로 사용 (['users']).

  • queryKey는 "데이터 조회" 기준으로만 캐시 구분에 사용됩니다.
  • DELETE, UPDATE 같은 mutation은 별도의 캐싱이 없기 때문에 queryKey와 직접 연결되지 않습니다.
사용자 목록 불러오기 ['users']
사용자 상세 불러오기 ['user', userId]
사용자 생성 (POST) 없음 (mutation 후 invalidateQueries(['users']))
사용자 삭제 (DELETE) 없음 (mutation 후 invalidateQueries(['users']) 또는 ['user', userId])
사용자 수정 (PUT) 없음 (mutation 후 상황에 맞게 무효화)

queryFn

실제 데이터를 요청하는 함수.
axios나 fetch 등을 사용하며 반드시 Promise를 반환해야 함.
queryFn 내부에서는 로딩 상태나 에러 처리는 React Query가 자동으로 해줌.

isLoading

  • 처음 요청이 시작되었을 때 true
  • refetch 중에도 true
  • 이 상태에서 data는 undefined일 수 있으므로 반드시 분기 처리해야 함
const { data, isLoading, error } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
});

// ❌ 이렇게 바로 data.map()을 호출하면 isLoading 시점에는 오류 발생 가능
// return <ul>{data.map(...)} </ul>

// ✔ 반드시 다음과 같이 분기 처리
if (isLoading) {
  return <div>로딩 중...</div>; // data가 undefined일 때 안전하게 분리
}

if (error) {
  return <div>에러 발생</div>;
}

if (!data || data.length === 0) {
  return <div>데이터 없음</div>;
}

// 여기까지 오면 data는 반드시 존재하므로 안전하게 map 가능
return (
  <ul>
    {data.map((user) => (
      <li key={user.id}>{user.name}</li>
    ))}
  </ul>
);

error

  • 요청 실패 시 에러 객체 반환 (axios의 에러 객체 그대로 반환됨)
  • 에러 핸들링은 여기서 수행 (에러 메시지 표시, fallback 등)

data

  • 요청이 성공적으로 완료되면 API 응답 데이터를 반환
  • isLoading이 false일 때 data가 정상적으로 존재

실무 체크포인트

  • useQuery 내부에서 직접 try/catch는 하지 않음 → React Query가 자동으로 에러 상태 관리
  • 쿼리 키(queryKey)는 항상 배열로 작성 습관화 → ['users', page]처럼 조건에 따라 확장 가능
  • queryKey가 같으면 캐시된 데이터를 재사용 → 동일 요청 반복 방지
  • queryFn은 순수한 요청 함수만 → side effect(상태 변경)는 절대 넣지 않기

사용자 목록 예제: useQuery

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

const fetchUsers = async () => {
  const { data } = await axios.get('/api/users');
  return data;
};

const UserList = () => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  if (isLoading) {
    return <div>사용자 목록 로딩 중...</div>;
  }

  if (error) {
    return <div>사용자 목록 에러 발생: {error.message}</div>;
  }

  if (!data || data.length === 0) {
    return <div>사용자가 없습니다.</div>;
  }

  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>
          {user.name} ({user.email})
        </li>
      ))}
    </ul>
  );
};

export default UserList;

게시글 목록 예제: useQuery

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

const fetchPosts = async () => {
  const { data } = await axios.get('/api/posts');
  return data;
};

const PostList = () => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

  if (isLoading) {
    return <div>게시글 목록 로딩 중...</div>;
  }

  if (error) {
    return <div>게시글 에러 발생: {error.message}</div>;
  }

  if (!data || data.length === 0) {
    return <div>게시글이 없습니다.</div>;
  }

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

export default PostList;

상태별 UI 처리 포인트 요약

  • isLoading이 true인 경우 → 로딩 메시지
  • error가 발생하면 → 에러 메시지 + error.message 노출
  • data가 빈 배열이거나 없는 경우 → "데이터 없음" 표시
  • 정상적으로 데이터가 존재하는 경우 → 리스트 렌더링

실무 포인트

  • 무조건 if 분기를 단계별로 명확하게 나눠야 함 (중첩 대신 단계 분리)
  • data.map()을 바로 사용하면 data가 undefined일 때 오류 발생 → 반드시 isLoading과 error 처리 후 접근
  • error.message는 axios 기준 response가 없는 경우도 고려 (error.response ? error.response.data.message : error.message)

3단계. 캐싱, Refetch, 무효화 제어


1. 캐시 흐름 이해

React Query는 기본적으로 다음의 캐시 정책을 가집니다.

  • staleTime:
    데이터를 "신선한" 상태로 간주하는 시간(ms).
    이 시간이 지나면 React Query는 백그라운드에서 데이터를 refetch합니다.
    기본값은 0ms라서 요청 직후 stale 상태가 됩니다.
  • cacheTime:
    쿼리가 사용되지 않을 때까지 메모리에 남아있는 시간(ms).
    기본값은 5분이며, 이 시간이 지나면 캐시가 사라짐.

✔ 예시: staleTime과 cacheTime 적용

const { data, isLoading } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  staleTime: 10000,    // 10초 동안은 새로고침 없이 캐시 사용
  cacheTime: 60000,    // 1분 동안 메모리에 유지
});

2. 수동 Refetch, 자동 Refetch

✔ 수동 Refetch (사용자 액션으로)

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

<button onClick={() => refetch()}>수동 새로고침</button>

✔ 자동 Refetch (포커스 복귀 시)

const { data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  refetchOnWindowFocus: true,   // 탭을 벗어나고 다시 돌아오면 자동 리페치
});

3. 쿼리 무효화 (invalidateQueries)

invalidateQueries를 사용하면 해당 queryKey를 강제로 stale 상태로 만들고 자동으로 refetch 시킬 수 있습니다.

✔ 예시: 유저 추가 후 목록 무효화

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

const queryClient = useQueryClient();

const handleAddUser = async () => {
  await axios.post('/api/users', { name: 'Heeseong' });
  queryClient.invalidateQueries({ queryKey: ['users'] });  // 무효화 → 자동으로 리페치됨
};

4. queryClient 직접 접근 (getQueryData, setQueryData)

✔ 캐시된 데이터 즉시 가져오기 (읽기 전용)

const queryClient = useQueryClient();
const cachedUsers = queryClient.getQueryData(['users']);
console.log(cachedUsers);

✔ 캐시 데이터 직접 수정 (낙관적 업데이트 등)

queryClient.setQueryData(['users'], (old) => [...old, newUser]);

⚠️ 이 경우 서버 요청 없이 UI가 즉시 반영됨 (주의)


핵심 흐름

  1. staleTime → 캐시된 데이터를 얼마나 "신선"하게 유지할지 결정 (짧으면 자주 refetch)
  2. cacheTime → 쿼리가 사용되지 않아도 캐시가 유지되는 시간 (길게 하면 재사용 높아짐)
  3. refetch() → 수동 재요청 (사용자 버튼, 액션 기반)
  4. refetchOnWindowFocus → 자동 재요청 (포커스 복귀 시)
  5. invalidateQueries → 무효화 → 자동 refetch 트리거
  6. getQueryData, setQueryData → 캐시 직접 읽기/쓰기 (필요 시만)

실습 1: 리스트 무효화 → 자동 갱신

목표: 사용자 추가 후, ['users'] 쿼리를 invalidate → 자동 refetch

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

const fetchUsers = async () => {
  const { data } = await axios.get('/api/users');
  return data;
};

const createUser = async (name) => {
  const { data } = await axios.post('/api/users', { name });
  return data;
};

const UserListWithInvalidate = () => {
  const queryClient = useQueryClient();

  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      // 사용자 추가 성공 시 쿼리 무효화 → 자동으로 리페치됨
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

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

  return (
    <div>
      <button onClick={() => mutation.mutate('새 사용자')}>
        사용자 추가
      </button>
      <ul>
        {data.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
};

export default UserListWithInvalidate;

✔ 확인 포인트

  • onSuccess → invalidateQueries 호출
  • 자동으로 사용자 리스트가 갱신됨 (네트워크 재요청 발생)

실습 2: 캐시 직접 읽기 (getQueryData), 쓰기 (setQueryData)

목표: 사용자 추가 후 서버 요청 없이 UI에 즉시 추가 → 이후 리페치 없이 캐시 업데이트

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

const fetchUsers = async () => {
  const { data } = await axios.get('/api/users');
  return data;
};

const createUser = async (name) => {
  const { data } = await axios.post('/api/users', { name });
  return data;
};

const UserListWithOptimistic = () => {
  const queryClient = useQueryClient();

  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: (newUser) => {
      // 캐시 직접 수정
      queryClient.setQueryData(['users'], (old) => [...(old ?? []), newUser]);
    },
  });

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

  return (
    <div>
      <button onClick={() => mutation.mutate('새 사용자')}>
        사용자 추가 (낙관적 업데이트)
      </button>
      <ul>
        {data.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
};

export default UserListWithOptimistic;

✔ 확인 포인트

  • onSuccess → setQueryData 호출로 캐시 직접 수정
  • UI가 서버 요청 없이 바로 업데이트
  • 나중에 원하는 시점에 invalidateQueries로 최신화 가능

핵심

  • invalidateQueries: 무효화 후 자동 refetch (네트워크 요청 발생)
  • setQueryData: 캐시 데이터 직접 수정 (서버 요청 없이 UI 즉시 변경)
  • getQueryData: 현재 캐시 상태 읽기 (서버 요청 없이)

4단계. useMutation으로 데이터 변경 처리


 

useMutation의 콜백 옵션 4가지 정리

1. onMutate

  • mutationFn 실행 직전에 호출
  • 주로 낙관적 업데이트(Optimistic UI)에서 사용
  • 이 시점에서 캐시를 직접 업데이트 가능 (queryClient.setQueryData)
  • 이전 상태를 반환해서 onError에서 롤백에 사용
onMutate: async (variables) => {
  const previousData = queryClient.getQueryData(['users']);
  queryClient.setQueryData(['users'], (old) => [...old, { id: 999, name: 'Temp User' }]);
  return { previousData }; // rollback 용으로 반환
}

2. onError

  • mutation 실패 시 호출
  • onMutate에서 반환한 context를 받아서 롤백 처리 가능
onError: (error, variables, context) => {
  queryClient.setQueryData(['users'], context.previousData); // 롤백
}

3. onSuccess

  • mutation 성공 시 호출
  • 서버에서 정상 응답이 왔을 때 동작
  • 보통 이 시점에서 관련 query invalidate 처리
onSuccess: (data) => {
  queryClient.invalidateQueries({ queryKey: ['users'] });
}

4. onSettled

  • 성공, 실패 관계없이 항상 마지막에 호출
  • UI 리셋, query 무효화 등 후처리에 적합
  • onSuccess, onError 둘 다 발생한 후에도 최종적으로 항상 호출됨
onSettled: () => {
  queryClient.invalidateQueries({ queryKey: ['users'] });
}

흐름

   ┌──────────┐
   │ onMutate │ → Optimistic UI 적용
   └────┬─────┘
        │
        ▼
 ┌─────────────┐
 │ mutationFn  │ 실행 (axios 요청 등)
 └────┬────────┘
      │
 ┌────▼────┐         ┌───────┐
 │ onError │◄─────┐  │ onSuccess │
 └─────────┘      └─►└──────────┘
      │                  │
      └──────┬────────────┘
             ▼
       ┌──────────┐
       │ onSettled │ → 항상 실행
       └──────────┘
  • onMutate: 요청 전에 → 캐시 직접 업데이트 → 낙관적 UI 적용
  • onError: 실패 시 → onMutate에서 반환한 상태로 setQueryData로 롤백
  • onSuccess: 성공 시 → 성공 데이터 처리, invalidate로 무효화 후 리패치
  • onSettled: 항상 마지막에 호출, 상태 초기화, refetch 등 공통 처리에 적합

1. useMutation 기본 사용법

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

const createUser = async (name) => {
  const { data } = await axios.post('/api/users', { name });
  return data;
};

const ExampleComponent = () => {
  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: (data) => {
      console.log('생성 성공', data);
    },
    onError: (error) => {
      console.error('생성 실패', error);
    },
  });

  return (
    <button onClick={() => mutation.mutate('새 사용자')}>
      사용자 추가
    </button>
  );
};

✔ 핵심 이해

  • useMutation은 데이터 변경 요청 (POST, PUT, DELETE) 전용
  • mutationFn에서 비동기 요청을 수행
  • mutation.mutate(파라미터) 호출로 트리거
  • 성공/실패 핸들링은 onSuccess, onError 사용

2. 성공 후 invalidateQueries 적용

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

const UserListWithInvalidate = () => {
  const queryClient = useQueryClient();

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

  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });  // 리스트 리페치
    },
  });

  return (
    <>
      <button onClick={() => mutation.mutate('새 사용자')}>
        사용자 추가 (invalidateQueries)
      </button>
      <ul>
        {data?.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </>
  );
};

✔ 요점

  • 성공 후 invalidateQueries 호출
  • 서버에 있는 최신 데이터를 다시 받아서 UI 갱신

3. Optimistic UI (낙관적 업데이트) 적용

const UserListOptimistic = () => {
  const queryClient = useQueryClient();

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

  const mutation = useMutation({
    mutationFn: createUser,
    onMutate: async (newName) => {
      // 기존 캐시 백업
      const previousUsers = queryClient.getQueryData(['users']);

      // 캐시를 즉시 업데이트 (낙관적 UI)
      queryClient.setQueryData(['users'], (old) => [...(old ?? []), { id: Date.now(), name: newName }]);

      // 실패 시 롤백을 위해 이전 데이터 반환
      return { previousUsers };
    },
    onError: (err, newName, context) => {
      // 실패 시 롤백
      queryClient.setQueryData(['users'], context.previousUsers);
    },
    onSettled: () => {
      // 성공/실패와 관계없이 최종적으로 서버 데이터 refetch
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  return (
    <>
      <button onClick={() => mutation.mutate('새 사용자')}>
        사용자 추가 (Optimistic UI)
      </button>
      <ul>
        {data?.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </>
  );
};

✔ 핵심 흐름

  1. onMutate: 요청 전에 캐시를 직접 업데이트 → 즉시 UI 반영 (낙관적 UI)
  2. onError: 실패 시 onMutate에서 반환한 이전 상태로 롤백
  3. onSettled: 성공/실패 관계없이 서버 데이터 새로고침 (invalidateQueries)

실무 요약

  • useMutation은 클라이언트에서 서버로 데이터 변경 트리거 담당
  • 성공 시 → invalidateQueries로 서버 데이터 최신화
  • Optimistic UI 적용 시 → onMutate에서 캐시 직접 변경 → onError에서 롤백 가능 → onSettled에서 최종 동기화

실습 1: 사용자 생성 (POST)

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

const fetchUsers = async () => {
  const { data } = await axios.get('/api/users');
  return data;
};

const createUser = async (name) => {
  const { data } = await axios.post('/api/users', { name });
  return data;
};

const UserCreate = () => {
  const queryClient = useQueryClient();

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

  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  return (
    <div>
      <button onClick={() => mutation.mutate('새 사용자')}>
        사용자 생성
      </button>
      <ul>
        {data?.map((u) => <li key={u.id}>{u.name}</li>)}
      </ul>
    </div>
  );
};

실습 2: 사용자 이름 수정 (PUT)

const updateUser = async ({ id, name }) => {
  const { data } = await axios.put(`/api/users/${id}`, { name });
  return data;
};

const UserUpdate = () => {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: updateUser,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  const handleUpdate = (user) => {
    const newName = prompt('새 이름 입력', user.name);
    if (newName) {
      mutation.mutate({ id: user.id, name: newName });
    }
  };

  return null; // 리스트 컴포넌트에서 핸들러로 사용 가능
};

실습 3: 사용자 삭제 (DELETE) + Optimistic UI 적용

const deleteUser = async (id) => {
  await axios.delete(`/api/users/${id}`);
};

const UserDeleteOptimistic = () => {
  const queryClient = useQueryClient();

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

  const mutation = useMutation({
    mutationFn: deleteUser,
    onMutate: async (id) => {
      // 기존 데이터 백업
      const previousUsers = queryClient.getQueryData(['users']);

      // Optimistic UI 적용: 바로 제거
      queryClient.setQueryData(['users'], (old) =>
        old?.filter((user) => user.id !== id)
      );

      return { previousUsers };
    },
    onError: (err, id, context) => {
      // 실패 시 롤백
      queryClient.setQueryData(['users'], context.previousUsers);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  return (
    <ul>
      {data?.map((u) => (
        <li key={u.id}>
          {u.name}
          <button onClick={() => mutation.mutate(u.id)}>삭제 (Optimistic)</button>
        </li>
      ))}
    </ul>
  );
};

핵심 흐름

  1. 생성 → useMutation → 성공 시 invalidateQueries
  2. 수정 → useMutation → 성공 시 invalidateQueries
  3. 삭제 (Optimistic UI)
    • onMutate에서 캐시 직접 수정 → 즉시 UI에서 제거
    • onError에서 롤백
    • onSettled에서 무조건 최신화

실무 포인트

  • 낙관적 업데이트는 사용자 경험이 부드럽지만 실패 시 롤백 처리를 반드시 구현
  • 생성/수정은 Optimistic UI를 쓰는 경우가 드물지만, 삭제에서는 매우 많이 사용
  • 무조건 최종적으로 invalidateQueries로 서버와 동기화는 해주는 습관 필수

5단계. Query 의존성, 조건부 Fetch, Polling


1. enabled 옵션으로 조건부 fetch

const fetchUserById = async (userId) => {
  const { data } = await axios.get(`/api/users/${userId}`);
  return data;
};

const UserDetail = ({ userId }) => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUserById(userId),
    enabled: !!userId,  // userId가 존재할 때만 실행
  });

  if (!userId) return <div>사용자 선택 필요</div>;
  if (isLoading) return <div>로딩 중...</div>;
  if (error) return <div>에러 발생</div>;

  return <div>사용자 이름: {data.name}</div>;
};

✔ 요점

  • enabled: false → 쿼리 일시 중지
  • enabled: !!userId → userId가 있을 때만 쿼리 실행
  • 동적 입력이 필요한 상세 페이지, 검색 등에 유용

2. queryKey 동적 사용 (파라미터에 따른 캐시 분리)

const UserDetailDynamicKey = ({ userId }) => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],  // userId별 캐시 분리
    queryFn: () => fetchUserById(userId),
    enabled: !!userId,
  });

  return (
    <>
      {isLoading && <div>로딩 중...</div>}
      {error && <div>에러 발생</div>}
      {data && <div>{data.name}</div>}
    </>
  );
};

✔ 요점

  • ['user', 1] → id:1의 데이터만 관리
  • ['user', 2] → id:2의 데이터와 구분
  • 서버와 동기화된 캐시가 queryKey별로 자동 관리됨

3. Polling (refetchInterval) 적용

const fetchServerTime = async () => {
  const { data } = await axios.get('/api/time');
  return data;
};

const ServerTime = () => {
  const { data } = useQuery({
    queryKey: ['serverTime'],
    queryFn: fetchServerTime,
    refetchInterval: 5000,  // 5초마다 자동 refetch (polling)
  });

  return <div>서버 시간: {data?.time ?? '로딩 중...'}</div>;
};

✔ 요점

  • refetchInterval → 지정한 ms마다 자동 refetch
  • polling은 서버 부하에 유의
  • 실시간 데이터, 상태 모니터링 등에 사용

실무 요약 정리

  • enabled: 조건부로 쿼리를 멈출 때 필수 (ex. userId가 없으면 중단)
  • queryKey: 조건에 따른 캐시 분리 → [리소스, 조건1, 조건2] 패턴으로 습관화
  • refetchInterval: polling 기반 실시간 UI → 서버 과부하 주의하며 사용

실습 1. 사용자 상세 페이지에서 userId로 쿼리 구분

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

const fetchUserById = async (userId) => {
  const { data } = await axios.get(`/api/users/${userId}`);
  return data;
};

const UserDetailPage = ({ userId }) => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],  // userId를 쿼리 키에 포함 → 캐시가 userId별로 분리됨
    queryFn: () => fetchUserById(userId),
    enabled: !!userId,            // userId가 있을 때만 요청 수행
  });

  if (!userId) return <div>사용자를 선택하세요.</div>;
  if (isLoading) return <div>사용자 정보 로딩 중...</div>;
  if (error) return <div>사용자 정보 가져오기 실패</div>;

  return (
    <div>
      <h2>{data.name}님의 상세 정보</h2>
      <p>이메일: {data.email}</p>
      <p>가입일: {data.createdAt}</p>
    </div>
  );
};

export default UserDetailPage;

✔ 핵심 요점

  • ['user', userId] 사용 → userId가 다른 경우 캐시도 자동 구분됨
  • enabled로 userId가 없을 때 요청 방지
  • 상세 페이지, 모달에서 주로 사용하는 패턴

실습 2. 채팅방 메시지 실시간 polling (refetchInterval 사용)

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

const fetchChatMessages = async (roomId) => {
  const { data } = await axios.get(`/api/chat/rooms/${roomId}/messages`);
  return data;
};

const ChatRoom = ({ roomId }) => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['chatMessages', roomId],      // 채팅방 ID별로 캐시 분리
    queryFn: () => fetchChatMessages(roomId),
    refetchInterval: 3000,                   // 3초마다 자동 polling
    enabled: !!roomId,                       // roomId 있을 때만 활성화
  });

  if (!roomId) return <div>채팅방을 선택하세요.</div>;
  if (isLoading) return <div>메시지 로딩 중...</div>;
  if (error) return <div>메시지 가져오기 실패</div>;

  return (
    <ul>
      {data.map((msg) => (
        <li key={msg.id}>
          <b>{msg.sender}</b>: {msg.content}
        </li>
      ))}
    </ul>
  );
};

export default ChatRoom;

✔ 핵심 요점

  • ['chatMessages', roomId] → 방 별로 메시지 캐시 분리
  • refetchInterval: 3000 → 3초마다 자동으로 새 메시지 fetch
  • 실시간성이 필요한 채팅방, 모니터링 UI 등에 적합
  • polling은 서버 부하를 고려해 적절히 설정 (짧게 하면 부하 증가)

정리

  1. 쿼리 구분
    • 항상 queryKey에 식별자(userId, roomId)를 포함
    • 캐시 충돌 방지, 효율적인 무효화 가능
  2. 조건부 fetch
    • enabled: !!userId 또는 !!roomId → 특정 상황에서만 요청
  3. polling
    • refetchInterval 사용
    • 실시간성 요구되는 UI에서 유용 (주기 조정 필수)

6단계. 무한 스크롤, 페이지네이션, Prefetch


1. 무한 스크롤 (useInfiniteQuery)

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

const fetchPosts = async ({ pageParam = 1 }) => {
  const { data } = await axios.get(`/api/posts?page=${pageParam}&size=10`);
  return data;
};

const InfinitePostList = () => {
  const { data, isLoading, error, fetchNextPage, hasNextPage } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    getNextPageParam: (lastPage) => {
      // API에서 마지막 페이지 정보 제공한다고 가정
      return lastPage.nextPage ?? undefined;
    },
  });

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

  return (
    <div>
      {data.pages.map((page, i) => (
        <div key={i}>
          {page.items.map((post) => (
            <div key={post.id}>{post.title}</div>
          ))}
        </div>
      ))}
      {hasNextPage && <button onClick={() => fetchNextPage()}>더 보기</button>}
    </div>
  );
};

✔ 요점

  • pageParam을 통해 다음 페이지 파라미터 관리
  • getNextPageParam으로 다음 요청의 파라미터 반환
  • fetchNextPage() 호출로 다음 페이지 요청
  • pages 배열을 순회해서 데이터 렌더링

2. 페이지네이션 (useQuery + page param)

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

const fetchPostsByPage = async (page) => {
  const { data } = await axios.get(`/api/posts?page=${page}&size=10`);
  return data;
};

const PostPagination = () => {
  const [page, setPage] = useState(1);

  const { data, isLoading, error } = useQuery({
    queryKey: ['posts', page],
    queryFn: () => fetchPostsByPage(page),
    keepPreviousData: true,  // 이전 페이지 캐시 유지
  });

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

  return (
    <div>
      {data.items.map((post) => (
        <div key={post.id}>{post.title}</div>
      ))}
      <button onClick={() => setPage((p) => p - 1)} disabled={page === 1}>이전</button>
      <button onClick={() => setPage((p) => p + 1)}>다음</button>
    </div>
  );
};

✔ 요점

  • ['posts', page]로 page별 캐시 분리
  • keepPreviousData: true → 페이지 이동 시 이전 데이터 유지 (UX 부드럽게)

3. prefetchQuery로 미리 데이터 로드

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

const prefetchUser = (userId) => {
  const queryClient = useQueryClient();

  queryClient.prefetchQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUserById(userId),
  });
};

const UserPreviewButton = ({ userId }) => {
  return (
    <button
      onMouseEnter={() => prefetchUser(userId)}
      onClick={() => alert('상세 페이지 이동')}
    >
      {userId}번 사용자 미리보기
    </button>
  );
};

✔ 요점

  • prefetchQuery → 사용자 액션 전 미리 데이터 요청
  • 서버 응답 시간이 긴 경우 UX 개선 (hover 시 미리 fetch)
  • 페이지 전환, 모달 열기 전에 미리 캐싱 가능

실무 요약

  1. 무한 스크롤 → useInfiniteQuery
    • 페이지 구분 없이 스크롤 기반으로 연속 요청
    • fetchNextPage, hasNextPage 사용
  2. 페이지네이션 → useQuery + queryKey에 page 포함
    • ['posts', page] → 페이지별 캐시 관리
    • keepPreviousData로 UX 개선 가능
  3. prefetchQuery → 사전 요청
    • hover, prefetch 시 사용
    • queryClient.prefetchQuery → 서버 요청 → 캐시만 업데이트 (UI 반영 X)

실습 1. 무한스크롤 게시판 (useInfiniteQuery)

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

const fetchPosts = async ({ pageParam = 1 }) => {
  const { data } = await axios.get(`/api/posts?page=${pageParam}&size=10`);
  return data;
};

const InfiniteBoard = () => {
  const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
  });

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

  return (
    <div>
      {data.pages.map((page, i) => (
        <div key={i}>
          {page.items.map((post) => (
            <div key={post.id}>{post.title}</div>
          ))}
        </div>
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? '불러오는 중...' : '더 보기'}
        </button>
      )}
    </div>
  );
};

export default InfiniteBoard;

✔ 핵심 확인

  • getNextPageParam에서 lastPage.nextPage 반환 → API가 다음 페이지 정보 제공
  • fetchNextPage() 호출로 다음 데이터 요청
  • data.pages에서 순차적으로 렌더링

실습 2. 페이지네이션 리스트 + 상세 페이지 prefetch (prefetchQuery)

2-1. 페이지네이션 리스트

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

const fetchPostsByPage = async (page) => {
  const { data } = await axios.get(`/api/posts?page=${page}&size=10`);
  return data;
};

const fetchPostById = async (postId) => {
  const { data } = await axios.get(`/api/posts/${postId}`);
  return data;
};

const PostListPagination = () => {
  const queryClient = useQueryClient();
  const [page, setPage] = useState(1);

  const { data, isLoading, error } = useQuery({
    queryKey: ['posts', page],
    queryFn: () => fetchPostsByPage(page),
    keepPreviousData: true,
  });

  const handlePrefetch = (postId) => {
    queryClient.prefetchQuery({
      queryKey: ['post', postId],
      queryFn: () => fetchPostById(postId),
    });
  };

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

  return (
    <div>
      {data.items.map((post) => (
        <div
          key={post.id}
          onMouseEnter={() => handlePrefetch(post.id)}  // hover 시 상세 페이지 prefetch
        >
          {post.title}
        </div>
      ))}
      <button onClick={() => setPage((p) => p - 1)} disabled={page === 1}>
        이전
      </button>
      <button onClick={() => setPage((p) => p + 1)}>
        다음
      </button>
    </div>
  );
};

export default PostListPagination;

2-2. 상세 페이지

const PostDetailPage = ({ postId }) => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['post', postId],
    queryFn: () => fetchPostById(postId),
    enabled: !!postId,
  });

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

  return (
    <div>
      <h2>{data.title}</h2>
      <p>{data.content}</p>
    </div>
  );
};

흐름

  • 무한스크롤 → useInfiniteQuery + fetchNextPage
  • 페이지네이션 리스트 → useQuery + page param → 캐시 분리
  • prefetchQuery → 리스트 hover 시 상세 페이지 미리 요청 → 페이지 전환 시 즉시 데이터 렌더링 가능

7단계. 실전 최적화와 Devtools 활용


1. Devtools 활용 (설치 및 사용)

설치

npm install @tanstack/react-query-devtools

설정

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

const queryClient = new QueryClient();

const App = () => (
  <QueryClientProvider client={queryClient}>
    <YourComponent />
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>
);

✔ 활용 포인트

  • 모든 쿼리의 상태 (loading, error, stale, inactive 등) 실시간 확인
  • queryKey, 캐시 데이터, 마지막 fetch 시간, refetch 버튼 제공
  • 캐시 수동 삭제, 무효화 가능

2. onSuccess, onError 활용

const { data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  onSuccess: (data) => {
    console.log('성공적으로 사용자 목록을 받음', data);
  },
  onError: (error) => {
    console.error('에러 발생', error);
  },
});

✔ 포인트

  • onSuccess: 성공 시 로깅, 알림, 상태 변경 등 수행 가능
  • onError: 실패 시 경고 띄우기, fallback UI 처리 가능

3. select 사용 (데이터 가공 → 렌더링 최적화)

const { data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  select: (data) => data.map((u) => u.name),  // 가공된 데이터만 노출
});

✔ 포인트

  • select는 캐시된 원본 데이터에는 영향을 주지 않고 반환 데이터만 가공
  • 불필요한 map/filter를 매번 렌더링 단계에서 하지 않음 → 퍼포먼스 최적화
  • 데이터 형식이 필요한 형태로 바로 가공해서 사용할 수 있음

4. placeholderData 사용 (UX 부드럽게)

const { data, isLoading } = useQuery({
  queryKey: ['posts', page],
  queryFn: () => fetchPostsByPage(page),
  placeholderData: { items: [] },  // 처음 로딩 시 비어있는 데이터 표시
});

✔ 포인트

  • placeholderData는 최초 로딩 시 "잠시 표시할 데이터" 제공
  • 스켈레톤, 로딩 딜레이 제거에 유용
  • initialData와 달리 첫 요청 이후 사라짐 (캐시에는 영향 X)

실무

  • Devtools 항상 켜고 쿼리 상태 모니터링 습관
  • onSuccess, onError → side effect, 로깅, 사용자 알림 등 처리
  • select → 데이터 가공, 불필요 렌더링 방지
  • placeholderData → 로딩 UX 개선

실습 1: Devtools에서 모든 API 요청 흐름 확인

1-1. Devtools 설치 및 적용

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

const queryClient = new QueryClient();

const App = () => (
  <QueryClientProvider client={queryClient}>
    <YourComponent />
    <ReactQueryDevtools initialIsOpen={true} /> {/* 항상 열기 */}
  </QueryClientProvider>
);

1-2. 실습: 사용자 목록 쿼리 실행 후 Devtools 확인

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

const fetchUsers = async () => {
  await new Promise((resolve) => setTimeout(resolve, 3000)); // 일부러 3초 지연
  const { data } = await axios.get('/api/users');
  return data;
};

const UserList = () => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

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

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

export default UserList;

✔ 실습 포인트

  • 브라우저 Devtools → React Query Devtools 탭 열기
  • ['users'] 쿼리가 loading → success 상태로 변화 확인
  • 쿼리 데이터, 캐시 시간, refetch 버튼, stale 상태, fetch time 등을 실시간 모니터링

실습 2: 느린 응답 + placeholderData 적용 UX 개선

const UserListWithPlaceholder = () => {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    placeholderData: [
      { id: 0, name: '로딩 사용자1' },
      { id: 1, name: '로딩 사용자2' },
    ],
  });

  if (error) return <div>에러 발생</div>;

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

✔ 확인 포인트

  • placeholderData는 isLoading 상태를 true로 유지하더라도 데이터(data)를 임시로 제공
  • UX가 매끄럽고 깜빡임 없이 렌더링
  • 실제 데이터 도착 시 자동으로 대체됨

실무에서의 핵심 UX 전략

  • 느린 API, 예측 가능한 데이터 패턴 (목록, 대시보드 등)에서는 placeholderData를 적극 사용
  • Devtools로 API 요청의 stale, active, inactive, refetching 흐름을 정확히 모니터링
  • placeholderData는 UX 최적화 전용, 캐시 영향 X
    (캐시를 초기값으로 고정하고 싶다면 initialData 사용)

React Query + Zustand 통합 실전 아키텍처 패턴

1. 서버 상태 관리 → React Query

  • API 응답 데이터 (user, posts, chat messages)
  • useQuery, useMutation, useInfiniteQuery 사용
  • 캐싱, 자동 refetch, invalidate 관리
  • 예시: ['users'], ['post', postId], ['chatMessages', roomId]

2. 클라이언트 상태 관리 → Zustand

  • UI 상태 (isModalOpen, selectedTab, filters)
  • 인증/세션 (accessToken, userInfo)
  • 입력값, 플래그, 테마 등 앱 내부 상태
  • 서버 상태와 직접 연관되지 않는 로컬 상태

아키텍처 흐름 예시

src/
├── api/                  ← API 호출 함수 (axios)
│   └── user.ts
│   └── post.ts
│
├── hooks/                ← React Query 쿼리 hook 모음
│   └── useUserQuery.ts
│   └── usePostQuery.ts
│
├── store/                ← Zustand 클라이언트 상태 관리
│   └── useAuthStore.ts    ← accessToken, userInfo 관리
│   └── useUiStore.ts      ← 모달, 탭, 필터 등 UI 상태
│
├── components/           ← UI 컴포넌트 (버튼, 카드, 모달)
│
├── pages/                ← 라우트 단위 페이지
│   └── UserPage.tsx
│   └── PostPage.tsx
│
├── services/             ← 복합 비즈니스 로직 (Mutation + invalidate + UI 전환)
│   └── userService.ts
│
└── lib/                  ← axios, queryClient 설정
    └── axios.ts
    └── react-query.ts

실전 흐름 사례

1. 사용자 목록 페이지 (UserPage.tsx)

  • React Query → useUserQuery로 사용자 목록 가져오기 (['users'])
  • Zustand → 필터 상태 관리 (useUiStore에서 selectedFilter)

2. 로그인 (LoginPage.tsx)

  • Zustand → useAuthStore에서 accessToken 저장
  • React Query → 로그인 성공 후 queryClient.invalidateQueries(['me']) 호출 → 사용자 정보 새로고침

3. 게시글 상세 페이지 (PostPage.tsx)

  • React Query → useQuery(['post', postId], fetchPostById)
  • 페이지 접근 전 queryClient.prefetchQuery(['post', postId])로 사전 로딩
  • Zustand → useUiStore에서 북마크 상태 관리

4. 채팅방 (ChatRoom.tsx)

  • React Query → useQuery(['chatMessages', roomId], fetchChatMessages, { refetchInterval: 3000 })
  • Zustand → 현재 채팅방 UI 상태 관리 (useUiStore)

상태 관리 기준 (실무 핵심)

  1. 서버 상태 (React Query)
    • API에서 가져온 데이터
    • 서버 변경 시 무효화 필요
    • 캐싱, 동기화, 리페치 필요
  2. 클라이언트 상태 (Zustand)
    • UI 상태 (모달, 탭, 입력값)
    • 인증/세션 상태
    • 서버와 무관한 앱 내부 상태
  3. 상태 관리 기준
    • 절대 섞지 말기
    • API 요청 결과 → React Query
      UI 컨트롤, 세션 관리 → Zustand
    • 필요 시 상태를 공유할 때는 React Query 캐시에서 getQueryData 사용

실무 아키텍처 Best Practice

  1. api/ → API 요청 함수만
  2. hooks/ → React Query 전용 hook으로 View와 분리
  3. store/ → 클라이언트 상태 관리 (UI/세션)
  4. pages/ → React Query + Zustand 결합 사용
  5. services/ → mutation 이후 invalidate, UI 전환, 상태 리셋 등 복합 로직 관리

 

React Query + Zustand 통합 프로젝트 구조 예시

src/
├── api/
│   └── user.ts              ← 사용자 관련 API 호출 함수
│   └── post.ts
│
├── hooks/
│   └── useUserQuery.ts      ← React Query 쿼리 훅
│   └── usePostQuery.ts
│
├── store/
│   └── useAuthStore.ts      ← 인증 상태 관리
│   └── useUiStore.ts        ← UI 상태 관리
│
├── pages/
│   └── UserPage.tsx
│   └── PostPage.tsx
│
├── services/
│   └── userService.ts       ← 사용자 관련 복합 비즈니스 로직 (mutation, invalidate)
│
└── lib/
    └── axios.ts
    └── react-query.ts       ← QueryClient, Devtools 설정

 

api/user.ts

import axios from 'axios';

export const fetchUsers = async () => {
  const { data } = await axios.get('/api/users');
  return data;
};

export const createUser = async (name) => {
  const { data } = await axios.post('/api/users', { name });
  return data;
};

hooks/useUserQuery.ts

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

export const useUserQuery = () => {
  return useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    staleTime: 10000,
  });
};

store/useAuthStore.ts

import { create } from 'zustand';

export const useAuthStore = create((set) => ({
  accessToken: null,
  setToken: (token) => set({ accessToken: token }),
  clearToken: () => set({ accessToken: null }),
}));

store/useUiStore.ts

import { create } from 'zustand';

export const useUiStore = create((set) => ({
  isModalOpen: false,
  openModal: () => set({ isModalOpen: true }),
  closeModal: () => set({ isModalOpen: false }),
}));

services/userService.ts

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createUser } from '@/api/user';

export const useCreateUserService = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
};

pages/UserPage.tsx

import { useUserQuery } from '@/hooks/useUserQuery';
import { useUiStore } from '@/store/useUiStore';
import { useCreateUserService } from '@/services/userService';

const UserPage = () => {
  const { data, isLoading } = useUserQuery();
  const { isModalOpen, openModal, closeModal } = useUiStore();
  const createUser = useCreateUserService();

  return (
    <div>
      <button onClick={openModal}>사용자 추가</button>
      {isModalOpen && (
        <div>
          <button onClick={() => createUser.mutate('새 사용자')}>추가 실행</button>
          <button onClick={closeModal}>닫기</button>
        </div>
      )}
      {isLoading ? (
        <div>로딩 중...</div>
      ) : (
        <ul>
          {data.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
};

export default UserPage;

lib/react-query.ts

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

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
      retry: 1,
    },
  },
});

App.tsx

import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/lib/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import UserPage from '@/pages/UserPage';

const App = () => (
  <QueryClientProvider client={queryClient}>
    <UserPage />
    <ReactQueryDevtools initialIsOpen={true} />
  </QueryClientProvider>
);

export default App;

핵심

  1. React Query
    • 서버 상태: hooks/useUserQuery.ts
    • mutation 후 invalidate: services/userService.ts
  2. Zustand
    • 클라이언트 상태 (모달, 토큰, 필터): store/useUiStore.ts, useAuthStore.ts
  3. 분리된 계층
    • API 요청 (api/)
    • 쿼리 hook (hooks/)
    • 클라이언트 상태 (store/)
    • 복합 비즈니스 (services/)
    • View (pages/)
  4. Devtools
    • App.tsx에서 항상 활성화 → 모든 쿼리 흐름 실시간 확인

 

React Query + Zustand 결합 상태 흐름도

[사용자 액션 (버튼 클릭, 페이지 진입, 이벤트)]
         │
         ▼
──────────────────────────────────────
 클라이언트 상태 (Zustand)
──────────────────────────────────────
- UI 상태: 모달 열림/닫힘, 탭 상태, 필터, 입력값
- 세션/인증 상태: accessToken, userInfo
- 클라이언트만 사용하는 로컬 상태
──────────────────────────────────────
         │
         ▼
──────────────────────────────────────
 서버 상태 요청 (React Query: useQuery, useMutation)
──────────────────────────────────────
- useQuery: 서버 데이터 가져오기
  - queryKey로 캐싱 및 구분
  - 로딩, 에러, 데이터 상태 자동 관리

- useMutation: 서버에 데이터 변경 요청
  - onSuccess → queryClient.invalidateQueries() 호출
  - Optimistic UI 적용 가능 (onMutate, onError, onSettled)

──────────────────────────────────────
         │
         ▼
──────────────────────────────────────
 API 호출 함수 (api/)
──────────────────────────────────────
- axios, fetch 등 HTTP 요청 수행
- 순수하게 요청만 담당 (상태 X)
──────────────────────────────────────
         │
         ▼
──────────────────────────────────────
 서버 (API 응답)
──────────────────────────────────────
- 응답 데이터 → React Query 캐시에 저장
- 클라이언트에서 자동 상태 반영 (data, isLoading, error)
──────────────────────────────────────

흐름 요약

  1. 사용자는 UI(버튼 클릭, 페이지 이동 등)를 통해 Zustand 상태 조작하거나 React Query 쿼리 trigger
  2. 클라이언트 UI 상태는 Zustand에서 직접 관리 (예: 모달, 탭, 입력값)
  3. 서버 데이터 요청/변경은 React Query로 관리
  4. React Query는 API 요청 → 캐시 → 상태 관리 (data, isLoading, error)
  5. Mutation 이후에는 invalidateQueries()를 통해 서버 상태 자동 동기화
  6. Optimistic UI 적용 시 → onMutate → UI 즉시 변경 → onError 롤백 → onSettled 최종 동기화
  7. API 호출 함수(api/)는 순수하게 요청만 → 상태 관리 X

✔ 실무 기준 핵심 원칙

  • UI 상태, 세션 → Zustand (React Query 관여 X)
  • 서버 상태 → React Query (Zustand 관여 X)
  • api/와 React Query, Zustand는 분리
  • React Query Devtools로 모든 요청, 캐시, 상태 실시간 확인 → 디버깅 필수

React Query 관련 오류

'No QueryClient set, use QueryClientProvider to set one'는 React Query의 핵심인 QueryClientProvider가 앱 루트에 적용되지 않아서 발생

 

useQuery나 useMutation은 내부적으로 QueryClient 인스턴스에 의존하지만, QueryClientProvider로 앱 전체에 client를 주입하지 않으면 이런 오류 발생.

✔ 해결 방법

App 컴포넌트 (또는 최상위 레이아웃)에서 QueryClientProvider 감싸기

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import DashboardPage from './pages/dashboard';

const queryClient = new QueryClient();

const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <DashboardPage />
    </QueryClientProvider>
  );
};

export default App;

만약 vite나 Next.js 같은 경우 _app.tsx 또는 루트 index.tsx에 등록

import React from 'react';
import ReactDOM from 'react-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';

const queryClient = new QueryClient();

ReactDOM.render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>,
  document.getElementById('root'),
);