Next.js와 React Query, 캐싱 전략 어떻게 세울까?
안녕하세요! 포테코입니다.
Next.js와 React Query를 함께 사용할 때, 서버 사이드 캐싱과 클라이언트 사이드 캐싱을 효과적으로 결합하는 것이 중요해요. 이번 글에서는 두 가지 캐싱 메커니즘을 이해하고, 최적의 캐싱 전략을 수립하는 방법을 실무 관점에서 다뤄볼게요.
캐싱의 두 가지 레이어
1. Next.js 서버 사이드 캐싱
Next.js는 서버 컴포넌트에서 fetch를 사용할 때 자동으로 캐싱해요.
// 서버 컴포넌트
export default async function ProductsPage() {
const products = await fetch("https://api.example.com/products", {
next: { revalidate: 3600 }, // 1시간마다 재검증
});
return <ProductsList products={products} />;
}Next.js 캐싱의 특징:
- 서버에서만 작동
- Request Memoization으로 중복 요청 자동 제거
revalidate옵션으로 재검증 주기 제어- 빌드 타임에 정적 생성 가능
2. React Query 클라이언트 사이드 캐싱
React Query는 브라우저에서 데이터를 캐싱해서 불필요한 네트워크 요청을 방지해요.
"use client";
import { useQuery } from "@tanstack/react-query";
export function ProductsList() {
const { data: products } = useQuery({
queryKey: ["products"],
queryFn: () => fetch("/api/products").then((res) => res.json()),
staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
gcTime: 10 * 60 * 1000, // 10분간 메모리에 유지
});
return <div>{/* ... */}</div>;
}React Query 캐싱의 특징:
- 클라이언트에서 작동
- 자동 백그라운드 동기화
staleTime과gcTime으로 캐시 수명 제어- 쿼리 키 기반 캐시 관리
Next.js의 서버 캐싱과 React Query의 클라이언트 캐싱은 서로 다른 레이어에서 작동합니다. 두 가지를 함께 사용하면 서버 부하를 줄이고 클라이언트 성능을 향상시킬 수 있습니다.
React Query 캐싱 개념 이해
staleTime vs gcTime
React Query의 캐싱은 두 가지 시간 개념으로 관리돼요.
staleTime (이전 cacheTime)
의미: 데이터가 "신선한(fresh)" 상태로 유지되는 시간
staleTime동안은 데이터가 fresh 상태로 간주됨- Fresh 상태일 때는 자동 refetch가 발생하지 않음
- 기본값:
0(즉시 stale)
useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
// 5분 이내에는 자동 refetch 없음
});gcTime (이전 cacheTime)
의미: 사용되지 않는 데이터가 메모리에 유지되는 시간
- 컴포넌트가 언마운트되어도
gcTime동안은 캐시에 유지 - 같은 쿼리를 다시 사용하면 즉시 캐시에서 가져옴
- 기본값:
5 * 60 * 1000(5분)
useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
gcTime: 10 * 60 * 1000, // 10분간 메모리에 유지
// 컴포넌트 언마운트 후 10분간 캐시 유지
});staleTime은 "언제 다시 가져올지"를 결정하고, gcTime은 "언제까지 메모리에 보관할지"를 결정합니다.
staleTime < gcTime이 일반적으로 권장됩니다.
데이터 상태: Fresh vs Stale
Fresh 상태 (staleTime 이내)
- 자동 refetch 없음
- 캐시에서 즉시 반환
Stale 상태 (staleTime 경과)
- 백그라운드에서 자동 refetch 가능
- 캐시 데이터는 즉시 표시, 백그라운드에서 업데이트캐싱 전략 수립
1. 데이터 유형별 캐싱 전략
정적 데이터 (Static Data)
변경이 거의 없는 데이터는 긴 staleTime을 설정하는 것이 좋아요.
// 설정, 카테고리 등
useQuery({
queryKey: ["categories"],
queryFn: fetchCategories,
staleTime: Infinity, // 영구적으로 fresh
gcTime: Infinity, // 영구적으로 캐시 유지
});반정적 데이터 (Semi-Static Data)
가끔 변경되는 데이터는 중간 길이의 staleTime을 설정하면 좋아요.
// 제품 정보, 사용자 프로필 등
useQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id),
staleTime: 5 * 60 * 1000, // 5분
gcTime: 10 * 60 * 1000, // 10분
});동적 데이터 (Dynamic Data)
자주 변경되는 데이터는 짧은 staleTime을 설정하는 것이 좋아요.
// 실시간 주문, 알림 등
useQuery({
queryKey: ["orders"],
queryFn: fetchOrders,
staleTime: 0, // 즉시 stale
refetchInterval: 30000, // 30초마다 자동 refetch
});2. Next.js와 React Query 통합 전략
패턴 1: 서버에서 초기 데이터, 클라이언트에서 업데이트
// app/products/page.tsx (서버)
import { getProducts } from "@/lib/api";
import { ProductsList } from "@/components/products/ProductsList";
export default async function ProductsPage() {
// 서버에서 초기 데이터 페칭 (Next.js 캐싱)
const initialProducts = await getProducts();
return <ProductsList initialProducts={initialProducts} />;
}// components/products/ProductsList.tsx (클라이언트)
"use client";
import { useQuery } from "@tanstack/react-query";
import { getProducts } from "@/lib/api";
export function ProductsList({ initialProducts }: { initialProducts: Product[] }) {
const { data: products = initialProducts } = useQuery({
queryKey: ["products"],
queryFn: getProducts,
initialData: initialProducts, // 서버 데이터를 초기 데이터로
staleTime: 5 * 60 * 1000, // 5분간 fresh
// 서버 데이터가 있으면 즉시 표시, 5분 후 백그라운드에서 업데이트
});
return <div>{/* ... */}</div>;
}패턴 2: Prefetch + Hydration
// 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();
// 서버에서 prefetch (Next.js 캐싱 + React Query 캐싱)
await queryClient.prefetchQuery({
queryKey: ["product", params.id],
queryFn: () => fetchProduct(params.id),
staleTime: 5 * 60 * 1000, // 5분간 fresh
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductDetail id={params.id} />
</HydrationBoundary>
);
}3. 캐시 무효화 전략
선택적 무효화
특정 쿼리만 무효화해서 불필요한 refetch를 방지할 수 있어요.
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function UpdateProduct() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateProduct,
onSuccess: (updatedProduct) => {
// 1. 해당 제품만 업데이트
queryClient.setQueryData(
["product", updatedProduct.id],
updatedProduct
);
// 2. 제품 리스트만 무효화 (선택적)
queryClient.invalidateQueries({
queryKey: ["products"],
});
// 3. 관련 쿼리는 무효화하지 않음 (성능 최적화)
},
});
return <button onClick={() => mutation.mutate(data)}>Update</button>;
}계층적 무효화
쿼리 키 계층을 활용해서 관련 쿼리를 일괄 무효화할 수 있어요.
// 쿼리 키 구조
const queryKeys = {
products: {
all: ["products"] as const,
lists: () => [...queryKeys.products.all, "list"] as const,
list: (filters: Filters) =>
[...queryKeys.products.lists(), filters] as const,
details: () => [...queryKeys.products.all, "detail"] as const,
detail: (id: string) => [...queryKeys.products.details(), id] as const,
},
};
// 사용
queryClient.invalidateQueries({
queryKey: queryKeys.products.all, // 모든 제품 관련 쿼리 무효화
});
queryClient.invalidateQueries({
queryKey: queryKeys.products.details(), // 모든 제품 상세 쿼리만 무효화
});실전 캐싱 전략 예제
예제 1: 전자상거래 제품 페이지
// lib/api/products.ts
export async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 3600 }, // Next.js: 1시간 캐싱
});
return res.json();
}// app/products/[id]/page.tsx (서버)
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getProduct } from "@/lib/api/products";
import { ProductDetail } from "@/components/products/ProductDetail";
export default async function ProductPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
// 서버에서 prefetch (Next.js 캐싱 활용)
await queryClient.prefetchQuery({
queryKey: ["product", params.id],
queryFn: () => getProduct(params.id),
staleTime: 5 * 60 * 1000, // React Query: 5분간 fresh
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductDetail id={params.id} />
</HydrationBoundary>
);
}// components/products/ProductDetail.tsx (클라이언트)
"use client";
import { useQuery } from "@tanstack/react-query";
import { getProduct } from "@/lib/api/products";
export function ProductDetail({ id }: { id: string }) {
const { data: product } = useQuery({
queryKey: ["product", id],
queryFn: () => getProduct(id),
staleTime: 5 * 60 * 1000, // 5분간 fresh
gcTime: 10 * 60 * 1000, // 10분간 메모리 유지
refetchOnWindowFocus: false, // 창 포커스 시 refetch 안 함
});
return <div>{product?.name}</div>;
}예제 2: 실시간 대시보드
// components/dashboard/Dashboard.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
export function Dashboard() {
// 실시간 데이터: 짧은 staleTime + 자동 refetch
const { data: stats } = useQuery({
queryKey: ["dashboard", "stats"],
queryFn: fetchStats,
staleTime: 0, // 즉시 stale
refetchInterval: 10000, // 10초마다 자동 refetch
refetchOnWindowFocus: true, // 창 포커스 시 refetch
});
// 정적 설정: 긴 staleTime
const { data: settings } = useQuery({
queryKey: ["dashboard", "settings"],
queryFn: fetchSettings,
staleTime: Infinity, // 영구적으로 fresh
});
return <div>{/* ... */}</div>;
}예제 3: 검색 결과
// components/search/SearchResults.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
import { useDebounce } from "@/hooks/useDebounce";
export function SearchResults({ query }: { query: string }) {
const debouncedQuery = useDebounce(query, 300);
const { data: results } = useQuery({
queryKey: ["search", debouncedQuery],
queryFn: () => searchProducts(debouncedQuery),
enabled: debouncedQuery.length > 0, // 쿼리가 있을 때만 실행
staleTime: 2 * 60 * 1000, // 2분간 fresh
gcTime: 5 * 60 * 1000, // 5분간 캐시 유지
// 같은 검색어를 다시 입력하면 캐시에서 즉시 반환
});
return <div>{/* ... */}</div>;
}고급 캐싱 전략
1. 조건부 캐싱
데이터의 중요도에 따라 다른 캐싱 전략을 적용하는 것이 좋아요.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0, // 기본값: 즉시 stale
gcTime: 5 * 60 * 1000, // 기본값: 5분
},
},
});
// 중요 데이터: 긴 캐싱
useQuery({
queryKey: ["user", "profile"],
queryFn: fetchUserProfile,
staleTime: 10 * 60 * 1000, // 10분
gcTime: 30 * 60 * 1000, // 30분
});
// 덜 중요한 데이터: 짧은 캐싱
useQuery({
queryKey: ["notifications"],
queryFn: fetchNotifications,
staleTime: 30 * 1000, // 30초
gcTime: 2 * 60 * 1000, // 2분
});2. 캐시 우선순위
중요한 데이터는 더 오래 캐시에 유지하는 것이 좋아요.
// QueryClient 설정
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: (query) => {
// 쿼리 키에 따라 다른 staleTime
if (query.queryKey[0] === "user") {
return 10 * 60 * 1000; // 사용자 데이터: 10분
}
if (query.queryKey[0] === "products") {
return 5 * 60 * 1000; // 제품 데이터: 5분
}
return 0; // 기본값: 즉시 stale
},
},
},
});3. 캐시 공유 전략
같은 데이터를 다른 형태로 캐싱해서 효율성을 높일 수 있어요.
// 제품 리스트와 개별 제품 캐시 공유
const { data: products } = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
});
const { data: product } = useQuery({
queryKey: ["product", id],
queryFn: () => {
// 리스트에서 찾기 시도
const cached = queryClient.getQueryData(["products"]);
if (cached) {
const found = cached.find((p: Product) => p.id === id);
if (found) return Promise.resolve(found);
}
// 없으면 API 호출
return fetchProduct(id);
},
});캐시 무효화 패턴
1. Mutation 후 무효화
const mutation = useMutation({
mutationFn: createProduct,
onSuccess: () => {
// 제품 생성 후 리스트 무효화
queryClient.invalidateQueries({
queryKey: ["products"],
});
},
});2. 낙관적 업데이트 + 무효화
const mutation = useMutation({
mutationFn: updateProduct,
onMutate: async (newProduct) => {
// 진행 중인 쿼리 취소
await queryClient.cancelQueries({ queryKey: ["product", id] });
// 이전 값 저장
const previousProduct = queryClient.getQueryData(["product", id]);
// 낙관적 업데이트
queryClient.setQueryData(["product", id], newProduct);
return { previousProduct };
},
onError: (err, variables, context) => {
// 에러 시 롤백
queryClient.setQueryData(["product", id], context?.previousProduct);
},
onSettled: () => {
// 성공/실패 관계없이 서버 데이터로 동기화
queryClient.invalidateQueries({
queryKey: ["product", id],
});
},
});3. 배치 무효화
여러 관련 쿼리를 한 번에 무효화할 수 있어요.
const updateUser = useMutation({
mutationFn: updateUserProfile,
onSuccess: () => {
// 사용자 관련 모든 쿼리 무효화
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === "user";
},
});
},
});성능 최적화 팁
1. 불필요한 Refetch 방지
useQuery({
queryKey: ["product", id],
queryFn: () => fetchProduct(id),
staleTime: 5 * 60 * 1000, // 5분간 fresh
refetchOnWindowFocus: false, // 창 포커스 시 refetch 안 함
refetchOnReconnect: false, // 네트워크 재연결 시 refetch 안 함
refetchOnMount: false, // 마운트 시 refetch 안 함 (캐시 사용)
});2. 쿼리 키 최적화
계층적 쿼리 키를 사용해서 효율적인 무효화를 수행할 수 있어요.
// ✅ 좋은 예: 계층적 구조
const queryKeys = {
products: {
all: ["products"] as const,
lists: () => [...queryKeys.products.all, "list"] as const,
list: (filters: Filters) =>
[...queryKeys.products.lists(), filters] as const,
},
};
// ❌ 나쁜 예: 평면적 구조
const queryKeys = {
products: ["products"],
productsWithFilters: (filters: Filters) => ["products", filters],
// 무효화가 어려움
};3. 메모리 관리
오래된 캐시는 자동으로 정리되지만, gcTime을 적절히 설정하는 것이 좋아요.
// 자주 사용되는 데이터: 긴 gcTime
useQuery({
queryKey: ["user", "profile"],
queryFn: fetchUserProfile,
gcTime: 30 * 60 * 1000, // 30분
});
// 덜 사용되는 데이터: 짧은 gcTime
useQuery({
queryKey: ["temp", "data"],
queryFn: fetchTempData,
gcTime: 1 * 60 * 1000, // 1분
});Next.js와 React Query 캐싱 통합
완전한 통합 예제
// lib/api/products.ts
export async function getProducts(filters?: Filters) {
const params = new URLSearchParams();
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
}
const res = await fetch(`https://api.example.com/products?${params}`, {
next: {
revalidate: 3600, // Next.js: 1시간 캐싱
tags: ["products"], // 태그 기반 재검증
},
});
if (!res.ok) throw new Error("Failed to fetch products");
return res.json();
}// app/products/page.tsx (서버)
import { getProducts } from "@/lib/api/products";
import { ProductsList } from "@/components/products/ProductsList";
export default async function ProductsPage() {
// Next.js 서버 캐싱 활용
const initialProducts = await getProducts();
return <ProductsList initialProducts={initialProducts} />;
}// components/products/ProductsList.tsx (클라이언트)
"use client";
import { useQuery } from "@tanstack/react-query";
import { getProducts } from "@/lib/api/products";
export function ProductsList({ initialProducts }: { initialProducts: Product[] }) {
const { data: products = initialProducts } = useQuery({
queryKey: ["products"],
queryFn: () => getProducts(),
initialData: initialProducts, // 서버 데이터 사용
staleTime: 5 * 60 * 1000, // React Query: 5분간 fresh
gcTime: 10 * 60 * 1000, // 10분간 메모리 유지
// 서버 캐시 + 클라이언트 캐시 = 이중 캐싱
});
return <div>{/* ... */}</div>;
}베스트 프랙티스 체크리스트
✅ DO
- 데이터 유형에 맞는
staleTime설정 staleTime < gcTime유지- 계층적 쿼리 키 사용
- 선택적 캐시 무효화
- 서버와 클라이언트 캐싱 조합
❌ DON'T
- 모든 쿼리에 동일한 캐싱 전략 적용
- 불필요한 refetch 설정
- 과도한 캐시 무효화
staleTime과gcTime혼동- 서버와 클라이언트 캐싱 중복
마무리
Next.js와 React Query의 캐싱을 효과적으로 결합하면 이런 이점들이 있어요.
- 서버 부하 감소: Next.js 서버 캐싱으로 불필요한 요청 방지
- 클라이언트 성능 향상: React Query 클라이언트 캐싱으로 빠른 응답
- 사용자 경험 개선: 로딩 시간 최소화, 부드러운 인터랙션
Next.js의 서버 사이드 캐싱과 React Query의 클라이언트 사이드 캐싱을 함께 사용하면, 최고의 성능과 사용자 경험을 제공할 수 있어요. 데이터 유형과 사용 패턴에 맞는 캐싱 전략을 수립하는 것이 중요합니다.
적절한 캐싱 전략을 수립하면 더 빠르고 효율적인 웹 애플리케이션을 만들 수 있어요. 앞으로도 Next.js와 React Query를 활용한 실전 캐싱 전략과 성능 최적화 팁을 계속 공유할 예정이니 기대해 주세요!
