React Query를 실무에 잘 적용하는 방법: 기준, 패턴, 자주 하는 실수
React Query는 입문 자체보다 프로젝트에 오래 녹여 넣는 단계가 더 중요합니다. useQuery, useMutation 문법은 금방 익숙해지지만, 실제 팀 코드베이스에서는 이런 문제가 곧바로 생깁니다.
- query key가 화면마다 제각각이다
- 같은 데이터를 어떤 곳은 5초, 어떤 곳은 5분 stale로 본다
- mutation 이후 invalidate 규칙이 통일되지 않는다
- 로딩과 에러 UI가 화면마다 다르게 보인다
- 공통 훅 추상화가 과해져서 오히려 읽기 어렵다
즉, 실무에서 중요한 것은 "라이브러리 사용법"보다 데이터 동기화 기준을 팀 안에서 어떻게 통일할 것인가입니다.
이번 글에서는 그 기준을 중심으로 정리해보겠습니다.
한눈에 보면
짧게 정리하면 이렇습니다.
React Query는 잘 쓰면 강력하지만, 규칙 없이 쓰면 캐시가 오히려 더 헷갈릴 수 있습니다.- 실무에서는
queryKey,staleTime, invalidation 정책부터 통일하는 편이 좋습니다. - 공통 추상화는 필요하지만, 너무 빨리 숨기면 오히려 디버깅이 어려워집니다.
- 로딩, 에러, 낙관적 업데이트는 "훅 문법"이 아니라 UX 설계로 봐야 합니다.
1. query key 규칙부터 정하자
실무에서 가장 먼저 무너지는 지점 중 하나입니다.
예를 들어 아래가 섞여 있다고 가정해보겠습니다.
['posts']['postList'][('posts', 'list')][('posts', { page })][('article', postId)];전부 게시글을 뜻하는 것 같지만, 규칙이 제각각이면 아래 문제가 생깁니다.
- 어떤 걸 invalidate해야 할지 감이 안 온다
- 리스트와 상세 관계가 드러나지 않는다
- 새 팀원이 키 규칙을 추론하기 어렵다
그래서 보통은 도메인 단위로 query key factory를 두는 편이 좋습니다.
export const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters: { page: number; sort: string }) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, 'detail'] as const,
detail: (postId: number) => [...postKeys.details(), postId] as const,
};이렇게 해두면:
- 조회할 때도
- prefetch할 때도
- invalidate할 때도
같은 언어를 쓸 수 있습니다.
useQuery({
queryKey: postKeys.detail(postId),
queryFn: () => getPost(postId),
});
queryClient.invalidateQueries({ queryKey: postKeys.lists() });핵심은 멋진 패턴이 아니라, 팀 전체가 같은 query naming 문법을 갖는 것입니다.
2. staleTime 기본값을 화면마다 감으로 정하지 말자
처음엔 아무 옵션 없이 시작해도 됩니다. 하지만 프로젝트가 커지면 곧 "왜 여기만 자꾸 refetch되지?" 같은 질문이 나옵니다.
실무에서는 보통 데이터 성격을 기준으로 나누는 편이 훨씬 낫습니다.
예를 들어:
- 사용자 프로필, 카테고리 목록: 길게
- 대시보드 통계: 중간
- 실시간성 높은 피드/알림: 짧게
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 30,
retry: 1,
refetchOnWindowFocus: false,
},
},
});그리고 예외가 필요한 query만 개별 override 합니다.
useQuery({
queryKey: ['notifications'],
queryFn: getNotifications,
staleTime: 1000 * 5,
});즉, 기준은 "화면마다 마음대로"가 아니라 기본 정책을 하나 두고, 데이터 특성 때문에 예외를 선언하는 구조가 좋습니다.
3. fetch 함수와 query 훅의 경계를 나누자
실무에서 자주 나오는 구조는 이렇습니다.
- API 함수
- query option 생성 함수
- 화면에서 쓰는 custom hook
예를 들면:
export async function getPost(postId: number) {
const response = await fetch(`/api/posts/${postId}`);
if (!response.ok) {
throw new Error('게시글 상세를 불러오지 못했습니다.');
}
return response.json();
}import { queryOptions } from '@tanstack/react-query';
export function postDetailQueryOptions(postId: number) {
return queryOptions({
queryKey: postKeys.detail(postId),
queryFn: () => getPost(postId),
staleTime: 1000 * 60,
});
}export function usePostDetail(postId: number) {
return useQuery(postDetailQueryOptions(postId));
}이렇게 나누면 좋은 점이 있습니다.
- 화면은 읽기 쉬워지고
- prefetch나 SSR hydrate에도 같은 옵션을 재사용할 수 있고
- 테스트와 디버깅 포인트도 명확해집니다
다만 모든 걸 훅으로 감추는 것은 또 다른 문제를 만듭니다.
4. 공통 훅 추상화는 너무 빨리 하지 말자
실무에서 흔한 실수 중 하나가 "중복이 보이니까 일단 훅으로 감춘다"입니다.
예를 들어 아래 같은 훅은 처음에는 좋아 보일 수 있습니다.
function useApiQuery<TData>(key: unknown[], fetcher: () => Promise<TData>) {
return useQuery({
queryKey: key,
queryFn: fetcher,
staleTime: 1000 * 30,
});
}하지만 시간이 지나면:
- 어떤 query는 retry가 달라야 하고
- 어떤 query는 select가 필요하고
- 어떤 query는 placeholderData가 필요하고
- 어떤 query는 refetchOnMount 정책이 달라야 합니다
결국 범용 훅이 옵션을 계속 받아야 하고, 그 순간 단순화를 위해 만든 추상화가 다시 복잡한 우회 레이어가 됩니다.
그래서 실무에서는 보통:
- fetch 함수는 공통화
- query key 규칙은 통일
- query option 함수는 재사용
- 도메인 훅은 필요한 경우에만 만든다
정도의 균형이 좋습니다.
5. mutation 이후 규칙을 먼저 정하자
생성, 수정, 삭제 뒤에는 항상 질문이 나옵니다.
- 어떤 query를 invalidate할까
- 캐시를 직접 바꿀까
- optimistic update를 할까
이걸 화면마다 즉흥적으로 정하면 나중에 데이터 불일치가 자주 생깁니다.
예를 들어 게시글 수정이라면 아래처럼 원칙을 둘 수 있습니다.
- 상세는 직접 캐시 갱신
- 리스트는 invalidate
- 카운트/통계는 invalidate
const updatePostMutation = useMutation({
mutationFn: updatePost,
onSuccess: (updatedPost) => {
queryClient.setQueryData(postKeys.detail(updatedPost.id), updatedPost);
queryClient.invalidateQueries({ queryKey: postKeys.lists() });
},
});이렇게 해두면:
- 현재 보고 있는 상세 화면은 즉시 반영되고
- 목록은 서버 기준으로 다시 정리되며
- 데이터 일관성을 비교적 쉽게 유지할 수 있습니다
즉, mutation은 API 호출 훅이 아니라 읽기 캐시를 어떻게 다시 맞출 것인가의 설계 포인트입니다.
6. optimistic update는 "빠르게 보이게"보다 "롤백 가능하게"가 핵심이다
낙관적 업데이트는 UX를 좋게 만들 수 있지만, 그만큼 실패 시 복구 전략이 중요합니다.
그래서 실무에서는 아래 질문을 먼저 합니다.
- 실패 가능성이 얼마나 높은가
- 충돌이 자주 나는 데이터인가
- 롤백했을 때 사용자 혼란이 큰가
예를 들어 좋아요, 체크박스 토글, 즐겨찾기처럼 단순 액션은 optimistic update가 잘 맞습니다. 반면 복잡한 폼 저장처럼 실패 케이스가 많은 작업은 섣불리 optimistic하게 가지 않는 편이 낫습니다.
즉, optimistic update는 기술이 아니라 도메인 신뢰도와 UX 비용의 판단입니다.
7. 로딩 상태를 하나로 뭉개지 말자
실무에서 흔한 안티패턴은 모든 비동기 상태를 isLoading 하나로 처리하는 것입니다.
하지만 실제 사용자 경험은 다릅니다.
- 첫 진입의 빈 상태
- 이미 데이터가 있는데 백그라운드 갱신 중인 상태
- 버튼 하나만 pending인 상태
이 상태를 모두 같은 spinner로 처리하면 UX가 투박해집니다.
예를 들어:
const { data, isPending, isFetching } = useQuery(...);이때 보통은:
isPending && !data: 스켈레톤isFetching && data: 작은 상단 로딩 표시
처럼 분리하는 편이 자연스럽습니다.
즉, React Query를 잘 쓴다는 건 훅을 잘 쓰는 것보다 로딩 상태의 의미를 잘 나누는 것에 더 가깝습니다.
8. 에러 처리 방식도 통일하자
화면마다 에러를 다르게 다루면 사용자 경험도 흔들리고, 개발자도 어디서 무엇을 처리해야 하는지 헷갈립니다.
보통은 아래 레벨을 나눠서 보는 편이 좋습니다.
- 공통 네트워크 에러 처리
- 페이지 레벨 에러 경계
- 컴포넌트 내부 재시도 버튼
- mutation 실패 토스트
예를 들어 읽기 실패는 화면 단위 fallback이 잘 맞고, mutation 실패는 사용자 액션과 직접 연결되므로 toast나 inline error가 더 자연스러울 수 있습니다.
중요한 것은 일관성입니다. "여기서는 alert, 저기서는 아무것도 없음" 같은 상태를 줄이는 것이 더 중요합니다.
9. select를 이용해 화면에 필요한 데이터만 가공하자
실무에서는 API 응답이 화면이 원하는 모양과 꼭 같지 않습니다. 이때 컴포넌트 안에서 매번 가공하기보다 select를 활용할 수 있습니다.
const { data } = useQuery({
queryKey: postKeys.detail(postId),
queryFn: () => getPost(postId),
select: (post) => ({
id: post.id,
title: post.title,
commentCount: post.comments.length,
}),
});이 패턴의 장점은:
- 화면 코드를 가볍게 하고
- 파생 데이터 계산 위치를 한곳에 모으며
- 재사용성과 가독성을 높일 수 있다는 점입니다
다만 너무 많은 비즈니스 로직을 select 안에 밀어 넣는 것은 피하는 편이 좋습니다.
10. React Query를 전역 상태 관리 도구처럼 쓰지 말자
이것도 자주 나오는 실수입니다.
예를 들어 모달 open 여부, 탭 상태, 검색 input 값까지 전부 query cache로 밀어 넣는 식은 보통 과합니다.
React Query는 기본적으로 서버 상태를 위한 도구입니다. client state까지 한 도구로 통일하고 싶은 마음이 들 수는 있지만, 그 순간 모델이 흐려집니다.
보통은 이렇게 나누는 편이 자연스럽습니다.
- server state:
React Query - local UI state:
useState,useReducer - 앱 전역 client state: 필요 시 Zustand 같은 별도 도구
즉, 도구를 넓게 쓰는 것보다 문제에 맞는 경계를 유지하는 것이 더 중요합니다.
11. Next.js나 SSR 문맥에서는 "언제 hydrate할지"까지 같이 보자
실무에서는 React 단독 SPA만 있는 것이 아니라, Next.js 같은 환경과 함께 가는 경우가 많습니다. 이때는 단순 클라이언트 fetch만 보는 것이 아니라:
- 서버에서 prefetch할지
- 클라이언트에서만 가져올지
- 어느 데이터를 hydrate할지
까지 같이 보게 됩니다.
여기서도 핵심은 같습니다. React Query를 도입한다고 해서 모든 데이터를 다 hydrate하는 것이 아니라, 초기 진입 UX에 정말 도움이 되는 데이터만 선별하는 판단이 필요합니다.
12. 팀에 적용할 때 추천하는 최소 규칙
실무 기준으로 너무 무겁지 않게 시작하려면 아래 정도만 먼저 맞춰도 꽤 좋아집니다.
- query key naming 규칙을 정한다
- 기본
staleTime, retry, focus refetch 정책을 정한다 - mutation 이후 invalidate 규칙을 도메인별로 정한다
- 로딩 상태를 first load / background fetch로 구분한다
- 공통 추상화는 fetch 함수와 query option 수준까지만 먼저 가져간다
이 다섯 가지만 있어도 "각자 자기 방식으로 쓰는 React Query" 상태는 꽤 줄일 수 있습니다.
자주 하는 실수
마지막으로 자주 보게 되는 실수를 정리하면 이렇습니다.
- query key를 규칙 없이 만든다
- 모든 query에 같은
staleTime을 감으로 넣는다 - mutation 성공 후 invalidate를 빼먹는다
- optimistic update를 롤백 없이 넣는다
- 범용 훅 추상화를 너무 빨리 한다
React Query를 client state 저장소처럼 쓴다
이 실수들은 모두 문법 실수라기보다 데이터 모델과 팀 규칙이 없는 상태에서 생깁니다.
정리하면
React Query를 실무에 잘 적용한다는 것은 결국 아래를 잘 정하는 것입니다.
- query key를 어떤 문법으로 구성할지
- 데이터별 신선도 기준을 어떻게 둘지
- mutation 이후 어떤 캐시를 어떻게 갱신할지
- 로딩/에러 상태를 UX 관점에서 어떻게 나눌지
- 공통화는 어디까지 하고 어디서 멈출지
즉, 좋은 React Query 코드는 훅을 많이 쓴 코드가 아니라 캐시와 동기화 전략이 팀 차원에서 읽히는 코드에 가깝습니다.
시리즈 전체를 한 줄로 묶으면:
- 1편은 왜 필요한지
- 2편은 어떻게 쓰는지
- 3편은 팀 안에서 어떻게 기준을 세울지
를 다뤘습니다. 이 세 단계가 연결되면 React Query는 단순 fetch 라이브러리가 아니라, 서버 상태를 다루는 운영 도구로 보이기 시작합니다.
