React 애플리케이션에서 API 요청을 직접 처리하면 로딩, 에러, 캐싱 같은 작업을 매번 구현해야 해서 번거롭습니다. React Query(TanStack Query)는 이런 반복 코드를 줄이고 서버 상태를 자동으로 관리해줍니다.
이 글에서는 React Query의 핵심 개념인 Query와 Mutation, 그리고 queryKey 관리 방법을 v5 기준으로 간단히 정리할게요.
React Query는 데이터 페칭을 "쿼리"라는 개념으로 추상화하고 캐싱, 자동 재요청, 에러 처리까지 알아서 처리합니다. 결과적으로 코드가 간결해지고 성능과 생산성이 크게 향상되죠. Zustand 같은 상태 관리 라이브러리와 함께 쓰면 더 좋지요.
개인적으로 리액트의 hook에 충실하다는 점을 좋아합니다.
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>
);
}
Query는 "읽기" 작업을 위한 핵심 기능입니다. API에서 데이터를 가져오고, 캐싱하며, 상태를 관리합니다. 주 hook은 useQuery와 useSuspenseQuery입니다.
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>
);
}
data: 성공 시 데이터.isLoading: 초기 로딩 중 (캐시 없음).isFetching: 백그라운드 페칭 중 (e.g., stale 데이터 사용 중 재페칭).isError, error: 에러 상태.refetch: 수동 재페칭 함수.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>
);
}
enabled: false로 쿼리 실행 지연 (e.g., userId가 있을 때만).select: (data) => data.name으로 데이터 변환.Mutation은 "쓰기" 작업 (POST, PUT, DELETE 등)을 위한 hook입니다. Optimistic Updates를 지원해 UI를 즉시 반영합니다.
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>;
}
isPending (진행 중), isError, error, data (성공 데이터).onMutate: mutate 직전 호출 (Optimistic Update: 임시 UI 업데이트).onSuccess: 성공 시.onError: 실패 시 (Rollback: onMutate에서 저장한 이전 데이터로 복구).onSettled: 성공/실패 상관없이 호출.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가 즉시 반영되어 사용자 경험이 좋아집니다.
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 });
이렇게 중앙화하면 코드가 일관되고, 리팩토링 시 쉽습니다.