리스트로 돌아가기
Next.js와 React Query, 캐싱 전략 어떻게 세울까? thumbnail

Next.js와 React Query, 캐싱 전략 어떻게 세울까?

Next.js의 서버 사이드 캐싱과 React Query의 클라이언트 사이드 캐싱을 효과적으로 결합하는 방법을 실무 관점에서 알아봤어요. 최적의 캐싱 전략으로 성능을 극대화하는 실전 가이드입니다.

2026-01-05 00:00

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 캐싱의 특징:

  • 클라이언트에서 작동
  • 자동 백그라운드 동기화
  • staleTimegcTime으로 캐시 수명 제어
  • 쿼리 키 기반 캐시 관리

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 설정
  • 과도한 캐시 무효화
  • staleTimegcTime 혼동
  • 서버와 클라이언트 캐싱 중복

마무리

Next.js와 React Query의 캐싱을 효과적으로 결합하면 이런 이점들이 있어요.

  • 서버 부하 감소: Next.js 서버 캐싱으로 불필요한 요청 방지
  • 클라이언트 성능 향상: React Query 클라이언트 캐싱으로 빠른 응답
  • 사용자 경험 개선: 로딩 시간 최소화, 부드러운 인터랙션

Next.js의 서버 사이드 캐싱과 React Query의 클라이언트 사이드 캐싱을 함께 사용하면, 최고의 성능과 사용자 경험을 제공할 수 있어요. 데이터 유형과 사용 패턴에 맞는 캐싱 전략을 수립하는 것이 중요합니다.

적절한 캐싱 전략을 수립하면 더 빠르고 효율적인 웹 애플리케이션을 만들 수 있어요. 앞으로도 Next.js와 React Query를 활용한 실전 캐싱 전략과 성능 최적화 팁을 계속 공유할 예정이니 기대해 주세요!