React Query/TanStack 심화

2025. 4. 2. 16:40Frameworks/React

React Query/TanStack 심화

TanStack Query란 무엇인가요?

TanStack Query(이전 이름: React Query)는 서버 상태 관리를 쉽게 해주는 라이브러리입니다. API 요청, 캐싱, 동기화, 데이터 업데이트를 간편하게 처리할 수 있습니다.

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

서버 상태의 특징

  • 원격 서버에 저장됨
  • 비동기적으로 가져와야 함
  • 여러 사용자가 공유함
  • 시간이 지나면 오래된 데이터가 될 수 있음

클라이언트 상태의 특징

  • 클라이언트(브라우저)에 저장됨
  • 즉시 접근 가능
  • 특정 사용자에게만 속함
  • 항상 최신 상태

TanStack Query의 장점

  • 자동 캐싱 및 데이터 재검증
  • 중복 요청 제거
  • 백그라운드 데이터 업데이트
  • 페이지네이션 및 무한 스크롤 지원
  • 에러 및 로딩 상태 관리
  • 서버 상태와 UI 동기화

시작하기

설치

npm install @tanstack/react-query
# 또는
yarn add @tanstack/react-query

기본 설정

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

// QueryClient 인스턴스 생성
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5분
      cacheTime: 1000 * 60 * 30, // 30분
      retry: 1, // 실패 시 1번 재시도
      refetchOnWindowFocus: false, // 창 포커스 시 재요청 비활성화
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* 애플리케이션 컴포넌트 */}
      <ReactQueryDevtools initialIsOpen={false} position="bottom-right" />
    </QueryClientProvider>
  );
}

기본 사용법: 데이터 가져오기

useQuery 훅

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

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUserData(userId),
  });

  if (isLoading) return <div>로딩 중...</div>;
  if (error) return <div>오류 발생: {error.message}</div>;

  return (
    <div>
      <h1>{data.name}</h1>
      <p>이메일: {data.email}</p>
    </div>
  );
}

// API 요청 함수
async function fetchUserData(userId) {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  if (!response.ok) {
    throw new Error('사용자 데이터를 가져올 수 없습니다');
  }
  return response.json();
}

주요 반환 값 이해하기

const {
  data,           // 요청 결과 데이터
  isLoading,      // 첫 요청 로딩 중
  isFetching,     // 모든 요청(재요청 포함) 중
  error,          // 오류 정보
  isError,        // 오류 발생 여부
  refetch,        // 수동으로 다시 요청 트리거
  status,         // 'loading', 'error', 'success' 상태
  ...
} = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUserData,
});

queryKey 이해하기

queryKey는 TanStack Query가 내부적으로 쿼리를 식별하고 캐싱하는 데 사용됩니다.

// 기본 키
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });

// 매개변수가 있는 키
useQuery({ queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId) });

// 객체 매개변수가 있는 키
useQuery({
  queryKey: ['todos', { status, page }],
  queryFn: () => fetchTodos({ status, page })
});

데이터 변경하기

useMutation 훅

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

function CreateTodoForm() {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState('');

  const mutation = useMutation({
    mutationFn: (newTodo) => createTodo(newTodo),
    onSuccess: () => {
      // 성공 시 'todos' 쿼리 무효화하여 다시 가져오기
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      // 입력 폼 초기화
      setTitle('');
    },
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    mutation.mutate({ title, completed: false });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        disabled={mutation.isPending}
      />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? '추가 중...' : '할 일 추가'}
      </button>
      {mutation.isError && <div>오류: {mutation.error.message}</div>}
    </form>
  );
}

// API 함수
async function createTodo(newTodo) {
  const response = await fetch('https://api.example.com/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(newTodo),
  });
  
  if (!response.ok) {
    throw new Error('할 일을 추가할 수 없습니다');
  }
  
  return response.json();
}

낙관적 업데이트

서버 응답을 기다리지 않고 UI를 즉시 업데이트하는 기법입니다.

const queryClient = useQueryClient();

const mutation = useMutation({
  mutationFn: updateTodo,
  // 요청 전에 캐시 업데이트
  onMutate: async (newTodo) => {
    // 진행 중인 요청 취소
    await queryClient.cancelQueries({ queryKey: ['todo', newTodo.id] });
    
    // 이전 값 저장
    const previousTodo = queryClient.getQueryData(['todo', newTodo.id]);
    
    // 낙관적으로 캐시 업데이트
    queryClient.setQueryData(['todo', newTodo.id], newTodo);
    
    // 롤백을 위한 컨텍스트 반환
    return { previousTodo };
  },
  // 오류 발생 시 롤백
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(
      ['todo', newTodo.id],
      context.previousTodo
    );
  },
  // 성공 또는 실패 후 관련 쿼리 다시 가져오기
  onSettled: (newTodo) => {
    queryClient.invalidateQueries({ queryKey: ['todo', newTodo.id] });
  },
});

고급 기능

무한 스크롤/페이지네이션

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

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
    getNextPageParam: (lastPage, allPages) => {
      return lastPage.hasMore ? allPages.length + 1 : undefined;
    },
  });

  if (status === 'pending') return

데이터 의존성 처리

한 쿼리의 결과에 따라 다른 쿼리를 실행해야 할 때 사용합니다.

function UserPosts() {
  // 먼저 현재 사용자 정보 가져오기
  const { data: user, isLoading: userLoading } = useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchCurrentUser,
  });

  // 사용자 정보가 있을 때만 게시글 가져오기
  const { data: posts, isLoading: postsLoading } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => fetchUserPosts(user.id),
    // 사용자 ID가 있을 때만 실행
    enabled: !!user?.id,
  });

  if (userLoading) return <div>사용자 정보 로딩 중...</div>;
  if (!user) return <div>사용자를 찾을 수 없습니다</div>;
  if (postsLoading) return <div>게시글 로딩 중...</div>;

  return (
    <div>
      <h1>{user.name}의 게시글</h1>
      <ul>
        {posts?.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

쿼리 사전 로딩

예측 가능한 사용자 경로에 대해 데이터를 미리 가져올 수 있습니다.

function PostListWithPrefetch() {
  const queryClient = useQueryClient();
  const [posts, setPosts] = useState([]);

  // 게시글 목록 가져오기
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

  // 마우스 오버 시 상세 데이터 미리 가져오기
  const prefetchPost = (postId) => {
    queryClient.prefetchQuery({
      queryKey: ['post', postId],
      queryFn: () => fetchPostDetails(postId),
      staleTime: 1000 * 60 * 5, // 5분
    });
  };

  return (
    <ul>
      {data?.map(post => (
        <li 
          key={post.id}
          onMouseEnter={() => prefetchPost(post.id)}
        >
          <a href={`/posts/${post.id}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  );
}

상태 관리와의 통합

Zustand와 함께 사용하기

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

// Zustand 스토어
const useStore = create((set) => ({
  filters: {
    status: 'all',
    priority: 'all',
  },
  setStatusFilter: (status) => set((state) => ({
    filters: { ...state.filters, status }
  })),
  setPriorityFilter: (priority) => set((state) => ({
    filters: { ...state.filters, priority }
  })),
}));

function TodoList() {
  const queryClient = useQueryClient();
  const { filters, setStatusFilter, setPriorityFilter } = useStore();
  
  // 필터와 함께 할 일 목록 가져오기
  const { data, isLoading } = useQuery({
    queryKey: ['todos', filters],
    queryFn: () => fetchTodos(filters),
  });
  
  if (isLoading) return <div>로딩 중...</div>;
  
  return (
    <div>
      <div className="filters">
        <select 
          value={filters.status} 
          onChange={(e) => setStatusFilter(e.target.value)}
        >
          <option value="all">모든 상태</option>
          <option value="active">진행 중</option>
          <option value="completed">완료됨</option>
        </select>
        
        <select 
          value={filters.priority} 
          onChange={(e) => setPriorityFilter(e.target.value)}
        >
          <option value="all">모든 우선순위</option>
          <option value="high">높음</option>
          <option value="medium">중간</option>
          <option value="low">낮음</option>
        </select>
      </div>
      
      <ul>
        {data?.map(todo => (
          <li key={todo.id}>
            {todo.title} - 우선순위: {todo.priority}
          </li>
        ))}
      </ul>
    </div>
  );
}

실전 활용 패턴

커스텀 훅 만들기

반복적인 쿼리 로직을 커스텀 훅으로 추출하여 재사용성을 높일 수 있습니다.

// API 클라이언트 함수
const api = {
  getTodos: async (filters) => {
    const response = await fetch(`/api/todos?status=${filters.status}&priority=${filters.priority}`);
    return response.json();
  },
  
  getTodoById: async (id) => {
    const response = await fetch(`/api/todos/${id}`);
    return response.json();
  },
  
  createTodo: async (todo) => {
    const response = await fetch('/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(todo),
    });
    return response.json();
  },
  
  updateTodo: async (todo) => {
    const response = await fetch(`/api/todos/${todo.id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(todo),
    });
    return response.json();
  },
  
  deleteTodo: async (id) => {
    await fetch(`/api/todos/${id}`, { method: 'DELETE' });
    return id;
  },
};

// 커스텀 훅
export function useTodos(filters) {
  return useQuery({
    queryKey: ['todos', filters],
    queryFn: () => api.getTodos(filters),
  });
}

export function useTodoById(id) {
  return useQuery({
    queryKey: ['todo', id],
    queryFn: () => api.getTodoById(id),
    enabled: !!id,
  });
}

export function useCreateTodo() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: api.createTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

export function useUpdateTodo() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: api.updateTodo,
    onSuccess: (updatedTodo) => {
      // 개별 할 일 쿼리 업데이트
      queryClient.setQueryData(['todo', updatedTodo.id], updatedTodo);
      // 목록 쿼리 무효화
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

export function useDeleteTodo() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: api.deleteTodo,
    onSuccess: (deletedId) => {
      // 캐시에서 삭제된 항목 제거
      queryClient.removeQueries({ queryKey: ['todo', deletedId] });
      // 목록 쿼리 무효화
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}

// 사용 예시
function TodoApp() {
  const [filters, setFilters] = useState({ status: 'all', priority: 'all' });
  const { data: todos, isLoading } = useTodos(filters);
  const createTodo = useCreateTodo();
  const updateTodo = useUpdateTodo();
  const deleteTodo = useDeleteTodo();
  
  // 컴포넌트 로직
  // ...
}

캐시 상태 조작하기

TanStack Query는 다양한 캐시 조작 메서드를 제공합니다.

const queryClient = useQueryClient();

// 쿼리 데이터 직접 접근
const todos = queryClient.getQueryData(['todos']);

// 쿼리 데이터 직접 설정
queryClient.setQueryData(['todos'], newTodos);

// 기존 데이터 기반 업데이트
queryClient.setQueryData(['todos'], (oldTodos) => {
  return [...oldTodos, newTodo];
});

// 모든 쿼리 무효화
queryClient.invalidateQueries();

// 특정 키로 시작하는 쿼리만 무효화
queryClient.invalidateQueries({ queryKey: ['todos'] });

// 특정 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['todo', id] });

// 쿼리 데이터 제거
queryClient.removeQueries({ queryKey: ['todos'] });

// 쿼리 재설정 (refetch)
queryClient.resetQueries({ queryKey: ['todos'] });

웹소켓/실시간 업데이트 통합

function ChatRoom({ roomId }) {
  const queryClient = useQueryClient();
  
  // 초기 메시지 로드
  const { data: messages } = useQuery({
    queryKey: ['messages', roomId],
    queryFn: () => fetchMessages(roomId),
  });
  
  // 웹소켓 연결 및 실시간 업데이트
  useEffect(() => {
    const socket = new WebSocket('wss://api.example.com/chat');
    
    socket.addEventListener('open', () => {
      socket.send(JSON.stringify({ type: 'join', roomId }));
    });
    
    socket.addEventListener('message', (event) => {
      const data = JSON.parse(event.data);
      
      // 새 메시지가 도착하면 쿼리 캐시 업데이트
      if (data.type === 'new_message') {
        queryClient.setQueryData(['messages', roomId], (oldMessages) => {
          return [...oldMessages, data.message];
        });
      }
    });
    
    return () => {
      socket.close();
    };
  }, [roomId, queryClient]);
  
  // 메시지 전송 처리
  const sendMessage = useMutation({
    mutationFn: (text) => sendChatMessage(roomId, text),
    // 낙관적 업데이트 적용
    onMutate: (text) => {
      const tempMessage = {
        id: 'temp-' + Date.now(),
        text,
        sender: 'me',
        pending: true,
      };
      
      queryClient.setQueryData(['messages', roomId], (oldMessages) => {
        return [...oldMessages, tempMessage];
      });
      
      return { tempMessage };
    },
    // 성공 시 임시 메시지를 실제 메시지로 교체
    onSuccess: (response, text, context) => {
      queryClient.setQueryData(['messages', roomId], (oldMessages) => {
        return oldMessages.map(msg => 
          msg.id === context.tempMessage.id ? response.message : msg
        );
      });
    },
  });
  
  // 컴포넌트 UI 렌더링
  // ...
}

성능 최적화

쿼리 캐시 설정 최적화

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // 데이터가 오래된 것으로 간주되는 시간 (ms)
      staleTime: 1000 * 60 * 5, // 5분
      
      // 사용하지 않는 캐시 데이터 유지 시간 (ms)
      gcTime: 1000 * 60 * 30, // 30분
      
      // 데이터 변경사항 폴링 간격 (ms)
      // refetchInterval: 1000 * 30, // 30초
      
      // 브라우저 탭 포커스 시 재요청
      refetchOnWindowFocus: true,
      
      // 브라우저 온라인 상태가 되면 재요청
      refetchOnReconnect: true,
      
      // 에러 발생 시 재시도 횟수
      retry: 3,
    },
  },
});

선택적 쿼리 데이터 (Data Selection)

쿼리 결과의 일부만 선택하여 불필요한 리렌더링을 방지합니다.

function UserNameDisplay({ userId }) {
  // 전체 사용자 데이터 대신 이름만 선택
  const { data: userName } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    select: (user) => user.name,
  });
  
  return <div>{userName}</div>;
}

// 다른 곳에서 같은 쿼리를 사용하되 다른 데이터 선택
function UserEmailDisplay({ userId }) {
  // 동일한 쿼리를 사용하지만 이메일만 선택
  const { data: userEmail } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    select: (user) => user.email,
  });
  
  return <div>{userEmail}</div>;
}

페이지네이션 성능 최적화

function PaginatedList() {
  const [page, setPage] = useState(1);
  
  const { data, isPending } = useQuery({
    queryKey: ['posts', page],
    queryFn: () => fetchPosts(page),
    // 이전 페이지 데이터 유지
    keepPreviousData: true,
  });
  
  // 다음 페이지 미리 가져오기
  const queryClient = useQueryClient();
  
  useEffect(() => {
    if (data?.hasMore) {
      queryClient.prefetchQuery({
        queryKey: ['posts', page + 1],
        queryFn: () => fetchPosts(page + 1),
      });
    }
  }, [data, page, queryClient]);
  
  return (
    <div>
      {isPending ? (
        <div>로딩 중...</div>
      ) : (
        <>
          <ul>
            {data.posts.map(post => (
              <li key={post.id}>{post.title}</li>
            ))}
          </ul>
          
          <div>
            <button
              onClick={() => setPage(old => Math.max(old - 1, 1))}
              disabled={page === 1}
            >
              이전 페이지
            </button>
            <span>페이지 {page}</span>
            <button
              onClick={() => setPage(old => old + 1)}
              disabled={!data.hasMore}
            >
              다음 페이지
            </button>
          </div>
        </>
      )}
    </div>
  );
}

요약 및 모범 사례

핵심 개념

  • TanStack Query는 서버 상태 관리를 위한 강력한 도구입니다
  • useQuery로 데이터를 가져오고, useMutation으로 데이터를 변경합니다
  • 쿼리 키는 캐싱과 무효화의 기준이 됩니다
  • 자동 재요청, 에러 처리, 낙관적 업데이트 기능을 제공합니다

모범 사례

  • API 함수와 쿼리 로직을 분리하여 테스트와 유지보수성 향상
  • 복잡한 쿼리 로직은 커스텀 훅으로 추출
  • 적절한 staleTime과 gcTime 설정으로 성능 최적화
  • 낙관적 업데이트로 사용자 경험 개선
  • QueryDevtools를 개발 환경에서 활성화하여 디버깅 용이성 확보

주의사항

  • 너무 많은 의존성이 있는 쿼리 키는 불필요한 재요청 유발
  • 큰 데이터셋은 select 옵션으로 필터링하여 메모리 사용량 관리
  • 반복적인 백그라운드 재요청은 서버 부하 고려
  • 모든 변경에 낙관적 업데이트를 적용할 필요는 없음

다음 단계

  • TanStack Query 공식 문서 참조
  • 실제 프로젝트에 점진적으로 도입
  • 더 복잡한 사용 사례 탐색 (쿼리 무효화 전략, 사용자 정의 캐시 조작 등)

'Frameworks > React' 카테고리의 다른 글

Next.js 확장과 차이점  (1) 2025.04.02
React 성능 분석 실전  (0) 2025.04.02
테스트 전략 (Jest, RTL, Cypress)  (0) 2025.04.02
React + TypeScript 도입과 패턴  (0) 2025.04.02
React 애플리케이션 성능 최적화  (0) 2025.04.01