- 소개
- 기본 사용법
- 동적 파라미터와 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의 강력한 기능을 최대한 활용하시기 바랍니다.
@chanwoo00106
I need your comment plz