
1편에서 브라우저 HTTP 캐시를 다뤘다.
2부에서는 API 응답 데이터를 캐싱하는 TanStack Query를 파헤쳐보자.
"왜 로딩이 또 뜨지?", "왜 데이터가 안 바뀌지?"의 답이 여기 있다.
1편에서 배운 HTTP 캐시는 파일 단위다. JS, CSS, 이미지 같은 정적 파일을 저장한다.
HTTP 캐시가 저장하는 것:
├─ bundle.js (500KB)
├─ styles.css (50KB)
└─ logo.png (20KB)
하지만 API 응답 데이터는 다르다.
// 이건 HTTP 캐시로 관리하기 어렵다
const response = await fetch('/api/products')
const products = await response.json()
// 왜?
// 1. 같은 URL이어도 사용자마다 다른 데이터
// 2. 실시간으로 바뀌는 데이터
// 3. 인증 토큰에 따라 다른 응답
| HTTP 캐시 (Part 1) | TanStack Query (Part 2) | |
|---|---|---|
| 대상 | 정적 파일 | API 응답 데이터 |
| 키 | URL | Query Key (커스텀) |
| 무효화 | Cache-Control, ETag | staleTime, refetch |
| 저장 위치 | 디스크 | 메모리 (RAM) |
| 지속성 | 브라우저 종료 후에도 유지 | 탭 닫으면 사라짐 |
// 😩 직접 구현할 때 마주치는 문제들
function ProductList() {
const [products, setProducts] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetchProducts()
.then(setProducts)
.catch(setError)
.finally(() => setLoading(false))
}, [])
// 문제 1: 다른 컴포넌트에서도 같은 데이터 필요하면?
// → 또 fetch하거나, 전역 상태로 올려야 함
// 문제 2: 데이터 새로고침은?
// → refetch 로직 직접 구현
// 문제 3: 캐싱은?
// → 직접 구현... 복잡해짐
// 문제 4: 로딩/에러 상태 관리?
// → 매번 useState 3개씩
}
// 😎 TanStack Query
function ProductList() {
const { data, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
})
// ✅ 다른 컴포넌트에서 같은 queryKey로 호출하면 캐시 공유
// ✅ 자동 refetch (포커스, 네트워크 복구 시)
// ✅ 캐싱 + stale 관리 내장
// ✅ 로딩/에러 상태 자동 관리
}
TanStack Query의 캐싱을 이해하려면 두 가지 시간을 알아야 한다.
staleTime = 데이터가 "신선하다"고 간주되는 시간
┌─────────────────────────────────────────────────────┐
│ │
│ fetch 완료 staleTime 경과 │
│ │ │ │
│ ▼ ▼ │
│ ┌───────┐ ┌───────┐ │
│ │ fresh │ ──────▶ │ stale │ │
│ │ 신선함 │ │ 오래됨 │ │
│ └───────┘ └───────┘ │
│ │
│ refetch 안 함 refetch 트리거 가능 │
│ │
└─────────────────────────────────────────────────────┘
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 1000 * 60 * 5, // 5분 동안 신선
})
// staleTime 동안:
// - 같은 queryKey로 useQuery 호출해도 fetch 안 함
// - 캐시된 데이터 즉시 반환
// staleTime 지나면:
// - 데이터는 여전히 캐시에 있음 (바로 보여줌)
// - 백그라운드에서 refetch 시도
staleTime 기본값: 0
// 기본값이 0이면?
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
// staleTime: 0 (기본값)
})
// 결과: 매번 stale 상태
// → 컴포넌트 마운트할 때마다 refetch
// → 창 포커스할 때마다 refetch
// → 데이터는 바로 보여주지만, 계속 요청이 나감
gcTime (Garbage Collection Time) = 캐시 메모리 유지 시간
┌─────────────────────────────────────────────────────┐
│ │
│ 마지막 사용 gcTime 경과 │
│ (구독 해제) │ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────┐ ┌───────┐ │
│ │ 캐시 │ ──────▶ │ 삭제 │ │
│ │ 유지 │ │ 됨 │ │
│ └───────┘ └───────┘ │
│ │
│ inactive 상태 메모리에서 제거 │
│ │
└─────────────────────────────────────────────────────┘
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
gcTime: 1000 * 60 * 10, // 10분 후 캐시에서 삭제
})
// 컴포넌트가 언마운트되면:
// 1. 쿼리가 "inactive" 상태가 됨
// 2. gcTime 동안 캐시에 유지
// 3. gcTime 지나면 메모리에서 삭제
// 4. 다시 마운트하면 처음부터 fetch
gcTime 기본값: 5분
타임라인 예시 (staleTime: 1분, gcTime: 5분)
─────────────────────────────────────────────────────
0분 1분 2분 3분 5분 6분
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
fetch stale 컴포넌트 gcTime 캐시
완료 됨 언마운트 경과 삭제
│ │ │ │
│ └─ inactive 시작 ───┘ │
│ │
└─ 이 사이에 재접근하면 │
캐시 데이터 즉시 반환 │
+ 백그라운드 refetch │
│
다시 접근하면 │
처음부터 fetch
| staleTime | gcTime | |
|---|---|---|
| 의미 | 신선함 유지 시간 | 캐시 유지 시간 |
| 기본값 | 0 | 5분 |
| 영향 | refetch 여부 | 메모리 점유 |
| 0이면 | 항상 stale (계속 refetch) | 즉시 삭제 (캐시 없음) |
| Infinity면 | 영원히 fresh (refetch 안 함) | 영원히 유지 (메모리 주의) |
1. useQuery 호출
│
▼
2. 캐시에 데이터 있나? ─── 없음 ───▶ 3. fetch 실행
│
▼
4. 캐시에 저장
│
▼
5. 컴포넌트에 반환
1. useQuery 호출
│
▼
2. 캐시에 데이터 있나? ─── 있음
│
▼
3. stale인가? ─── 아니오 (fresh)
│
▼
4. 캐시 데이터 즉시 반환 (fetch 없음!)
1. useQuery 호출
│
▼
2. 캐시에 데이터 있나? ─── 있음
│
▼
3. stale인가? ─── 예
│
├──────────────────────────┐
▼ ▼
4. 캐시 데이터 5. 백그라운드에서
즉시 반환 refetch
(일단 보여줌) │
▼
6. 새 데이터로
캐시 업데이트
│
▼
7. 컴포넌트 리렌더링
이게 바로 Stale-While-Revalidate 패턴이다.
Query Key는 캐시의 고유 식별자다. 같은 키 = 같은 캐시 데이터.
// 이 두 쿼리는 같은 캐시를 공유한다
// ComponentA.tsx
useQuery({ queryKey: ['products'], queryFn: fetchProducts })
// ComponentB.tsx
useQuery({ queryKey: ['products'], queryFn: fetchProducts })
// 1. 단순 키
useQuery({ queryKey: ['products'] })
// 2. 파라미터 포함 (ID)
useQuery({ queryKey: ['products', productId] })
// 3. 필터/옵션 포함
useQuery({
queryKey: ['products', { category: 'electronics', sort: 'price' }]
})
// 4. 계층 구조
useQuery({ queryKey: ['users', userId, 'posts'] })
// 이 세 쿼리는 모두 다른 캐시!
useQuery({ queryKey: ['products'] })
useQuery({ queryKey: ['products', 1] })
useQuery({ queryKey: ['products', 2] })
// 캐시 상태:
// ['products'] → { data: [...전체 상품...] }
// ['products', 1] → { data: { id: 1, name: '맥북' } }
// ['products', 2] → { data: { id: 2, name: '아이폰' } }
// 추천: Query Key를 한 곳에서 관리
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filters: Filters) => [...productKeys.lists(), filters] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: number) => [...productKeys.details(), id] as const,
}
// 사용
useQuery({ queryKey: productKeys.all })
useQuery({ queryKey: productKeys.detail(1) })
useQuery({ queryKey: productKeys.list({ category: 'phone' }) })
// 무효화도 쉬움
queryClient.invalidateQueries({ queryKey: productKeys.all })
// → products로 시작하는 모든 쿼리 무효화
TanStack Query는 여러 상황에서 자동으로 refetch한다.
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
// 창에 포커스 돌아올 때 refetch (기본: true)
refetchOnWindowFocus: true,
// 네트워크 재연결 시 refetch (기본: true)
refetchOnReconnect: true,
// 컴포넌트 마운트 시 refetch (기본: true)
refetchOnMount: true,
})
// staleTime: 0 (기본값)
// → 위 트리거들이 발생하면 무조건 refetch
// staleTime: 5분
// → 5분 내에는 트리거가 발생해도 refetch 안 함
// → 5분 지나면 트리거 발생 시 refetch
useQuery({
queryKey: ['stockPrice'],
queryFn: fetchStockPrice,
refetchInterval: 1000 * 5, // 5초마다 refetch
refetchIntervalInBackground: true, // 탭이 백그라운드여도 refetch
})
데이터를 수정/삭제/추가한 후에 관련 캐시를 무효화해야 한다.
// 상품 추가 후 목록 캐시 무효화
const mutation = useMutation({
mutationFn: createProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
},
})
invalidateQueries 호출
│
▼
해당 쿼리를 "stale"로 표시
│
├─ 쿼리가 현재 사용 중 (active) ───▶ 즉시 refetch
│
└─ 쿼리가 미사용 (inactive) ───▶ 다음 사용 시 refetch
// 정확히 일치하는 키만
queryClient.invalidateQueries({
queryKey: ['products', 1],
exact: true
})
// products로 시작하는 모든 키
queryClient.invalidateQueries({
queryKey: ['products']
})
// → ['products'], ['products', 1], ['products', { filter: 'x' }] 모두 무효화
refetch 없이 캐시를 직접 수정할 수도 있다.
const mutation = useMutation({
mutationFn: updateProduct,
onSuccess: (newProduct) => {
// 방법 1: 무효화 (refetch 발생)
queryClient.invalidateQueries({ queryKey: ['products'] })
// 방법 2: 직접 업데이트 (refetch 없음, 더 빠름)
queryClient.setQueryData(['products', newProduct.id], newProduct)
// 방법 3: 목록에서 해당 항목만 업데이트
queryClient.setQueryData(['products'], (old) =>
old.map(p => p.id === newProduct.id ? newProduct : p)
)
},
})
"일단 성공했다고 가정하고 UI 먼저 업데이트"
일반적인 흐름:
클릭 → 로딩... → 서버 응답 → UI 업데이트 (느림)
낙관적 업데이트:
클릭 → UI 즉시 업데이트 → 서버 응답 확인
│
├─ 성공: 그대로 유지
└─ 실패: 롤백
const mutation = useMutation({
mutationFn: updateTodo,
// 1. 뮤테이션 시작 전
onMutate: async (newTodo) => {
// 진행 중인 refetch 취소 (충돌 방지)
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 이전 값 저장 (롤백용)
const previousTodos = queryClient.getQueryData(['todos'])
// 캐시 즉시 업데이트 (낙관적)
queryClient.setQueryData(['todos'], (old) =>
old.map(todo => todo.id === newTodo.id ? newTodo : todo)
)
// context로 이전 값 전달
return { previousTodos }
},
// 2. 에러 시 롤백
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
// 3. 성공/실패 상관없이 refetch로 서버 상태 동기화
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// 거의 안 바뀌는 데이터
useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
staleTime: 1000 * 60 * 60, // 1시간
})
// 가끔 바뀌는 데이터
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 1000 * 60 * 5, // 5분
})
// 자주 바뀌는 데이터
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
staleTime: 1000 * 30, // 30초
})
// 실시간 데이터
useQuery({
queryKey: ['stockPrice'],
queryFn: fetchStockPrice,
staleTime: 0, // 항상 stale
refetchInterval: 1000, // 1초마다 polling
})
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5분
gcTime: 1000 * 60 * 10, // 10분
retry: 1, // 실패 시 1번 재시도
refetchOnWindowFocus: false, // 포커스 시 refetch 끄기
},
},
})
// React 18+ Suspense 모드
const { data } = useSuspenseQuery({
queryKey: ['products'],
queryFn: fetchProducts,
})
// 부모 컴포넌트
function App() {
return (
<Suspense fallback={<Loading />}>
<ProductList />
</Suspense>
)
}
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
throwOnError: true, // 에러 시 ErrorBoundary로 전파
})
// 부모 컴포넌트
function App() {
return (
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<ProductList />
</Suspense>
</ErrorBoundary>
)
}
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
DevTools에서 확인할 수 있는 것:
// ❌ staleTime이 0이고 gcTime도 짧으면
// 컴포넌트 언마운트 → 마운트할 때마다 처음부터 fetch
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
// staleTime: 0 (기본값)
// gcTime: 1000 * 60 * 5 (기본값 5분)
})
// ✅ staleTime을 적절히 설정
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 1000 * 60 * 5, // 5분 동안 fresh
})
// ❌ 무효화 안 함
const mutation = useMutation({
mutationFn: updateProduct,
onSuccess: () => {
// 아무것도 안 함...
},
})
// ✅ 관련 쿼리 무효화
const mutation = useMutation({
mutationFn: updateProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
},
})
// ❌ queryKey가 다르면 다른 캐시
useQuery({ queryKey: ['product', '1'] }) // 문자열 '1'
useQuery({ queryKey: ['product', 1] }) // 숫자 1
// → 서로 다른 캐시!
// ✅ 타입 일관성 유지
const productId = Number(id)
useQuery({ queryKey: ['product', productId] })
// ❌ 기본값 그대로 사용
// → 창 포커스, 마운트, 네트워크 복구마다 refetch
// ✅ 필요한 것만 켜기
useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 1000 * 60 * 5,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
})
요청 흐름에서의 위치:
┌─────────────────────────────────────────────────────────────┐
│ 브라우저 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ TanStack Query Cache (메모리) │ │
│ │ → API 응답 데이터 캐싱 │ │
│ │ → staleTime, gcTime으로 관리 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ HTTP Cache (디스크) │ │
│ │ → 정적 파일 캐싱 (JS, CSS, 이미지) │ │
│ │ → Cache-Control, ETag로 관리 │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Next.js 서버 │
│ → Router Cache, Full Route Cache, Data Cache 등 │
│ → Part 3에서 다룸 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ CDN │
│ → 전세계 엣지 서버에 캐싱 │
│ → Part 4에서 다룸 │
└─────────────────────────────────────────────────────────────┘
둘 다 API 응답을 캐싱하지만, 실행 위치가 다르다:
| TanStack Query | Next.js Data Cache | |
|---|---|---|
| 위치 | 브라우저 (클라이언트) | Next.js 서버 |
| 범위 | 현재 사용자의 현재 탭 | 모든 사용자가 공유 |
| 지속성 | 탭 닫으면 사라짐 | 서버에 영구 저장 |
| 용도 | Client Component | Server Component |
| 무효화 | invalidateQueries | revalidateTag |
| 상황 | 추천 | 이유 |
|---|---|---|
| Server Component에서 fetch | Next.js Data Cache | 서버에서 캐싱, SEO 유리 |
| Client Component에서 fetch | TanStack Query | 클라이언트 상태 관리 |
| 실시간 데이터 (polling) | TanStack Query | refetchInterval 지원 |
| 정적 데이터 (SSG/ISR) | Next.js Full Route Cache | 빌드 시 생성 |
| 사용자별 다른 데이터 | TanStack Query + private | 개인화 데이터 |
useQuery({
queryKey: ['key'], // 캐시 식별자
queryFn: fetchFn, // 데이터 fetch 함수
staleTime: 0, // fresh 유지 시간 (기본: 0)
gcTime: 1000 * 60 * 5, // 캐시 유지 시간 (기본: 5분)
refetchOnWindowFocus: true, // 포커스 시 refetch
refetchOnMount: true, // 마운트 시 refetch
refetchOnReconnect: true, // 재연결 시 refetch
refetchInterval: false, // 폴링 간격
retry: 3, // 재시도 횟수
enabled: true, // 쿼리 활성화 여부
})
// 무효화
queryClient.invalidateQueries({ queryKey: ['products'] })
// 직접 데이터 설정
queryClient.setQueryData(['products', id], newData)
// 데이터 가져오기
queryClient.getQueryData(['products'])
// 캐시에서 제거
queryClient.removeQueries({ queryKey: ['products'] })
// refetch 강제 실행
queryClient.refetchQueries({ queryKey: ['products'] })
const {
data, // 캐시된 데이터
error, // 에러 객체
isLoading, // 첫 로딩 중 (캐시 없음)
isFetching, // fetch 진행 중 (캐시 있어도)
isStale, // stale 상태인지
isSuccess, // 성공 상태
isError, // 에러 상태
refetch, // 수동 refetch 함수
} = useQuery(...)
Part 3: Next.js 캐싱에서는: