React 교재 · 고급 17편

React Suspense + TanStack Query

useEffect 안의 fetch + loading + error 5줄을 한 줄로. 모던 데이터 fetch 의 표준.

스피너가 콘텐츠로 변하는 페이드 트랜지션 일러스트 — Suspense 비동기 컨셉

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. 컴포넌트 트리 안 에러를 격리해 화면 전체가 죽는 사고 방지.

© 2026 주나이테크(주) @JUNAITECH