React Query 간단 설명

ys10·2025년 9월 24일

React 애플리케이션에서 API 요청을 직접 처리하면 로딩, 에러, 캐싱 같은 작업을 매번 구현해야 해서 번거롭습니다. React Query(TanStack Query)는 이런 반복 코드를 줄이고 서버 상태를 자동으로 관리해줍니다.

이 글에서는 React Query의 핵심 개념인 Query와 Mutation, 그리고 queryKey 관리 방법을 v5 기준으로 간단히 정리할게요.

React Query는 데이터 페칭을 "쿼리"라는 개념으로 추상화하고 캐싱, 자동 재요청, 에러 처리까지 알아서 처리합니다. 결과적으로 코드가 간결해지고 성능과 생산성이 크게 향상되죠. Zustand 같은 상태 관리 라이브러리와 함께 쓰면 더 좋지요.
개인적으로 리액트의 hook에 충실하다는 점을 좋아합니다.

기본 설정: QueryClient와 Provider

React Query를 사용하려면 앱 최상단에 QueryClient를 설정합니다. 이는 전역 설정을 관리하는 인스턴스입니다.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; // 개발 도구 (optional)

// QueryClient 생성: 기본 옵션 설정
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,       // 데이터가 "fresh"한 시간 (1분 동안 재요청 안 함)
      gcTime: 5 * 60 * 1000,      // 캐시 유지 시간 (v5부터 cacheTime -> gcTime으로 변경)
      retry: 3,                   // 실패 시 재시도 횟수
      refetchOnWindowFocus: true, // 창 포커스 시 자동 재페칭 (기본 true, 필요 시 false)
      refetchOnReconnect: true,   // 네트워크 재연결 시 재페칭
    },
    mutations: {
      retry: 1, // 뮤테이션 실패 시 재시도
    },
  },
});

// App 컴포넌트
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourRoutesOrComponents />
      {/* 개발 환경에서 Devtools 활성화 - 브라우저에서 쿼리 상태 확인 가능 */}
      {process.env.NODE_ENV === 'development' && <ReactQueryDevtools initialIsOpen={false} />}
    </QueryClientProvider>
  );
}
  • staleTime: 데이터가 신선한 시간. 이 시간 내에는 캐시된 데이터를 사용합니다.
  • gcTime: 사용되지 않는 쿼리가 메모리에서 제거되기까지의 시간.
  • retry: 자동 재시도 횟수 (지수 백오프 방식으로 지연됨).
  • ReactQueryDevtools: 브라우저 개발자 도구에서 쿼리 캐시, 상태 등을 시각적으로 확인할 수 있습니다. 매우 유용!

Query: 데이터 페칭과 읽기

Query는 "읽기" 작업을 위한 핵심 기능입니다. API에서 데이터를 가져오고, 캐싱하며, 상태를 관리합니다. 주 hook은 useQuery와 useSuspenseQuery입니다.

useQuery 기본 사용

useQuery는 데이터, 로딩, 에러 상태를 반환합니다. queryKey로 쿼리를 식별합니다 (캐싱의 키 역할).

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

async function fetchUser(userId: string) {
  const res = await fetch(`/api/users/${userId}`);
  if (!res.ok) throw new Error('Network error');
  return res.json();
}

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, isError, error, refetch } = useQuery({
    queryKey: ['user', userId], // 고유 키: 배열 형식으로 의존성 관리
    queryFn: () => fetchUser(userId), // 페칭 함수
    staleTime: 30 * 1000, // 개별 쿼리 옵션 오버라이드
  });

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

  return (
    <div>
      <h1>{user?.name}</h1>
      <button onClick={refetch}>새로고침</button> {/* 수동 재페칭 */}
    </div>
  );
}
  • queryKey: 쿼리의 고유 식별자. 배열로 구성하면 의존성 (e.g., userId)이 변경될 때 자동 재페칭.
  • queryFn: 비동기 함수로 데이터 페칭.
  • 반환 값:
    • data: 성공 시 데이터.
    • isLoading: 초기 로딩 중 (캐시 없음).
    • isFetching: 백그라운드 페칭 중 (e.g., stale 데이터 사용 중 재페칭).
    • isError, error: 에러 상태.
    • refetch: 수동 재페칭 함수.
  • 자동 기능: 캐싱 (같은 queryKey면 재사용), 백그라운드 업데이트 (stale 시 자동 재페칭), 무효화 (invalidateQueries로 캐시 무효화).

useSuspenseQuery: Suspense 통합

React 18+에서 Suspense를 활용하면 로딩/에러 처리를 부모로 위임할 수 있습니다. 코드가 더 깔끔해집니다.

import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary'; // 별도 라이브러리 or 커스텀

function ErrorFallback({ error }: { error: Error }) {
  return <div>에러 발생: {error.message}</div>;
}

function UserProfile({ userId }: { userId: string }) {
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // 데이터가 보장됨! isLoading/isError 없음
  return (
    <div>
      <h1>{user.name}</h1>
    </div>
  );
}

function ParentComponent() {
  return (
    <ErrorBoundary fallback={<ErrorFallback />}>
      <Suspense fallback={<div>로딩 중...</div>}>
        <UserProfile userId="123" />
        {/* 여러 컴포넌트 로딩을 하나의 Suspense로 처리 가능 */}
      </Suspense>
    </ErrorBoundary>
  );
}
  • 장점: 컴포넌트 내 로딩/에러 코드 제거. 여러 쿼리의 로딩을 그룹화.
  • 주의: Suspense는 Promise를 throw하므로, React 18+에서만 동작.

고급 Query 기능

  • Enabled: enabled: false로 쿼리 실행 지연 (e.g., userId가 있을 때만).
  • Select: select: (data) => data.name으로 데이터 변환.
  • Infinite Queries: 무한 스크롤에 사용 (useInfiniteQuery). 페이지네이션 API에 적합.
  • Prefetching: queryClient.prefetchQuery로 미리 페칭.
  • Invalidate: queryClient.invalidateQueries({ queryKey: ['user'] })로 캐시 무효화 (e.g., mutation 후).

Mutation: 데이터 변경과 쓰기

Mutation은 "쓰기" 작업 (POST, PUT, DELETE 등)을 위한 hook입니다. Optimistic Updates를 지원해 UI를 즉시 반영합니다.

useMutation 기본 사용

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

async function updateUser(userId: string, newName: string) {
  const res = await fetch(`/api/users/${userId}`, {
    method: 'PUT',
    body: JSON.stringify({ name: newName }),
  });
  if (!res.ok) throw new Error('Update failed');
  return res.json();
}

function UserEditor({ userId }: { userId: string }) {
  const queryClient = useQueryClient(); // 쿼리 클라이언트 접근
  const { mutate, isPending, isError, error } = useMutation({
    mutationFn: ({ newName }: { newName: string }) => updateUser(userId, newName),
    onSuccess: (updatedUser) => {
      // 성공 시 쿼리 무효화 -> 자동 재페칭
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
      // 또는 setQueryData로 직접 업데이트: queryClient.setQueryData(['user', userId], updatedUser);
    },
    onError: (err) => console.error('Mutation error:', err),
  });

  const handleUpdate = () => {
    mutate({ newName: 'New Name' });
  };

  if (isPending) return <div>업데이트 중...</div>;
  if (isError) return <div>에러: {error?.message}</div>;

  return <button onClick={handleUpdate}>이름 업데이트</button>;
}
  • mutationFn: 변경 함수.
  • mutate: 실행 함수 (인자 전달 가능).
  • 반환 값: isPending (진행 중), isError, error, data (성공 데이터).
  • 콜백:
    • onMutate: mutate 직전 호출 (Optimistic Update: 임시 UI 업데이트).
    • onSuccess: 성공 시.
    • onError: 실패 시 (Rollback: onMutate에서 저장한 이전 데이터로 복구).
    • onSettled: 성공/실패 상관없이 호출.

Optimistic Updates 예시

useMutation({
  mutationFn: updateUser,
  onMutate: async (variables) => {
    const previousUser = queryClient.getQueryData(['user', userId]);
    queryClient.setQueryData(['user', userId], { ...previousUser, name: variables.newName }); // 임시 업데이트
    return { previousUser }; // rollback 위해 저장
  },
  onError: (err, variables, context) => {
    queryClient.setQueryData(['user', userId], context?.previousUser); // rollback
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['user', userId] }); // 최종 동기화
  },
});

이렇게 하면 UI가 즉시 반영되어 사용자 경험이 좋아집니다.

QueryKeys 중앙 관리: 베스트 프랙티스

queryKey는 쿼리의 ID 역할을 하므로, 문자열 오타나 중복을 방지하기 위해 중앙에서 관리하는 게 좋습니다. 상수 객체 또는 팩토리 함수를 사용합니다.

팩토리 함수 사용

// src/queryKeys.ts
export const queryKeys = {
  user: {
    detail: (userId: string) => ['user', { userId }],
    list: () => ['users'],
  },
  post: {
    detail: (postId: string) => ['post', { postId }],
    list: (filters: { category?: string }) => ['posts', { filters }],
  },
};

// 사용 예
useQuery({
  queryKey: queryKeys.user.detail('123'),
  queryFn: () => fetchUser('123'),
});

// 부분 무효화: 모든 user 관련 쿼리
queryClient.invalidateQueries({ queryKey: ['user'], exact: false });
  • 장점: 복잡한 의존성 (e.g., 필터) 관리 쉽음. 부분 키로 무효화 가능 (exact: false).
  • 팁: TypeScript에서 제네릭으로 타입 강화 가능.

이렇게 중앙화하면 코드가 일관되고, 리팩토링 시 쉽습니다.

profile
hyu infosys24

0개의 댓글