Next.js에 fetch가 있는데 React Query가 필요한가요?
안녕하세요! 포테코입니다.
Next.js 13+ App Router에서는 서버 컴포넌트에서 fetch를 사용해서 데이터를 가져올 수 있어요. 그렇다면 클라이언트 사이드에서 React Query(TanStack Query)를 사용해야 할까요? 이번 글에서는 React Query의 장점과 Next.js와 함께 사용할 때의 이점을 실무 관점에서 다뤄볼게요.
Next.js의 fetch vs React Query
Next.js의 fetch
Next.js는 기본적으로 fetch를 확장해서 자동 캐싱과 재검증을 제공해요.
// 서버 컴포넌트에서
export default async function Page() {
const data = await fetch("https://api.example.com/data", {
next: { revalidate: 3600 }, // 1시간마다 재검증
});
return <div>{/* ... */}</div>;
}Next.js fetch의 특징:
- 서버 컴포넌트에서 자동 캐싱
revalidate옵션으로 재검증 주기 설정- Request Memoization으로 중복 요청 자동 제거
React Query의 역할
React Query는 클라이언트 사이드 데이터 페칭을 위한 강력한 라이브러리예요.
Next.js의 fetch는 서버에서만 작동하는 반면, React Query는 클라이언트에서 동적 데이터 페칭, 캐싱, 동기화를 제공합니다. 서로 다른 목적을 가지고 있어 함께 사용하면 더욱 강력합니다.
React Query를 사용해야 하는 이유
1. 클라이언트 사이드 캐싱
서버 컴포넌트의 fetch는 서버에서만 캐싱되지만, React Query는 브라우저에서도 캐싱해서 불필요한 네트워크 요청을 방지해요.
// ❌ fetch만 사용: 매번 새로운 요청
"use client";
export function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch("/api/user")
.then((res) => res.json())
.then(setUser);
}, []);
return <div>{user?.name}</div>;
}
// ✅ React Query 사용: 자동 캐싱
"use client";
import { useQuery } from "@tanstack/react-query";
export function UserProfile() {
const { data: user } = useQuery({
queryKey: ["user"],
queryFn: () => fetch("/api/user").then((res) => res.json()),
staleTime: 5 * 60 * 1000, // 5분간 캐시 유지
});
return <div>{user?.name}</div>;
}2. 자동 백그라운드 동기화
React Query는 백그라운드에서 자동으로 데이터를 동기화해서 항상 최신 데이터를 유지해요.
"use client";
import { useQuery } from "@tanstack/react-query";
export function ProductList() {
const { data: products } = useQuery({
queryKey: ["products"],
queryFn: () => fetch("/api/products").then((res) => res.json()),
refetchOnWindowFocus: true, // 창 포커스 시 자동 재요청
refetchOnReconnect: true, // 네트워크 재연결 시 자동 재요청
refetchInterval: 30000, // 30초마다 자동 재요청
});
return (
<ul>
{products?.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}3. 낙관적 업데이트 (Optimistic Updates)
서버 응답을 기다리지 않고 UI를 먼저 업데이트해서 더 나은 사용자 경험을 제공해요.
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
export function TodoItem({ todo }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (completed: boolean) =>
fetch(`/api/todos/${todo.id}`, {
method: "PATCH",
body: JSON.stringify({ completed }),
}),
// 낙관적 업데이트
onMutate: async (completed) => {
// 진행 중인 쿼리 취소
await queryClient.cancelQueries({ queryKey: ["todos"] });
// 이전 값 저장
const previousTodos = queryClient.getQueryData(["todos"]);
// 낙관적으로 UI 업데이트
queryClient.setQueryData(["todos"], (old: any) =>
old.map((t: any) =>
t.id === todo.id ? { ...t, completed } : t
)
);
return { previousTodos };
},
// 에러 발생 시 롤백
onError: (err, variables, context) => {
queryClient.setQueryData(["todos"], context?.previousTodos);
},
// 성공 시 서버 데이터로 동기화
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return (
<input
type="checkbox"
checked={todo.completed}
onChange={(e) => mutation.mutate(e.target.checked)}
/>
);
}4. 중복 요청 제거 (Request Deduplication)
같은 쿼리가 여러 컴포넌트에서 동시에 호출되면 하나의 요청만 실행하고 결과를 공유해요.
// 여러 컴포넌트에서 동시에 호출
function ComponentA() {
const { data } = useQuery({ queryKey: ["user"], queryFn: fetchUser });
// ...
}
function ComponentB() {
const { data } = useQuery({ queryKey: ["user"], queryFn: fetchUser });
// ...
}
function ComponentC() {
const { data } = useQuery({ queryKey: ["user"], queryFn: fetchUser });
// ...
}
// React Query는 하나의 요청만 실행하고 결과를 공유합니다!5. 로딩 및 에러 상태 관리
로딩, 에러, 성공 상태를 자동으로 관리해서 보일러플레이트 코드를 줄일 수 있어요.
"use client";
import { useQuery } from "@tanstack/react-query";
export function UserProfile() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ["user"],
queryFn: () => fetch("/api/user").then((res) => res.json()),
});
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
return <div>{data.name}</div>;
}Next.js와 React Query 함께 사용하기
서버 컴포넌트 + React Query 패턴
서버 컴포넌트에서 초기 데이터를 가져오고, 클라이언트에서 React Query로 동적 업데이트를 처리할 수 있어요.
// app/products/page.tsx (서버 컴포넌트)
import { getProducts } from "@/lib/api";
import { ProductsList } from "@/components/products/ProductsList";
export default async function ProductsPage() {
// 서버에서 초기 데이터 페칭
const initialProducts = await getProducts();
return (
<div>
<h1>Products</h1>
{/* 클라이언트 컴포넌트에 초기 데이터 전달 */}
<ProductsList initialProducts={initialProducts} />
</div>
);
}// components/products/ProductsList.tsx (클라이언트 컴포넌트)
"use client";
import { useQuery } from "@tanstack/react-query";
import { getProducts } from "@/lib/api";
interface ProductsListProps {
initialProducts: Product[];
}
export function ProductsList({ initialProducts }: ProductsListProps) {
// 초기 데이터를 사용하여 즉시 렌더링
const { data: products = initialProducts } = useQuery({
queryKey: ["products"],
queryFn: getProducts,
initialData: initialProducts, // 초기 데이터 설정
staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
});
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}Hydration 패턴
서버에서 렌더링된 HTML을 클라이언트에서 React Query로 하이드레이션할 수 있어요.
// app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}성능 최적화
1. 쿼리 키 전략
쿼리 키를 체계적으로 관리해서 캐시 효율을 높일 수 있어요.
// ✅ 좋은 예: 계층적 쿼리 키
const queryKeys = {
users: {
all: ["users"] as const,
lists: () => [...queryKeys.users.all, "list"] as const,
list: (filters: string) => [...queryKeys.users.lists(), filters] as const,
details: () => [...queryKeys.users.all, "detail"] as const,
detail: (id: number) => [...queryKeys.users.details(), id] as const,
},
};
// 사용
useQuery({
queryKey: queryKeys.users.list(filters),
queryFn: () => fetchUsers(filters),
});
// 관련 쿼리 일괄 무효화
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });2. Prefetching
필요한 데이터를 미리 가져와서 사용자 경험을 향상시킬 수 있어요.
// app/products/[id]/page.tsx
import { QueryClient, dehydrate } 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>
);
}3. Infinite Queries
무한 스크롤을 쉽게 구현할 수 있어요.
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
export function InfiniteProductList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["products", "infinite"],
queryFn: ({ pageParam = 0 }) =>
fetchProducts({ page: pageParam, limit: 20 }),
getNextPageParam: (lastPage, pages) =>
lastPage.hasNext ? pages.length : undefined,
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.products.map((product) => (
<div key={product.id}>{product.name}</div>
))}
</div>
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? "Loading..." : "Load More"}
</button>
)}
</div>
);
}실전 예제: Next.js + React Query 통합
1. QueryClient 설정
// lib/react-query.ts
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분
gcTime: 5 * 60 * 1000, // 5분 (이전 cacheTime)
refetchOnWindowFocus: false,
retry: 1,
},
},
});2. API 함수 정의
// lib/api/products.ts
export async function getProducts(filters?: ProductFilters) {
const params = new URLSearchParams();
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
if (value) params.append(key, value);
});
}
const res = await fetch(`/api/products?${params}`);
if (!res.ok) throw new Error("Failed to fetch products");
return res.json();
}
export async function getProduct(id: string) {
const res = await fetch(`/api/products/${id}`);
if (!res.ok) throw new Error("Failed to fetch product");
return res.json();
}3. 커스텀 훅 생성
// hooks/useProducts.ts
import { useQuery } from "@tanstack/react-query";
import { getProducts } from "@/lib/api/products";
export function useProducts(filters?: ProductFilters) {
return useQuery({
queryKey: ["products", filters],
queryFn: () => getProducts(filters),
staleTime: 5 * 60 * 1000, // 5분
});
}
export function useProduct(id: string) {
return useQuery({
queryKey: ["product", id],
queryFn: () => getProduct(id),
enabled: !!id, // id가 있을 때만 실행
});
}4. 컴포넌트에서 사용
// components/products/ProductList.tsx
"use client";
import { useProducts } from "@/hooks/useProducts";
import { useState } from "react";
export function ProductList() {
const [filters, setFilters] = useState({});
const { data: products, isLoading, error } = useProducts(filters);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<input
placeholder="Search..."
onChange={(e) => setFilters({ search: e.target.value })}
/>
<ul>
{products?.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
);
}Next.js fetch와 React Query 비교
| 기능 | Next.js fetch | React Query |
|---|---|---|
| 서버 캐싱 | ✅ 자동 | ❌ 클라이언트 전용 |
| 클라이언트 캐싱 | ❌ | ✅ 자동 |
| 백그라운드 동기화 | ❌ | ✅ 자동 |
| 낙관적 업데이트 | ❌ | ✅ 지원 |
| 중복 요청 제거 | ✅ (서버) | ✅ (클라이언트) |
| 로딩/에러 상태 | 수동 처리 | ✅ 자동 |
| 무한 스크롤 | ❌ | ✅ 지원 |
| Prefetching | ✅ | ✅ |
Next.js의 fetch와 React Query는 서로 다른 목적을 가지고 있습니다. 서버 컴포넌트에서는 fetch를, 클라이언트 컴포넌트에서는 React Query를 사용하는 것이 최적의 조합입니다.
베스트 프랙티스
1. 서버와 클라이언트 역할 분리
// ✅ 좋은 예
// 서버: 초기 데이터 페칭
export default async function Page() {
const data = await fetch("...");
return <ClientComponent initialData={data} />;
}
// 클라이언트: 동적 업데이트
"use client";
export function ClientComponent({ initialData }) {
const { data } = useQuery({
queryKey: ["data"],
queryFn: fetchData,
initialData,
});
// ...
}2. 쿼리 키 일관성 유지
// ✅ 좋은 예: 중앙 관리
export const queryKeys = {
users: ["users"] as const,
user: (id: string) => ["users", id] as const,
products: (filters?: Filters) => ["products", filters] as const,
};
// 사용
useQuery({ queryKey: queryKeys.user(id), ... });3. 에러 바운더리 활용
"use client";
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div>
<h2>Something went wrong:</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
export function App() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<ProductList />
</ErrorBoundary>
);
}마무리
React Query는 Next.js의 fetch를 대체하는 것이 아니라 보완하는 역할을 해요:
- 서버 컴포넌트: Next.js fetch로 초기 데이터 페칭
- 클라이언트 컴포넌트: React Query로 동적 업데이트 및 캐싱
이 조합을 통해 최고의 성능과 사용자 경험을 제공할 수 있어요.
Next.js의 서버 사이드 렌더링과 React Query의 클라이언트 사이드 캐싱을 결합하면, 빠른 초기 로딩과 부드러운 인터랙션을 동시에 달성할 수 있어요.
Next.js와 React Query를 함께 활용하면 더 효율적이고 사용자 친화적인 웹 애플리케이션을 만들 수 있어요. 앞으로도 Next.js와 React Query를 활용한 실전 사례와 팁을 계속 공유할 예정이니 기대해 주세요!
