React Query Prefetch, 언제 어떻게 써야 할까?
안녕하세요! 포테코입니다.
React Query의 prefetch는 사용자가 데이터를 필요로 하기 전에 미리 가져와 캐시에 저장하는 기능이에요. 이번 글에서는 prefetch의 개념부터 활용법, 그리고 initialData와 setQueryData와의 차이점까지 실무 관점에서 다뤄볼게요.
Prefetch란?
Prefetch는 사용자가 실제로 데이터를 요청하기 전에 미리 데이터를 가져와 캐시에 저장하는 기법이에요. 이를 통해 사용자가 다음 페이지나 컴포넌트로 이동했을 때 즉시 데이터를 표시할 수 있어요.
Prefetch는 사용자 경험을 크게 향상시킵니다. 데이터를 미리 가져와 캐시에 저장하면, 실제로 필요할 때 로딩 시간 없이 즉시 표시할 수 있습니다.
왜 Prefetch를 사용할까?
1. 사용자 경험 향상
사용자가 링크에 마우스를 올리거나, 다음 페이지로 이동할 가능성이 높을 때 미리 데이터를 가져오면 로딩 시간이 거의 없어져요.
// ❌ Prefetch 없이: 사용자가 클릭하면 로딩 시작
function ProductLink({ id }: { id: string }) {
return (
<Link href={`/products/${id}`}>
<ProductCard id={id} />
</Link>
);
}
// ✅ Prefetch 사용: 마우스 오버 시 미리 데이터 가져오기
function ProductLink({ id }: { id: string }) {
const queryClient = useQueryClient();
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id),
});
};
return (
<Link href={`/products/${id}`} onMouseEnter={handleMouseEnter}>
<ProductCard id={id} />
</Link>
);
}2. 네트워크 효율성
Prefetch는 백그라운드에서 조용히 실행되므로, 사용자가 다른 작업을 하는 동안 데이터를 미리 가져올 수 있어요.
"use client";
import { useQueryClient } from "@tanstack/react-query";
export function ProductList() {
const queryClient = useQueryClient();
// 사용자가 리스트를 보는 동안 다음 페이지 데이터 미리 가져오기
useEffect(() => {
queryClient.prefetchQuery({
queryKey: ["products", { page: 2 }],
queryFn: () => fetchProducts({ page: 2 }),
});
}, [queryClient]);
return <div>{/* ... */}</div>;
}3. SEO 및 초기 로딩 최적화
서버 사이드에서 prefetch를 사용하면 초기 HTML에 데이터가 포함되어 SEO와 초기 로딩 속도를 개선할 수 있어요.
// app/products/[id]/page.tsx (서버 컴포넌트)
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { ProductDetail } from "@/components/products/ProductDetail";
export default async function ProductPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
// 서버에서 미리 데이터 페칭
await queryClient.prefetchQuery({
queryKey: ["product", params.id],
queryFn: () => fetchProduct(params.id),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductDetail id={params.id} />
</HydrationBoundary>
);
}Prefetch의 이점
1. 즉각적인 데이터 표시
Prefetch된 데이터는 캐시에 저장되어 있어서, 컴포넌트가 마운트될 때 로딩 상태 없이 즉시 표시돼요.
"use client";
import { useQuery } from "@tanstack/react-query";
export function ProductDetail({ id }: { id: string }) {
// Prefetch된 데이터가 있으면 즉시 표시, 없으면 로딩
const { data: product, isLoading } = useQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id),
});
// Prefetch 덕분에 isLoading이 false일 가능성이 높음
if (isLoading) return <div>Loading...</div>;
return <div>{product.name}</div>;
}2. 네트워크 대역폭 활용
사용자가 유휴 상태일 때 네트워크 대역폭을 활용해서 필요한 데이터를 미리 준비할 수 있어요.
3. 예측 가능한 사용자 행동에 대응
사용자가 특정 링크를 클릭할 가능성이 높을 때 미리 prefetch하면, 클릭 시 즉시 콘텐츠를 표시할 수 있어요.
Prefetch 사용 방법
1. 클라이언트 사이드 Prefetch
마우스 이벤트 기반
"use client";
import { useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
export function ProductCard({ product }: { product: Product }) {
const queryClient = useQueryClient();
const handleMouseEnter = () => {
// 마우스 오버 시 상세 페이지 데이터 미리 가져오기
queryClient.prefetchQuery({
queryKey: ["product", product.id],
queryFn: () => fetchProduct(product.id),
staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
});
};
return (
<Link
href={`/products/${product.id}`}
onMouseEnter={handleMouseEnter}
>
<div>
<h3>{product.name}</h3>
<p>{product.description}</p>
</div>
</Link>
);
}프로그래매틱 Prefetch
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
export function ProductList() {
const queryClient = useQueryClient();
const { data: products } = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
});
useEffect(() => {
// 첫 번째 제품의 상세 정보 미리 가져오기
if (products?.[0]) {
queryClient.prefetchQuery({
queryKey: ["product", products[0].id],
queryFn: () => fetchProduct(products[0].id),
});
}
}, [products, queryClient]);
return <div>{/* ... */}</div>;
}2. 서버 사이드 Prefetch
Next.js의 서버 컴포넌트에서 prefetch를 사용하면 초기 HTML에 데이터가 포함돼요.
// app/products/[id]/page.tsx
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { ProductDetail } from "@/components/products/ProductDetail";
export default async function ProductPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
try {
// 서버에서 미리 데이터 페칭
await queryClient.prefetchQuery({
queryKey: ["product", params.id],
queryFn: () => fetchProduct(params.id),
});
} catch (error) {
// 에러 처리 (선택적)
console.error("Failed to prefetch product:", error);
}
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductDetail id={params.id} />
</HydrationBoundary>
);
}3. 여러 쿼리 동시 Prefetch
// app/products/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
// 여러 쿼리를 동시에 prefetch
await Promise.all([
queryClient.prefetchQuery({
queryKey: ["product", params.id],
queryFn: () => fetchProduct(params.id),
}),
queryClient.prefetchQuery({
queryKey: ["product", params.id, "reviews"],
queryFn: () => fetchProductReviews(params.id),
}),
queryClient.prefetchQuery({
queryKey: ["product", params.id, "related"],
queryFn: () => fetchRelatedProducts(params.id),
}),
]);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductDetail id={params.id} />
</HydrationBoundary>
);
}Prefetch vs initialData vs setQueryData
세 가지 방법 모두 데이터를 캐시에 저장하지만, 목적과 사용 시점이 달라요.
1. Prefetch
목적: 사용자가 필요로 하기 전에 미리 데이터를 가져와 캐시에 저장
특징:
- 비동기로 데이터를 가져옴
- 네트워크 요청 발생
- 캐시에 저장되어 나중에 사용 가능
- 쿼리 상태가 설정됨 (isLoading, isError 등)
// Prefetch: 실제 네트워크 요청
queryClient.prefetchQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id), // 실제 API 호출
});2. initialData
목적: 쿼리가 처음 마운트될 때 사용할 초기 데이터 제공
특징:
- 동기적으로 설정됨
- 네트워크 요청 없이 즉시 사용 가능
- 데이터가 stale로 간주됨 (기본값)
- 백그라운드에서 자동으로 refetch될 수 있음
사용 시나리오: 서버에서 prefetch를 사용하지 않고, props로 데이터를 전달받을 때
// 서버 컴포넌트 (prefetch 없이)
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.id); // 직접 fetch
return <ProductDetail id={params.id} initialProduct={product} />;
}
// 클라이언트 컴포넌트
"use client";
import { useQuery } from "@tanstack/react-query";
export function ProductDetail({ id, initialProduct }: { id: string; initialProduct: Product }) {
const { data: product } = useQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id),
initialData: initialProduct, // props로 받은 데이터를 초기 데이터로 사용
// initialData가 있으면 백그라운드에서 refetch될 수 있음
});
return <div>{product.name}</div>;
}Prefetch + HydrationBoundary를 사용할 때는 initialData가 불필요합니다!
HydrationBoundary가 자동으로 하이드레이션하므로, useQuery가 캐시에서
자동으로 데이터를 가져옵니다. initialData는 prefetch를 사용하지 않고 props로
데이터를 전달할 때만 사용하세요.
3. setQueryData
목적: 캐시에 데이터를 직접 설정 (일반적으로 mutation 후 사용)
특징:
- 동기적으로 즉시 설정됨
- 네트워크 요청 없음
- 데이터가 fresh로 간주됨 (기본값)
- 수동으로 관리해야 함
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function UpdateProduct() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateProduct,
onSuccess: (updatedProduct) => {
// Mutation 성공 후 캐시 직접 업데이트
queryClient.setQueryData(
["product", updatedProduct.id],
updatedProduct
);
},
});
return <button onClick={() => mutation.mutate(data)}>Update</button>;
}비교표
| 특징 | Prefetch | initialData | setQueryData |
|---|---|---|---|
| 네트워크 요청 | ✅ 있음 | ❌ 없음 | ❌ 없음 |
| 실행 시점 | 비동기 (미리) | 동기 (마운트 시) | 동기 (즉시) |
| 데이터 상태 | Fresh (설정 가능) | Stale (기본값) | Fresh (기본값) |
| 자동 Refetch | ❌ | ✅ (기본값) | ❌ |
| 사용 목적 | 예측 가능한 데이터 | 초기 렌더링 | Mutation 후 업데이트 |
| 서버 사이드 | ✅ 가능 | ❌ 불가능 | ❌ 불가능 |
| HydrationBoundary와 함께 | ✅ 자동 하이드레이션 | ❌ 불필요 | ❌ 불필요 |
Prefetch는 미래를 위한 준비, initialData는 즉시 표시, setQueryData는 수동 업데이트입니다. 각각의 목적에 맞게 사용하는 것이 중요합니다.
중요: 서버에서 prefetchQuery + HydrationBoundary를 사용하면 initialData가 불필요합니다. HydrationBoundary가 QueryClient 상태를 자동으로 하이드레이션하므로, 클라이언트 컴포넌트의 useQuery가 캐시에서 자동으로 데이터를 가져옵니다.
실전 예제: 통합 활용
시나리오: 제품 상세 페이지
// 1. 서버에서 Prefetch (초기 로딩 최적화)
// app/products/[id]/page.tsx
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { ProductDetail } from "@/components/products/ProductDetail";
export default async function ProductPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
// 서버에서 미리 데이터 가져오기
await queryClient.prefetchQuery({
queryKey: ["product", params.id],
queryFn: () => fetchProduct(params.id),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductDetail id={params.id} />
</HydrationBoundary>
);
}// 2. 클라이언트 컴포넌트 (HydrationBoundary가 자동 처리)
// components/products/ProductDetail.tsx
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
interface ProductDetailProps {
id: string;
}
export function ProductDetail({ id }: ProductDetailProps) {
const queryClient = useQueryClient();
// HydrationBoundary가 자동으로 하이드레이션하므로
// useQuery가 캐시에서 자동으로 데이터를 가져옴
// initialData 불필요! ✅
const { data: product } = useQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id),
staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
});
// 관련 제품 미리 가져오기
useEffect(() => {
if (product?.relatedIds) {
product.relatedIds.forEach((relatedId: string) => {
queryClient.prefetchQuery({
queryKey: ["product", relatedId],
queryFn: () => fetchProduct(relatedId),
});
});
}
}, [product, queryClient]);
return <div>{product?.name}</div>;
}// 3. Mutation 후 setQueryData로 업데이트
// components/products/UpdateProductForm.tsx
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function UpdateProductForm({ productId }: { productId: string }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: UpdateProductData) => updateProduct(productId, data),
onSuccess: (updatedProduct) => {
// Mutation 성공 후 캐시 직접 업데이트
queryClient.setQueryData(
["product", productId],
updatedProduct
);
// 관련 쿼리도 무효화
queryClient.invalidateQueries({
queryKey: ["products"],
});
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
mutation.mutate(new FormData(e.currentTarget));
}}>
{/* ... */}
</form>
);
}베스트 프랙티스
1. 적절한 타이밍에 Prefetch
// ✅ 좋은 예: 사용자가 링크에 마우스를 올렸을 때
function ProductLink({ id }: { id: string }) {
const queryClient = useQueryClient();
return (
<Link
href={`/products/${id}`}
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id),
});
}}
>
Product {id}
</Link>
);
}
// ❌ 나쁜 예: 모든 제품을 한 번에 prefetch
useEffect(() => {
products.forEach((product) => {
queryClient.prefetchQuery({
queryKey: ["product", product.id],
queryFn: () => fetchProduct(product.id),
});
});
}, [products]); // 너무 많은 요청 발생2. staleTime 설정
Prefetch된 데이터가 너무 빨리 stale 상태가 되지 않도록 적절한 staleTime을 설정하는 것이 좋아요.
queryClient.prefetchQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id),
staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
});3. 에러 처리
Prefetch 실패가 사용자 경험에 영향을 주지 않도록 에러를 적절히 처리하는 것이 중요해요.
// 서버 사이드
try {
await queryClient.prefetchQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id),
});
} catch (error) {
// Prefetch 실패해도 페이지는 정상 렌더링
console.error("Prefetch failed:", error);
}
// 클라이언트 사이드
const handleMouseEnter = () => {
queryClient
.prefetchQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id),
})
.catch((error) => {
// 에러를 조용히 처리 (사용자에게 표시하지 않음)
console.error("Prefetch failed:", error);
});
};4. 조건부 Prefetch
필요한 경우에만 prefetch하도록 조건을 추가하는 것이 좋아요.
const handleMouseEnter = () => {
// 이미 데이터가 있거나 로딩 중이면 prefetch하지 않음
const queryState = queryClient.getQueryState(["product", id]);
if (!queryState || queryState.status === "error") {
queryClient.prefetchQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id),
});
}
};성능 고려사항
1. 네트워크 대역폭
너무 많은 prefetch는 네트워크 대역폭을 낭비할 수 있어요. 중요한 데이터만 선별적으로 prefetch하는 것이 좋습니다.
2. 서버 부하
서버 사이드 prefetch는 서버 부하를 증가시킬 수 있어요. 필요한 경우에만 사용하고, 캐싱 전략을 고려하는 것이 중요합니다.
3. 메모리 사용
Prefetch된 데이터는 메모리에 저장됩니다. 오래된 캐시는 자동으로 정리되지만, gcTime을 적절히 설정하세요.
마무리
React Query의 prefetch는 사용자 경험을 크게 향상시키는 강력한 기능이에요:
- Prefetch: 미래를 위한 데이터 준비
- initialData: 즉시 표시를 위한 초기 데이터
- setQueryData: 수동 캐시 업데이트
각각의 목적에 맞게 사용하면 최적의 성능과 사용자 경험을 제공할 수 있어요.
Prefetch, initialData, setQueryData를 적절히 조합하면, 빠른 초기 로딩과 부드러운 인터랙션을 동시에 달성할 수 있어요. 각각의 특징을 이해하고 상황에 맞게 활용하는 것이 중요합니다.
React Query의 prefetch 기능을 적절히 활용하면 더 빠르고 부드러운 사용자 경험을 제공할 수 있어요. 앞으로도 React Query를 활용한 실전 사례와 최적화 팁을 계속 공유할 예정이니 기대해 주세요!
