- 소개
- 기본 사용법
- 동적 파라미터와 QueryKey 관리
- 상태 관리와 캐싱
- 페이지네이션과 무한 스크롤
- 최적화된 Mutation 패턴
- 성능 최적화 패턴
- 향상된 에러 처리
- HTTP 캐싱과의 관계
- 모범 사례 및 권장 패턴
TanStack Query(이전 명칭: React Query)는 서버 상태 관리를 위한 강력한 라이브러리입니다. 이 가이드에서는 TanStack Query v5의 주요 기능과 사용법을 Q&A 형식으로 설명합니다.
A: TanStack Query는 서버 상태를 가져오고, 캐싱하고, 동기화하고, 업데이트하는 작업을 간소화하는 라이브러리입니다. 복잡한 상태 관리 로직을 추상화하여 개발자가 데이터 페칭과 관련된 문제를 쉽게 해결할 수 있도록 도와줍니다.
A: TanStack Query v5의 주요 특징은 다음과 같습니다:
- 선언적 쿼리: 컴포넌트 내에서 데이터 요청을 선언적으로 정의
- 자동 캐싱: 쿼리 결과를 자동으로 캐싱하여 중복 요청 방지
- 백그라운드 업데이트: 사용자 경험을 방해하지 않고 데이터를 최신 상태로 유지
- 페이지네이션 및 무한 스크롤 지원: 대량의 데이터를 효율적으로 처리
- 데이터 동기화: 여러 컴포넌트 간에 데이터 상태 동기화
- 낙관적 업데이트: 서버 응답을 기다리지 않고 UI를 즉시 업데이트
- 타입스크립트 지원: 완전한 타입 안정성 제공
- 서버 사이드 렌더링 지원: SSR 환경에서도 원활하게 작동
A: TanStack Query v5는 다음과 같이 설치하고 설정할 수 있습니다:
# npm을 사용하는 경우
npm install @tanstack/react-query
# yarn을 사용하는 경우
yarn add @tanstack/react-query
# pnpm을 사용하는 경우
pnpm add @tanstack/react-query그리고 애플리케이션에 QueryClient와 QueryClientProvider를 설정합니다:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// QueryClient 인스턴스 생성
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5분
gcTime: 1000 * 60 * 60 * 24, // 24시간
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
{/* 애플리케이션 컴포넌트 */}
<YourAppComponent />
{/* 개발 환경에서만 DevTools 표시 */}
{process.env.NODE_ENV === 'development' && <ReactQueryDevtools />}
</QueryClientProvider>
);
}A: TanStack Query v5에서 기본적인 쿼리는 useQuery 훅을 사용하여 다음과 같이 구현합니다:
import { useQuery } from '@tanstack/react-query';
// 데이터를 가져오는 함수
const fetchProducts = async () => {
const response = await fetch('https://api.example.com/products');
if (!response.ok) {
throw new Error('네트워크 응답이 올바르지 않습니다');
}
return response.json();
};
function ProductList() {
const {
data,
isLoading,
isError,
error
} = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
if (isLoading) return <div>로딩 중...</div>;
if (isError) return <div>에러: {error.message}</div>;
return (
<div>
<h2>상품 목록</h2>
<ul>
{data.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
);
}A: useQuery 훅은 다음과 같은 주요 상태값을 반환합니다:
- data: 쿼리 함수가 성공적으로 해결된 경우의 데이터
- isLoading: 쿼리가 현재 로딩 중인지 여부
- isError: 쿼리에 오류가 발생했는지 여부
- error: 발생한 오류 객체
- status: 쿼리의 현재 상태 ('loading', 'error', 'success')
- isFetching: 쿼리가 현재 데이터를 가져오는 중인지 여부
- refetch: 쿼리를 수동으로 다시 가져오는 함수
- isStale: 쿼리 데이터가 오래되었는지(stale) 여부
- isSuccess: 쿼리가 성공적으로 완료되었는지 여부
A: TanStack Query v5에서는 다음과 같은 주요 변경사항이 있습니다:
-
객체 기반 API: 함수 인자 대신 객체 기반 API를 사용합니다.
// v4 useQuery(['products'], fetchProducts, { staleTime: 5000 }); // v5 useQuery({ queryKey: ['products'], queryFn: fetchProducts, staleTime: 5000 });
-
개선된 타입스크립트 지원: 더 정확한 타입 추론과 타입 안정성을 제공합니다.
-
성능 최적화: 내부 구현이 최적화되어 더 나은 성능을 제공합니다.
-
확장된 DevTools: 더 강력한 디버깅 도구를 제공합니다.
-
프레임워크 독립적: React 외에도 다양한 프레임워크를 지원합니다.
A: QueryKey를 배열 형태로 구성하여 파라미터를 포함시키세요. 모던한 방법은 QueryKey 팩토리 패턴을 사용하는 것입니다.
// QueryKey 팩토리 사용 (권장)
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filters) => [...productKeys.lists(), filters] as const,
details: () => [...productKeys.all, 'detail'] as const,
detail: (id) => [...productKeys.details(), id] as const,
}
// 사용법
function useProducts(filters) {
return useQuery({
queryKey: productKeys.list(filters),
queryFn: () => fetchProducts(filters)
});
}
function useProduct(id) {
return useQuery({
queryKey: productKeys.detail(id),
queryFn: () => fetchProduct(id)
});
}QueryKey 팩토리 패턴의 장점:
- 타입 안정성: TypeScript에서 완전한 타입 추론
- 일관성: 팀 전체에서 동일한 키 구조 사용
- 무효화 용이성: 계층적 구조로 부분 무효화 쉬움
A: 필터링 조건을 QueryKey에 포함시켜 각 조건별로 서로 다른 캐시 공간이 자동으로 관리되도록 합니다.
// 예시) 쇼핑몰 상품 목록을 가격 필터링 조건으로 가져올 때
function useProducts({ minPrice, maxPrice, category }) {
return useQuery({
queryKey: ['products', { minPrice, maxPrice, category }],
queryFn: () => fetchProducts({ minPrice, maxPrice, category })
});
}위 예시에서 ['products', { minPrice, maxPrice, category }]와 같이 배열로 QueryKey를 설정하면:
- minPrice, maxPrice, category가 달라질 때마다 서로 다른 캐시로 간주합니다.
- 내부적으로는 JSON.stringify를 통해 Key를 직렬화하므로, 같은 값 조합이면 이전에 캐시된 데이터를 바로 재사용합니다.
A: 내부적으로 배열을 JSON.stringify해서 고유 키를 만들기 때문에, 객체 리터럴을 QueryKey에 바로 넣으면 객체의 프로퍼티 순서나 참조가 달라질 때 캐시 미스가 발생할 수 있습니다. 따라서 보통은 단순 값(문자열·숫자)이나 "문자열 + JSON.stringify"를 조합해서 사용하는 것을 권장합니다.
// 권장 방법
const filters = JSON.stringify({ minPrice, maxPrice, category });
useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts({ minPrice, maxPrice, category })
});이렇게 하면 객체 순서가 바뀔 위험을 줄이고, 같은 필터 조합에서는 반드시 같은 Key를 생성하게 됩니다.
A: ID를 QueryKey에 포함시키면 중복 호출된 ID가 있을 경우 이미 캐싱된 데이터를 재활용합니다.
function useProductById(productId) {
return useQuery({
queryKey: ['product', String(productId)],
queryFn: () => fetchProductById(productId)
});
}이때 productId가 1, 2, 3... 식으로 바뀔 때마다 캐시 키(['product','1'], ['product','2'] 등)가 달라지므로, 중복 호출된 ID가 있을 경우(예: 나중에 ID=5 호출한 적이 있고, 또다시 ID=5를 요청하면) 이미 캐싱된 데이터를 재활용하게 됩니다.
A: 다음 두 가지 방법을 고려해볼 수 있습니다:
-
staleTime/gcTime을 짧게 설정: 불필요한 캐시를 빨리 정리합니다.
useQuery({ queryKey: ['products', filters], queryFn: fetchProducts, staleTime: 1000 * 60 * 5, // 5분 gcTime: 1000 * 60 * 10 // 10분 });
-
필요 없는 조합 직접 제거: 액세스 빈도가 매우 낮은 필터는 캐시 관리 로직에서 직접 제거합니다.
queryClient.removeQueries({ predicate: (query) => { // 특정 조건에 맞는 쿼리만 제거 const key = query.queryKey; return Array.isArray(key) && key[0] === 'products' && // 특정 조건 확인 key[1].someRareCondition === true; } });
A: 캐싱된 데이터를 변경하는 API 호출은 useMutation 훅을 사용합니다. 이를 통해 서버에 데이터를 변경하는 요청을 보내고, 성공 시 캐시를 업데이트할 수 있습니다.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (updatedProduct) =>
fetch(`/api/products/${updatedProduct.id}`, {
method: 'PUT',
body: JSON.stringify(updatedProduct),
headers: { 'Content-Type': 'application/json' },
}).then(res => res.json()),
onSuccess: (data, variables) => {
// 1) 변경된 단일 상품 데이터를 바로 cache에 반영
queryClient.setQueryData(
['product', String(data.id)],
data
);
// 2) 상품 목록에도 영향을 줄 수 있으므로,
// 목록 캐시를 무효화해 재요청하도록 설정
queryClient.invalidateQueries({ queryKey: ['products'] });
}
});
}A: 변경된 데이터를 캐시에 반영하는 방법은 크게 두 가지가 있습니다:
-
queryClient.setQueryData: 특정 QueryKey에 매핑된 캐시를 새 데이터로 직접 업데이트합니다.
queryClient.setQueryData(['product', String(updatedProduct.id)], updatedProduct);
-
queryClient.invalidateQueries: 해당 Key 패턴에 맞는 모든 쿼리를 "stale" 상태로 표시해, 다음번에 useQuery 훅이 마운트되거나 refetch를 호출하면 자동으로 재요청하게 합니다.
queryClient.invalidateQueries({ queryKey: ['products'] });
A: 예를 들어, "상품 A"를 수정(PUT)할 때 ['product','A'] 외에도 다양한 "상품 목록" 캐시도 무효화(invalidate)해야 할 수 있습니다. 이런 경우 다음과 같이 처리할 수 있습니다:
// 'products'를 포함하는 모든 캐시를 무효화
queryClient.invalidateQueries({ queryKey: ['products'], exact: false });
// 또는 더 간단하게
queryClient.invalidateQueries('products'); // prefix 매칭위 코드는 'products'를 포함하는 모든 캐시가 재요청 대상으로 표시됩니다.
A: 낙관적 업데이트는 서버 반영을 기다리지 않고, "UI 상에서 미리" 캐시를 업데이트해 주는 방법입니다. useMutation의 onMutate, onError, onSettled를 활용하여 구현합니다:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProduct,
// 🆕 개선: 더 정확한 타입과 함께 사용
onMutate: async (variables) => {
// 진행 중인 refetch 취소
await queryClient.cancelQueries({
queryKey: productKeys.detail(variables.id)
});
// 이전 상태 저장
const previousProduct = queryClient.getQueryData(
productKeys.detail(variables.id)
);
// 낙관적 업데이트
queryClient.setQueryData(
productKeys.detail(variables.id),
variables
);
return { previousProduct };
},
onError: (err, variables, context) => {
// 에러 발생 시 이전 상태로 롤백
if (context?.previousProduct) {
queryClient.setQueryData(
productKeys.detail(variables.id),
context.previousProduct
);
}
},
onSettled: (data, error, variables) => {
// 성공/실패 여부와 관계없이 쿼리 무효화
queryClient.invalidateQueries({
queryKey: productKeys.detail(variables.id)
});
}
});
}이 패턴의 장점은 사용자에게 즉각적인 피드백을 제공하면서도, 서버 응답에 따라 적절히 롤백하거나 최종 데이터로 업데이트할 수 있다는 점입니다.
A: TanStack Query는 페이지네이션과 무한 스크롤을 위한 두 가지 주요 접근 방식을 제공합니다.
A: useQuery를 활용한 전통적 페이지네이션은 다음과 같이 구현합니다:
function usePaginatedProducts(page, filters) {
return useQuery({
queryKey: ['products', filters, page],
queryFn: () => fetchProducts({ page, ...filters })
});
}이 방식의 특징:
page값을 QueryKey에 포함시켜 페이지별로 캐싱합니다.- 페이지가 변경될 때마다(예: page: 1 → 2) 새로운 캐시 슬롯이 생성됩니다.
- 이전 페이지를 다시 요청하면 캐시된 데이터를 그대로 사용합니다.
단점으로는 페이지 전환 시 "이전 페이지 데이터가 사라졌다가 새 페이지 로딩 스피너가 잠깐 보임" 등의 UX 문제가 있을 수 있습니다. 이를 해결하기 위해 keepPreviousData: true 옵션을 사용할 수 있습니다:
function usePaginatedProducts(page, filters) {
return useQuery({
queryKey: ['products', filters, page],
queryFn: () => fetchProducts({ page, ...filters }),
keepPreviousData: true // 새 데이터를 가져오는 동안 이전 데이터 유지
});
}이렇게 하면 "이전 페이지 데이터를 보여주면서" 새 페이지를 비동기적으로 받아오게 됩니다.
A: useInfiniteQuery를 활용한 무한 스크롤은 다음과 같이 구현합니다:
import { useInfiniteQuery } from '@tanstack/react-query';
function useInfiniteProducts(filters) {
return useInfiniteQuery({
queryKey: ['products', 'infinite', filters],
queryFn: ({ pageParam = 0 }) => fetchProducts({ page: pageParam, ...filters }),
getNextPageParam: (lastPage, allPages) => {
// lastPage: fetchProducts가 리턴한 { data, nextCursor } 형태라 가정
return lastPage.nextCursor ?? undefined;
}
});
}
// 사용 예시
function ProductList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status
} = useInfiniteProducts(filters);
return (
<div>
{status === 'pending' ? (
<p>로딩 중...</p>
) : status === 'error' ? (
<p>에러 발생</p>
) : (
<>
{data.pages.map((page, i) => (
<React.Fragment key={i}>
{page.data.map(product => (
<ProductItem key={product.id} product={product} />
))}
</React.Fragment>
))}
<div>
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? '더 불러오는 중...'
: hasNextPage
? '더 보기'
: '더 이상 상품이 없습니다'}
</button>
</div>
</>
)}
</div>
);
}이 방식의 특징:
pageParam은 내부에서 자동 관리되는 "다음 페이지 인덱스"입니다.- API가 cursor를 리턴하도록 설계됐다면,
lastPage.nextCursor를 반환해 자동으로 캐싱 및 페칭합니다. - 결과는
data.pages배열에 각 페이지별로 데이터를 누적 저장합니다. - 뒤로 갔다가 같은 필터로 돌아오면, 이미 가져온 페이지들은 모두 캐시에서 즉시 렌더링합니다.
- 백그라운드 리패치 시, 새로운 페이지가 있다면 이어 붙입니다.
A: 다음 사항들을 주의해야 합니다:
-
필터 변경 시 캐시 분리: 필터(쿼리 파라미터) 변경 시,
['products', filters]자체가 변경되므로 "새로운 키"로 캐시가 분리됩니다. 예를 들어, 카테고리만 변경되면 다음과 같은 캐시 슬롯들이 공존 가능합니다:['products', {category: 'electronics'}, page 1] ['products', {category: 'electronics'}, page 2] ['products', {category: 'books'}, page 1] -
staleTime 설정:
staleTime을 충분히 길게 설정하면, 뒤로 돌아갔을 때 백그라운드에서 바로 재요청하지 않으므로 깜빡임 없는 UX를 만들 수 있습니다. -
prefetching(미리 불러오기): 현재 보고 있는 페이지의 다음 페이지를 미리 fetch해서 스크롤 직전에 데이터가 이미 준비되도록 할 수 있습니다.
// 다음 페이지 미리 가져오기 useEffect(() => { if (data && !isFetching && hasNextPage) { queryClient.prefetchQuery({ queryKey: ['products', filters, page + 1], queryFn: () => fetchProducts({ page: page + 1, ...filters }) }); } }, [data, isFetching, page, filters, hasNextPage]);
A: TanStack Query v5에서는 다음과 같이 최적화된 Mutation 패턴을 구현할 수 있습니다:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useUpdateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateProduct,
// 🆕 개선: 더 정확한 타입과 함께 사용
onMutate: async (variables) => {
// 진행 중인 refetch 취소
await queryClient.cancelQueries({
queryKey: productKeys.detail(variables.id)
});
// 이전 상태 저장
const previousProduct = queryClient.getQueryData(
productKeys.detail(variables.id)
);
// 낙관적 업데이트
queryClient.setQueryData(
productKeys.detail(variables.id),
variables
);
return { previousProduct };
},
onError: (err, variables, context) => {
// 에러 발생 시 이전 상태로 롤백
if (context?.previousProduct) {
queryClient.setQueryData(
productKeys.detail(variables.id),
context.previousProduct
);
}
},
onSettled: (data, error, variables) => {
// 성공/실패 여부와 관계없이 쿼리 무효화
queryClient.invalidateQueries({
queryKey: productKeys.detail(variables.id)
});
}
});
}A: useMutation 훅의 주요 콜백 함수들은 다음과 같습니다:
-
onMutate: mutation이 실행되기 전에 호출됩니다. 낙관적 업데이트를 구현하는 데 사용됩니다.
- 진행 중인 refetch를 취소합니다.
- 이전 상태를 저장합니다.
- 낙관적 업데이트를 수행합니다.
- context 객체를 반환하여 다른 콜백에서 사용할 수 있게 합니다.
-
onError: mutation이 실패했을 때 호출됩니다.
- 에러 처리 로직을 구현합니다.
- onMutate에서 반환한 context를 사용하여 이전 상태로 롤백합니다.
-
onSuccess: mutation이 성공했을 때 호출됩니다.
- 서버에서 반환된 데이터로 캐시를 업데이트합니다.
- 관련된 쿼리를 무효화합니다.
-
onSettled: mutation이 성공하거나 실패한 후 항상 호출됩니다.
- 성공/실패 여부와 관계없이 수행해야 하는 작업을 구현합니다.
- 일반적으로 관련 쿼리를 무효화하는 데 사용됩니다.
A: 여러 관련 쿼리를 한 번에 무효화하는 방법은 다음과 같습니다:
-
문자열 또는 배열 첫 번째 요소로 매칭:
// 'users'를 prefix(혹은 key 배열의 첫 번째 요소)로 갖는 모든 쿼리를 무효화 queryClient.invalidateQueries('users');
이 방법은 다음과 같은 모든 쿼리를 무효화합니다:
- 문자열 'users'(정확히 같은 값)
- 배열이고 첫 번째 요소가 'users'인 모든 키 (예: ['users', {by: 'name'}], ['users', {by: 'type'}] 등)
-
predicate 함수를 사용한 정교한 매칭:
queryClient.invalidateQueries({ predicate: (query) => { // query.queryKey는 배열 또는 문자열 형태 const key = query.queryKey; // 예시: 첫 요소가 'users'이면서, key[1].by === 'type'인 쿼리만 무효화 if (Array.isArray(key) && key[0] === 'users' && key[1].by === 'type') { return true; } return false; } });
이 방법은 더 복잡한 조건(예: 특정 파라미터 조합만 무효화, 특정 조건인 쿼리만 무효화)에 유용합니다.
A: TanStack Query v5에서는 다음과 같은 성능 최적화 패턴을 제공합니다:
A: 선택적 구독은 select 옵션을 사용하여 필요한 데이터만 구독하는 방법입니다:
// 1. 선택적 구독 (필요한 데이터만 구독)
function useProductName(productId) {
return useQuery({
queryKey: productKeys.detail(productId),
queryFn: () => fetchProduct(productId),
select: (data) => data.name, // name만 선택
// name이 변경될 때만 리렌더링됨
});
}이 방식의 장점:
- 전체 데이터 중 필요한 부분만 선택하여 컴포넌트에 제공합니다.
- 선택한 데이터가 변경될 때만 리렌더링이 발생하므로 성능이 향상됩니다.
- 여러 컴포넌트에서 동일한 쿼리를 사용하지만 다른 부분의 데이터가 필요한 경우 유용합니다.
A: 조건부 쿼리는 enabled 옵션을 사용하여 특정 조건이 충족될 때만 쿼리를 실행하는 방법입니다:
// 2. 조건부 쿼리
function useProduct(productId) {
return useQuery({
queryKey: productKeys.detail(productId),
queryFn: () => fetchProduct(productId),
enabled: !!productId, // productId가 있을 때만 실행
});
}이 방식의 장점:
- 필요한 데이터가 있을 때만 쿼리를 실행합니다.
- 불필요한 네트워크 요청을 방지합니다.
- 의존성이 있는 쿼리를 순차적으로 실행할 수 있습니다.
A: 병렬 쿼리는 useQueries 훅을 사용하여 여러 쿼리를 동시에 실행하는 방법입니다:
// 3. 병렬 쿼리 최적화
function useProductWithReviews(productId) {
const results = useQueries({
queries: [
{
queryKey: productKeys.detail(productId),
queryFn: () => fetchProduct(productId)
},
{
queryKey: ['reviews', productId],
queryFn: () => fetchReviews(productId)
}
]
});
const [productQuery, reviewsQuery] = results;
return {
product: productQuery.data,
reviews: reviewsQuery.data,
isLoading: productQuery.isLoading || reviewsQuery.isLoading,
isError: productQuery.isError || reviewsQuery.isError
};
}이 방식의 장점:
- 여러 관련 데이터를 병렬로 가져와 로딩 시간을 단축합니다.
- 각 쿼리의 상태를 개별적으로 관리할 수 있습니다.
- 데이터 간의 의존성이 없을 때 효율적입니다.
A: 데이터 변환은 select 옵션을 사용하여 서버에서 받은 데이터를 컴포넌트에 필요한 형태로 변환하는 방법입니다:
// 4. 데이터 변환
function useFormattedProducts() {
return useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
select: (data) => data.map(product => ({
...product,
formattedPrice: `${product.currency} ${product.price.toFixed(2)}`,
discountPercentage: ((product.originalPrice - product.price) / product.originalPrice * 100).toFixed(0)
}))
});
}이 방식의 장점:
- 서버 데이터를 UI에 적합한 형태로 변환합니다.
- 변환 로직을 쿼리 훅 내부에 캡슐화하여 컴포넌트 코드를 간결하게 유지합니다.
- 동일한 데이터를 사용하는 여러 컴포넌트에서 일관된 형식을 보장합니다.
A: 쿼리 결합은 여러 쿼리의 결과를 결합하여 새로운 데이터를 생성하는 방법입니다:
// 5. 쿼리 결합
function useProductWithDetails(productId) {
const productQuery = useQuery({
queryKey: productKeys.detail(productId),
queryFn: () => fetchProduct(productId)
});
const manufacturerQuery = useQuery({
queryKey: ['manufacturer', productQuery.data?.manufacturerId],
queryFn: () => fetchManufacturer(productQuery.data?.manufacturerId),
enabled: !!productQuery.data?.manufacturerId
});
const categoryQuery = useQuery({
queryKey: ['category', productQuery.data?.categoryId],
queryFn: () => fetchCategory(productQuery.data?.categoryId),
enabled: !!productQuery.data?.categoryId
});
return {
product: productQuery.data,
manufacturer: manufacturerQuery.data,
category: categoryQuery.data,
isLoading: productQuery.isLoading || manufacturerQuery.isLoading || categoryQuery.isLoading,
isError: productQuery.isError || manufacturerQuery.isError || categoryQuery.isError
};
}이 방식의 장점:
- 관련된 여러 데이터를 하나의 훅으로 제공합니다.
- 의존성이 있는 쿼리를 순차적으로 실행합니다.
- 각 데이터의 캐싱을 개별적으로 관리하면서도 통합된 인터페이스를 제공합니다.
A: TanStack Query v5에서는 다음과 같이 향상된 에러 처리 방법을 제공합니다:
import { useQuery } from '@tanstack/react-query';
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
throwOnError: true, // React Error Boundary와 함께 사용
meta: {
errorMessage: '상품을 불러오는데 실패했습니다.' // 에러 메타데이터
}
});
}A: React Error Boundary와 함께 사용하면 에러 처리를 더 선언적으로 할 수 있습니다:
import { ErrorBoundary } from 'react-error-boundary';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
function ProductListPage() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>상품을 불러오는데 문제가 발생했습니다: {error.message}</p>
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
)}
>
<ProductList />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
function ProductList() {
const { data } = useProducts(); // 에러 시 자동으로 Error Boundary로 throw
return (
<div>
{data?.map(product => <ProductItem key={product.id} product={product} />)}
</div>
);
}이 방식의 장점:
- 에러 처리 로직을 컴포넌트 트리의 상위 레벨로 분리합니다.
- 선언적인 에러 처리를 가능하게 합니다.
- 에러 발생 시 사용자에게 적절한 UI를 제공하고 복구 메커니즘을 제공합니다.
A: 에러 재시도 기능은 retry 옵션을 사용하여 구현합니다:
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
retry: 3, // 최대 3번 재시도
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), // 지수 백오프
});
}이 방식의 장점:
- 일시적인 네트워크 오류에 대한 복원력을 제공합니다.
- 지수 백오프 전략으로 서버 부하를 줄입니다.
- 사용자 경험을 방해하지 않고 자동으로 복구를 시도합니다.
A: 에러 콜백은 onError 옵션을 사용하여 구현합니다:
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
onError: (error) => {
// 에러 로깅
console.error('상품을 불러오는데 실패했습니다:', error);
// 에러 알림
toast.error('상품을 불러오는데 실패했습니다. 나중에 다시 시도해주세요.');
// 분석 이벤트 전송
analytics.track('query_error', {
queryKey: 'products',
errorMessage: error.message
});
}
});
}이 방식의 장점:
- 에러 발생 시 추가 작업(로깅, 알림, 분석 등)을 수행할 수 있습니다.
- 사용자에게 적절한 피드백을 제공할 수 있습니다.
- 에러 추적 및 디버깅에 유용합니다.
A: 브라우저의 HTTP 캐싱(304 Not Modified, ETag, Cache-Control 등)과 TanStack Query의 내부 캐시는 서로 보완적으로 작동하지만, 완전히 독립된 레이어이기도 합니다.
A: 서버가 응답 헤더 Cache-Control, ETag, Last-Modified 등을 설정하면, 브라우저(또는 프록시)는 동일 URL 요청에 대해 "서버에 조건부 요청(Conditional Request)"을 보내 304 Not Modified를 받을 수 있습니다. 이 경우, 브라우저는 네트워크 응답을 재사용하거나 최소한 변경 여부만 확인하므로 속도·트래픽을 절약합니다.
A: TanStack Query는 기본적으로 JS 메모리에 "QueryKey → 데이터" 매핑을 저장합니다. 따라서 같은 QueryKey로 요청할 때:
staleTime안에서는 fetch 함수를 호출하지 않고, "캐시된 데이터"를 즉시 반환합니다.staleTime이 지났거나,invalidateQueries로 stale 처리되었으면 내부적으로 fetch 함수를 실행합니다. 이때 fetch 함수가 HTTP 요청을 보내면, 실제 네트워크 레벨에서는 브라우저가 HTTP 캐시 메커니즘을 확인합니다(304 응답 등).
즉, TanStack Query의 cache → 네트워크 요청 여부 결정(enabled, staleTime 등) → 브라우저 HTTP 캐싱 → 서버 응답 순서로 동작합니다.
A: 만약 서버가 오래된 데이터를 HTTP 캐시로 제공하고 있다면, TanStack Query가 "새로 요청을 보냈다" 하더라도 그 요청이 304 또는 캐시된 응답을 받을 수 있습니다.
결과적으로는 TanStack Query가 "JS 메모리 캐시"에서 무효화 시점(stale)이라 판단해 fetch를 했더라도, 브라우저가 "서버(또는 중간 프록시)에 실제로 새로운 데이터가 있는지 확인"한 뒤 304로 되돌려준다면, 실질적으로는 업데이트되지 않은(오래된) 데이터를 받아오게 됩니다.
A: 다음과 같은 해결책을 사용할 수 있습니다:
-
캐시 무효화(Invalidate): 서버 쪽에서 데이터를 강제로 갱신해야 한다면, fetch 요청 시 HTTP 헤더에 Cache-Control: no-cache 또는 Pragma: no-cache 옵션을 추가해 브라우저가 로컬 캐시를 무시하도록 만들 수 있습니다.
const fetchProducts = () => fetch('/api/products', { headers: { 'Cache-Control': 'no-cache' } }).then(res => res.json());
또는 fetch 옵션을 사용할 수도 있습니다:
const fetchProducts = () => fetch('/api/products', { cache: 'reload' }) .then(res => res.json());
-
ETag/Last-Modified 설계: 서버 API 레벨에서 ETag나 Last-Modified를 적절히 사용해 "정말 데이터가 변경된 경우에만(response 200)" 새로운 payload를 던져주는 구조가 좋습니다. 이러면 브라우저 네트워크 캐시는 물론 TanStack Query의 재요청도 불필요한 데이터 전달을 줄일 수 있습니다.
-
TanStack Query 옵션:
refetchOnWindowFocus: false: 기본값은 true여서, 윈도우가 다시 포커스될 때마다 re-fetch를 시도합니다. 이때 HTTP 캐시가 오래된 데이터를 주면 그대로 받아오므로, 실제로 화면에는 갱신되지 않는 경우가 생깁니다. false로 끄면 불필요한 네트워크 요청이 줄어듭니다.staleTime과gcTime설정:staleTime을 길게 잡으면, "열흘 전" 요청한 데이터라도 그 기간 동안은 fetch를 아예 시도하지 않으므로, HTTP 캐시 레벨과 섞이지 않고 일정 기간 메모리 캐시만 사용하도록 강제 가능합니다.- 반대로 "항상 최신"이 필요하다면,
staleTime: 0으로 두고,refetchOnMount: true로 설정해서 컴포넌트 마운트마다 무조건 fetch(→브라우저 HTTP 캐시 경로)하도록 할 수 있습니다.
A: QueryKey 작성 시 다음 요령을 따르는 것이 좋습니다:
-
동적 파라미터 포함: 동적 파라미터(필터, ID, 페이지 등)는 반드시 QueryKey(보통 배열) 안에 포함시킵니다.
-
객체 직렬화: 객체를 직접 넣을 땐 프로퍼티 순서를 보장하거나, JSON.stringify로 직렬화합니다.
// 권장 방법 ['products', JSON.stringify({minPrice, maxPrice, category})] // 또는 ['product', String(productId)]
-
QueryKey 팩토리 패턴 사용: 일관된 키 구조를 위해 QueryKey 팩토리 패턴을 사용합니다.
// QueryKey 팩토리 패턴 export const productKeys = { all: ['products'] as const, lists: () => [...productKeys.all, 'list'] as const, list: (filters) => [...productKeys.lists(), filters] as const, details: () => [...productKeys.all, 'detail'] as const, detail: (id) => [...productKeys.details(), id] as const, }
-
계층적 구조 설계: 무효화를 쉽게 하기 위해 계층적 구조로 설계합니다.
// 계층적 구조 예시 ['users'] // 모든 사용자 관련 쿼리 ['users', 'list'] // 사용자 목록 ['users', 'list', filters] // 필터링된 사용자 목록 ['users', 'detail'] // 모든 사용자 상세 정보 ['users', 'detail', userId] // 특정 사용자 상세 정보
A: Mutation 후 캐시 업데이트는 다음과 같은 방법으로 할 수 있습니다:
-
단일 리소스 변경(예: 상품 수정):
mutation.mutate(updatedProduct, { onSuccess: () => { // 1) 변경된 단일 상품 데이터를 바로 cache에 반영 queryClient.setQueryData( productKeys.detail(updatedProduct.id), updatedProduct ); // 2) 상품 목록에도 영향을 줄 수 있으므로, // 목록 캐시를 무효화해 재요청하도록 설정 queryClient.invalidateQueries({ queryKey: productKeys.lists() }); } });
-
낙관적 업데이트(Optimistic Update)로 UX 개선:
const mutation = useMutation({ mutationFn: updateProduct, onMutate: async (newProduct) => { // 진행 중인 refetch 취소 await queryClient.cancelQueries({ queryKey: productKeys.detail(newProduct.id) }); // 이전 상태 저장 const prevProduct = queryClient.getQueryData( productKeys.detail(newProduct.id) ); // 낙관적 업데이트 queryClient.setQueryData( productKeys.detail(newProduct.id), newProduct ); return { prevProduct }; }, onError: (err, newProduct, context) => { // 에러 발생 시 이전 상태로 롤백 queryClient.setQueryData( productKeys.detail(newProduct.id), context.prevProduct ); }, onSettled: (data, error, newProduct) => { // 성공/실패 여부와 관계없이 쿼리 무효화 queryClient.invalidateQueries({ queryKey: productKeys.detail(newProduct.id) }); queryClient.invalidateQueries({ queryKey: productKeys.lists() }); } });
-
관련된 모든 캐시(Key Prefix)를 invalidateQueries로 처리:
// 'products'를 prefix로 갖는 모든 쿼리를 무효화 queryClient.invalidateQueries({ queryKey: ['products'] });
A: 여러 관련 쿼리를 한 번에 무효화하는 방법은 다음과 같습니다:
-
prefix 기반 캐시 무효화:
// 'users'를 prefix(혹은 key 배열의 첫 번째 요소)로 갖는 모든 쿼리를 무효화 queryClient.invalidateQueries('users');
이 방법은 다음과 같은 모든 쿼리를 무효화합니다:
- 문자열 'users'(정확히 같은 값)
- 배열이고 첫 번째 요소가 'users'인 모든 키
['users', {by: 'name', name, keyword}] ['users', {by: 'type', type, subType}] ['users', {by: 'gender', gender}]
-
predicate 함수를 사용한 정교한 매칭:
queryClient.invalidateQueries({ predicate: (query) => { // query.queryKey는 배열 또는 문자열 형태 const key = query.queryKey; // 예시: 첫 요소가 'users'이면서, key[1].by === 'type'인 쿼리만 무효화 if (Array.isArray(key) && key[0] === 'users' && key[1].by === 'type') { return true; } return false; } });
A: "Product API 호출 시 user 정보도 같이 반환"하는 구조라면, Product 쿼리 캐시에 'user'가 내장되어 있을 수 있습니다. 이런 경우 다음과 같은 방법으로 캐시 일관성을 관리할 수 있습니다:
-
관련 캐시 함께 무효화:
onSuccess: (updatedUser) => { // 1) user 캐시 queryClient.invalidateQueries('user'); // 2) product 캐시 – 예를 들어, 특정 상품 목록에 해당 유저 데이터가 들어간다면 // products 조회 키 전부를 무효화 queryClient.invalidateQueries('products'); // 3) 단일 product 캐시 중에도 이 유저가 내장되어 있는 경우가 있으므로, // product prefix 전부를 무효화 queryClient.invalidateQueries('product'); }
장점: 구현이 비교적 간단합니다.
단점: Product 데이터가 많으면 "불필요하게 대부분의 Product 쿼리가 모두 다시 페치"될 수 있어 네트워크 비용이 상승합니다.
-
cache 업데이트(수동으로 setQueryData)로 일관성 유지:
onSuccess: (updatedUser) => { // 1) user 캐시 덮어쓰기 (단일 유저) queryClient.setQueryData(['user', 'byId', String(updatedUser.id)], updatedUser); queryClient.setQueryData(['user', 'byName', updatedUser.name], updatedUser); // 2) Product 캐시 중, "내장된 user.id === updatedUser.id"인 데이터들을 찾아서 // user 부분만 업데이트 // 예시: products 리스트 캐시에 들어있는 user 배열을 덮어쓰기 queryClient.setQueryData(['products'], oldData => { if (!oldData) return oldData; return oldData.map(product => { if (product.owner.id === updatedUser.id) { return { ...product, owner: updatedUser }; } return product; }); }); // 3) 단일 product 캐시 중에서도 해당 user가 포함된 것들을 업데이트 // 이 부분은 모든 product 캐시를 순회해야 하므로 복잡할 수 있음 // 실제 구현 시에는 제품 ID 목록을 알고 있는 경우에만 가능 knownProductIds.forEach(productId => { queryClient.setQueryData(['product', productId], oldProduct => { if (!oldProduct || oldProduct.owner.id !== updatedUser.id) return oldProduct; return { ...oldProduct, owner: updatedUser }; }); }); }
장점: 필요한 캐시만 정확히 업데이트하므로 네트워크 요청을 최소화합니다.
단점: 구현이 복잡하고, 모든 관련 캐시를 찾아 업데이트하기 어려울 수 있습니다.
A: TanStack Query v5에서 권장하는 전반적인 모범 사례는 다음과 같습니다:
-
QueryKey 관리:
- QueryKey 팩토리 패턴을 사용하여 일관된 키 구조를 유지합니다.
- 동적 파라미터는 항상 QueryKey에 포함시킵니다.
- 계층적 구조로 설계하여 부분 무효화를 쉽게 합니다.
-
데이터 페칭 최적화:
select옵션을 사용하여 필요한 데이터만 구독합니다.enabled옵션을 사용하여 조건부 쿼리를 구현합니다.useQueries를 사용하여 병렬 쿼리를 최적화합니다.prefetchQuery를 사용하여 사용자 경험을 개선합니다.
-
캐시 관리:
staleTime과gcTime을 적절히 설정하여 캐시 수명을 관리합니다.invalidateQueries를 사용하여 관련 쿼리를 무효화합니다.setQueryData를 사용하여 캐시를 직접 업데이트합니다.- 낙관적 업데이트를 구현하여 사용자 경험을 개선합니다.
-
에러 처리:
- React Error Boundary와 함께 사용하여 선언적 에러 처리를 구현합니다.
retry옵션을 사용하여 일시적인 오류에 대한 복원력을 제공합니다.onError콜백을 사용하여 에러 로깅 및 알림을 구현합니다.
-
성능 최적화:
keepPreviousData를 사용하여 페이지네이션 UX를 개선합니다.useInfiniteQuery를 사용하여 무한 스크롤을 구현합니다.refetchOnWindowFocus,refetchOnMount,refetchOnReconnect옵션을 적절히 설정합니다.- DevTools를 활용하여 쿼리 동작을 모니터링하고 디버깅합니다.
-
타입스크립트 활용:
- 제네릭을 활용하여 타입 안정성을 확보합니다.
- QueryKey 팩토리에
as const를 사용하여 타입 추론을 개선합니다. QueryFunctionContext를 활용하여 queryFn에서 타입 안정성을 확보합니다.
-
코드 구조화:
- 커스텀 훅으로 쿼리 로직을 캡슐화합니다.
- 관련 쿼리를 모듈로 그룹화합니다.
- QueryKey 팩토리를 별도 파일로 분리하여 관리합니다.
이러한 모범 사례를 따르면 TanStack Query를 사용하여 효율적이고 유지보수하기 쉬운 데이터 페칭 로직을 구현할 수 있습니다.
TanStack Query v5는 서버 상태 관리를 위한 강력한 도구입니다. 이 가이드에서는 기본 사용법부터 고급 패턴까지 다양한 주제를 다루었습니다. QueryKey 관리, 캐싱 전략, 페이지네이션, Mutation, 성능 최적화, 에러 처리 등의 개념을 이해하고 적용하면 더 효율적이고 사용자 친화적인 애플리케이션을 개발할 수 있습니다.
TanStack Query의 공식 문서와 커뮤니티 리소스를 활용하여 더 깊이 있는 지식을 습득하고, 실제 프로젝트에 적용해보세요. 서버 상태 관리의 복잡성을 줄이고 개발 경험을 향상시키는 TanStack Query의 강력한 기능을 최대한 활용하시기 바랍니다.
感觉像是一些web开发的教程,是你做的教程吗,确实不错