TanStack Query v5를 사용하여 서버 상태를 효율적으로 관리하고 캐싱, 동기화, 백그라운드 업데이트를 구현합니다.
상태를 서버 상태와 클라이언트 상태로 구분하면 더 효율적인 상태 관리가 가능합니다. 이 구분이 TanStack Query를 사용하는 핵심 이유입니다.
서버 상태의 특성 (복잡함)
클라이언트 상태의 특성 (단순함)
| 데이터 | 유형 | 관리 도구 |
|---|---|---|
| 상품 목록 | 서버 상태 | TanStack Query |
| 장바구니 | 서버 상태 | TanStack Query |
| 필터 UI 열림 | 클라이언트 상태 | useState |
| 다크모드 | 클라이언트 상태 | Zustand |
흔한 실수
서버 데이터를 useState나 Redux에 저장하면 캐싱, 동기화, 중복 요청 처리를 직접 구현해야 합니다. TanStack Query가 이를 자동으로 해결합니다.
핵심 원칙
서버에서 온 데이터는 TanStack Query로, UI 상태는 Zustand나 useState로 관리하세요.
TanStack Query는 서버 상태 관리를 위한 강력한 라이브러리입니다. 캐싱, 동기화, 백그라운드 업데이트를 자동으로 처리합니다.
npm install @tanstack/react-query
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
// useState로 QueryClient 생성 (SSR 안전)
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분간 fresh 상태 유지
gcTime: 5 * 60 * 1000, // 5분간 캐시 유지 (v5에서 cacheTime → gcTime)
},
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}'use client';
import { useQuery } from '@tanstack/react-query';
async function fetchProducts() {
const res = await fetch('/api/products');
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
}
export function ProductList() {
const {
data, // 성공 시 데이터
isLoading, // 첫 로딩 중
isFetching, // 백그라운드 페칭 중
error, // 에러 객체
isError, // 에러 여부
refetch // 수동 재요청 함수
} = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
if (isLoading) return <div>로딩 중...</div>;
if (isError) return <div>에러: {error.message}</div>;
return (
<ul>
{data.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}// Query Key는 배열로 구성
// 계층적으로 설계하면 무효화가 쉬움
// 전체 상품
queryKey: ['products']
// 카테고리별 상품
queryKey: ['products', { category: 'electronics' }]
// 특정 상품
queryKey: ['products', productId]
// 상품의 리뷰
queryKey: ['products', productId, 'reviews']
// 무효화 예시
queryClient.invalidateQueries({ queryKey: ['products'] });
// → 위의 모든 쿼리가 무효화됨| 옵션 | 설명 | 기본값 |
|---|---|---|
| staleTime | fresh 상태 유지 시간 | 0 |
| gcTime | 캐시 유지 시간 | 5분 |
| retry | 실패 시 재시도 횟수 | 3 |
| enabled | 쿼리 활성화 여부 | true |
Query Key 팁
Query Key를 계층적으로 설계하면 invalidateQueries로 관련 쿼리를 한 번에 무효화할 수 있습니다.
useMutation으로 데이터 생성, 수정, 삭제를 처리합니다.
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
async function addToCart(productId: string) {
const res = await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId }),
});
return res.json();
}
export function AddToCartButton({ productId }: { productId: string }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => addToCart(productId),
onSuccess: () => {
// 장바구니 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['cart'] });
},
});
return (
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
{mutation.isPending ? '추가 중...' : '장바구니 담기'}
</button>
);
}const mutation = useMutation({
mutationFn: updateProduct,
// 뮤테이션 시작 전
onMutate: async (newData) => {
// 진행 중인 쿼리 취소
await queryClient.cancelQueries({ queryKey: ['products', id] });
// 이전 데이터 저장
const previousData = queryClient.getQueryData(['products', id]);
// 낙관적으로 캐시 업데이트
queryClient.setQueryData(['products', id], newData);
return { previousData };
},
// 에러 시 롤백
onError: (err, newData, context) => {
queryClient.setQueryData(['products', id], context.previousData);
},
// 성공/실패 후 항상 실행
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['products', id] });
},
});| 상태 | 설명 | 활용 |
|---|---|---|
| isPending | 뮤테이션 진행 중 | 버튼 비활성화 |
| isSuccess | 성공 | 성공 메시지 |
| isError | 실패 | 에러 표시 |
| error | 에러 객체 | 에러 메시지 |
낙관적 업데이트 활용
장바구니 수량 변경처럼 즉각적인 피드백이 필요한 경우 낙관적 업데이트로 UX를 개선하세요.
데이터 변경 후 관련 캐시를 적절히 무효화하는 것이 중요합니다. Query Key를 계층적으로 설계하면 무효화가 쉬워집니다.
const queryClient = useQueryClient();
// 특정 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['products', '123'] });
// 프리픽스로 무효화 (계층적 키의 장점)
queryClient.invalidateQueries({ queryKey: ['products'] });
// → ['products'], ['products', '123'], ['products', { category }] 모두 무효화
// 정확히 일치하는 키만
queryClient.invalidateQueries({
queryKey: ['products'],
exact: true
});
// 조건부 무효화
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'products'
});// lib/queries/products.ts
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filters: ProductFilters) => [...productKeys.lists(), filters] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id: string) => [...productKeys.details(), id] as const,
};
// 사용
useQuery({
queryKey: productKeys.detail(productId),
queryFn: () => fetchProduct(productId),
});
// 무효화
queryClient.invalidateQueries({ queryKey: productKeys.lists() });// 뮤테이션 성공 후 캐시 직접 업데이트
const mutation = useMutation({
mutationFn: updateProduct,
onSuccess: (updatedProduct) => {
// 상세 페이지 캐시 업데이트
queryClient.setQueryData(
productKeys.detail(updatedProduct.id),
updatedProduct
);
// 목록에서도 업데이트
queryClient.setQueryData(productKeys.lists(), (old) =>
old?.map(p => p.id === updatedProduct.id ? updatedProduct : p)
);
},
});무효화 vs 직접 업데이트
Next.js의 Server Components와 TanStack Query를 함께 사용하는 방법입니다. 서버에서 데이터를 미리 가져와 클라이언트에 전달하면 초기 로딩이 빨라집니다.
// app/products/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { ProductList } from './ProductList';
async function getProducts() {
const res = await fetch('https://api.myshop.com/products');
return res.json();
}
export default async function ProductsPage() {
const queryClient = new QueryClient();
// 서버에서 데이터 미리 가져오기
await queryClient.prefetchQuery({
queryKey: ['products'],
queryFn: getProducts,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductList />
</HydrationBoundary>
);
}// app/products/ProductList.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
export function ProductList() {
// 서버에서 prefetch된 데이터가 즉시 사용됨
const { data } = useQuery({
queryKey: ['products'],
queryFn: async () => {
const res = await fetch('/api/products');
return res.json();
},
});
// isLoading이 false로 시작 (이미 데이터 있음)
return (
<ul>
{data?.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}서버: prefetchQuery → dehydrate → HTML에 포함 클라이언트: HydrationBoundary → 캐시 복원 → useQuery 즉시 사용
1. 서버
prefetchQuery로 데이터 가져옴
2. dehydrate
캐시를 직렬화하여 HTML에 포함
3. HydrationBoundary
클라이언트에서 캐시 복원
4. useQuery
복원된 데이터 즉시 사용
SSR + TanStack Query 장점
useInfiniteQuery로 무한 스크롤 상품 목록을 구현합니다. 페이지네이션보다 모바일 친화적인 UX를 제공합니다.
'use client';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';
async function fetchProducts({ pageParam = 1 }) {
const res = await fetch(`/api/products?page=${pageParam}&limit=12`);
return res.json();
}
export function ProductGrid() {
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['products', 'infinite'],
queryFn: fetchProducts,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) => {
return lastPage.hasMore ? pages.length + 1 : undefined;
},
});
// 스크롤이 하단에 도달하면 다음 페이지 로드
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, fetchNextPage]);
return (
<div>
<div className="grid grid-cols-4 gap-4">
{data?.pages.flatMap(page =>
page.products.map(product => (
<ProductCard key={product.id} product={product} />
))
)}
</div>
{/* 감지 요소 */}
<div ref={ref} className="h-10">
{isFetchingNextPage && <p>로딩 중...</p>}
</div>
</div>
);
}// hooks/useCart.ts
export function useCart() {
return useQuery({
queryKey: ['cart'],
queryFn: fetchCart,
staleTime: 0, // 항상 최신 데이터
});
}
export function useAddToCart() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addToCart,
onMutate: async (newItem) => {
await queryClient.cancelQueries({ queryKey: ['cart'] });
const previous = queryClient.getQueryData(['cart']);
// 낙관적 업데이트
queryClient.setQueryData(['cart'], (old) => ({
...old,
items: [...old.items, { ...newItem, id: 'temp' }],
}));
return { previous };
},
onError: (err, newItem, context) => {
queryClient.setQueryData(['cart'], context.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['cart'] });
},
});
}핵심 포인트
TanStack Query 활용 전략