11편의 useFetch 패턴은 학습용으론 좋지만 실전엔 부족. 캐싱 없음 → 같은 페이지 두 번 열면 두 번 fetch. 중복 호출 없음 → 컴포넌트 3개가 같은 API 부르면 3번 호출. 자동 재시도·refetch·낙관적 업데이트도 없음. 이걸 다 해결한 게 TanStack Query (옛 React Query) + React 18 의 Suspense.
이번 17편은 TanStack Query 가 왜 useEffect fetch 의 진짜 졸업인지 + Suspense 와 결합 + 회사 stack 표준으로 자리 잡는 이유.
1. TanStack Query — useFetch 가 못 하는 6가지
npm install @tanstack/react-query
// main.tsx — QueryClientProvider 한 번 감쌈
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
createRoot(...).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
// 사용 — useQuery 한 줄
import { useQuery } from '@tanstack/react-query';
function UserProfile({ id }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', id],
queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()),
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMsg />;
return <h1>{data.name}</h1>;
}
표면적으론 12편 useFetch 와 비슷. 안에 들어있는 6가지가 다르다.
useQuery 자동 제공 — ① 캐시 (같은 queryKey 면 메모리에서 즉시 반환). ② 중복 제거 (같은 시점에 같은 key 요청 3건 → 1번만 호출). ③ background refetch (사용자가 윈도우 다시 포커스하면 자동 최신화). ④ 자동 재시도 (실패 시 exponential backoff 3번). ⑤ 낙관적 업데이트 (mutate 직후 UI 먼저 반영). ⑥ devtools (캐시 상태 시각화). 직접 구현하려면 1000+ 라인.
2. queryKey 가 핵심 — 캐시 식별자
useQuery 의 첫 인자 queryKey 가 모든 마법의 기반. 같은 key 면 캐시 공유, 다른 key 면 다른 쿼리.
// 두 컴포넌트가 같은 데이터 → API 1번만 호출됨
function HeaderUser({ id }) {
const { data } = useQuery({ queryKey: ['user', id], queryFn: fetchUser });
return <span>{data?.name}</span>;
}
function SidebarAvatar({ id }) {
const { data } = useQuery({ queryKey: ['user', id], queryFn: fetchUser });
// ← Header 가 먼저 호출했으면 캐시에서 즉시 반환
return <img src={data?.avatar} />;
}
// id 가 바뀌면 다른 key → 다른 캐시 → 새 fetch
// queryKey 배열에 모든 의존 값 포함 (useEffect dependency 와 동일 원칙)
3. mutation — 데이터 변경
fetch GET 은 useQuery, POST/PUT/DELETE 는 useMutation. 변경 후 관련 query 를 자동 무효화 → 자동 refetch.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function AddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo) => fetch('/api/todos', {
method: 'POST', body: JSON.stringify(newTodo)
}).then(r => r.json()),
onSuccess: () => {
// ['todos'] 캐시 무효화 → 리스트 화면이 자동 refetch
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return <button onClick={() => mutation.mutate({ text: '우유' })}>
{mutation.isPending ? '추가 중...' : '추가'}
</button>;
}
이 패턴이 진가 — todo 추가 → 자동으로 목록 화면 갱신. 두 컴포넌트 사이 직접 상태 공유 없이도 동기화.
4. Suspense 모드 — if isLoading 없이
위 예제는 매번 if (isLoading) return <Spinner /> 분기. React 18 의 Suspense 와 useQuery 의 suspense: true 옵션으로 한 단계 더 우아하게:
// useQuery 가 promise throw → 부모 Suspense 가 fallback 표시
function UserProfile({ id }) {
const { data } = useSuspenseQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
});
// ✅ data 가 무조건 있음 (TS 도 알아챔)
return <h1>{data.name}</h1>;
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile id={1} />
</Suspense>
{/* 에러는 18편 Error Boundary 가 잡음 */}
);
}
장점 — ① 데이터 로딩 분기가 본문에서 사라짐 → JSX 간결. ② TypeScript 가 data 의 nullable 을 안 알아도 됨 (항상 있음 보장). ③ 부모 한 곳에 fallback 모음 → 디자인 일관성.
주의 — useSuspenseQuery 는 데이터 없으면 무조건 promise throw. 즉 컴포넌트 본문은 데이터 있을 때만 실행. SSR·점진적 hydration 과 결합 시 신중해야 (22편 RSC 에서 다시). 작은 SPA 는 그냥 useQuery 도 충분히 깨끗.
TanStack Query 가 회사 stack 의 사실상 표준이 된 이유는 명백 — useEffect 로 짠 fetch 코드가 200줄이면, useQuery 로 짜면 30줄. 그것도 더 빠르고 더 안정적. 18편 Error Boundary 와 짝지으면 production-grade 데이터 레이어 완성.
다음 글
React 교재 18편 — Error Boundary. 컴포넌트 트리 안 에러를 격리해 화면 전체가 죽는 사고 방지.