블로그 목록
라이브러리상태관리성능 최적화

React Query로 서버 상태 관리하기

React Query (TanStack Query)

  • 공식 홈페이지
  • 서버에서 가져온 데이터(서버 상태)를 클라이언트에서 관리하기 위한 라이브러리입니다.

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

상태 관리를 논할 때 두 가지를 구분하는 것이 중요합니다.

| 구분 | 예시 | 특징 | | --- | --- | --- | | 클라이언트 상태 | 모달 open/close, 폼 입력값, 테마 | 소유권이 클라이언트에 있음 | | 서버 상태 | 사용자 목록, 게시글, 상품 재고 | 소유권이 서버에 있음, 비동기, 공유됨 |

Redux, Zustand 같은 라이브러리는 클라이언트 상태에 최적화되어 있습니다. 서버 상태를 이들로 관리하면 캐싱, 재요청, 동기화 등의 로직을 직접 구현해야 하는 문제가 생깁니다.

React Query는 서버 상태 관리에 특화된 라이브러리로, 이런 복잡성을 해결해 줍니다.


설치

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

개발 편의를 위해 devtools도 함께 설치합니다.

npm install @tanstack/react-query-devtools

기본 설정

앱 최상단에 QueryClientProvider를 감싸줍니다.

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5분간 fresh 상태 유지
      retry: 1,                  // 실패 시 1회 재시도
    },
  },
});

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

useQuery — 데이터 조회

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

async function fetchUser(id: number) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
}

function UserProfile({ userId }: { userId: number }) {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['user', userId],  // 캐시 키
    queryFn: () => fetchUser(userId),
  });

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

  return <div>{data.name}</div>;
}

queryKey의 중요성

queryKey는 캐시를 식별하는 고유 키입니다. 배열 형태로 사용하며, queryKey가 바뀌면 자동으로 새 요청이 발생합니다.

// userId가 바뀌면 자동으로 새 사용자 데이터를 가져옴
useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
});

// 필터 조건이 달라져도 각각 캐싱됨
useQuery({
  queryKey: ['posts', { page, status }],
  queryFn: () => fetchPosts(page, status),
});

staleTime vs gcTime

useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 1000 * 60,     // 1분간 fresh → 그 동안 캐시를 그대로 사용
  gcTime:    1000 * 60 * 5, // 5분간 캐시 보관 → 이후 가비지 컬렉션
});
  • staleTime: 데이터가 "신선"하다고 판단하는 시간. 이 안에는 refetch하지 않음.
  • gcTime: 사용되지 않는 캐시를 메모리에 보관하는 시간.

useMutation — 데이터 변경

서버 데이터를 생성·수정·삭제할 때 사용합니다.

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

async function updateUser(user: { id: number; name: string }) {
  const res = await fetch(`/api/users/${user.id}`, {
    method: 'PATCH',
    body: JSON.stringify(user),
  });
  return res.json();
}

function EditUser({ userId }: { userId: number }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: updateUser,
    onSuccess: () => {
      // 성공 후 관련 캐시 무효화 → 자동으로 최신 데이터 재요청
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
    onError: (error) => {
      console.error('수정 실패:', error);
    },
  });

  return (
    <button
      onClick={() => mutation.mutate({ id: userId, name: '새 이름' })}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? '저장 중...' : '저장'}
    </button>
  );
}

Optimistic Update (낙관적 업데이트)

서버 응답을 기다리지 않고 UI를 먼저 업데이트한 다음, 실패하면 롤백하는 패턴입니다. UX가 크게 개선됩니다.

const mutation = useMutation({
  mutationFn: updateUser,
  onMutate: async (newUser) => {
    // 진행 중인 refetch 취소 (낙관적 업데이트와 충돌 방지)
    await queryClient.cancelQueries({ queryKey: ['user', newUser.id] });

    // 현재 캐시 스냅샷 저장 (롤백용)
    const previous = queryClient.getQueryData(['user', newUser.id]);

    // 캐시를 즉시 업데이트 (낙관적)
    queryClient.setQueryData(['user', newUser.id], newUser);

    return { previous };
  },
  onError: (err, newUser, context) => {
    // 실패 시 스냅샷으로 롤백
    queryClient.setQueryData(['user', newUser.id], context?.previous);
  },
  onSettled: (data, err, newUser) => {
    // 성공·실패 모두 최종적으로 서버 데이터로 동기화
    queryClient.invalidateQueries({ queryKey: ['user', newUser.id] });
  },
});

의존 쿼리 (Dependent Queries)

앞선 쿼리 결과가 있어야 실행되는 쿼리는 enabled 옵션으로 제어합니다.

function UserOrders({ userId }: { userId: number }) {
  // 1. 먼저 사용자 정보 조회
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // 2. user가 있을 때만 주문 목록 조회
  const { data: orders } = useQuery({
    queryKey: ['orders', user?.id],
    queryFn: () => fetchOrders(user!.id),
    enabled: !!user,  // user가 없으면 실행 안 함
  });

  return <div>{orders?.length}개 주문</div>;
}

무한 스크롤 (useInfiniteQuery)

페이지네이션이나 무한 스크롤 구현에 사용합니다.

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

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

  return (
    <>
      {data?.pages.map((page) =>
        page.posts.map((post) => <PostCard key={post.id} post={post} />)
      )}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? '로딩 중...' : '더 보기'}
      </button>
    </>
  );
}

실전 팁

커스텀 훅으로 분리하기

쿼리 로직을 커스텀 훅으로 감싸면 컴포넌트가 깔끔해집니다.

// hooks/useUser.ts
export function useUser(userId: number) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 1000 * 60 * 5,
  });
}

// 사용
function Profile({ userId }: { userId: number }) {
  const { data: user, isPending } = useUser(userId);
  // ...
}

에러 바운더리와 함께 사용하기

useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  throwOnError: true, // 에러를 React Error Boundary로 전파
});

Redux vs React Query 선택 기준

| 상황 | 권장 | | --- | --- | | UI 전용 상태 (모달, 폼, 토글) | Zustand / Redux | | 서버 데이터 조회·캐싱·동기화 | React Query | | 복잡한 클라이언트 비즈니스 로직 | Redux Toolkit | | 서버 데이터 + 낙관적 업데이트 | React Query |

많은 경우 Redux로 서버 상태를 관리하던 부분을 React Query로 교체하면 코드량이 절반 이하로 줄어들고, 캐싱·재요청·동기화를 자동으로 처리해줘서 유지보수가 쉬워집니다.