포스팅 목차
- 이 포스팅에서 다루는 것
- Server Component에서 fetch 사용 패턴
- 상황별 cache 옵션 선택 가이드
- fetch와 React Query - 역할을 어떻게 나누나?
- 실무에서 자주 하는 실수와 해결법
- 공부하면서 생겼던 의문점 (Q&A)
- 참고 자료
1. 이 포스팅에서 다루는 것
1탄에서는 Next.js가 fetch를 어떻게 확장했는지, Request Memoization과 Data Cache가 어떻게 동작하는지, 그리고 14버전과 15버전의 기본값 차이를 개념 위주로 정리했습니다.
2탄에서는 그 개념을 바탕으로 실제 코드에서 어떻게 패턴화해 쓰는지를 중심으로 정리합니다. Server Component에서 fetch를 어떻게 구조화하면 좋은지, React Query와 역할을 어떻게 나누는지, 실무에서 자주 마주치는 실수와 해결법을 다룹니다.
- 1탄 내용(캐싱 원리, 14 vs 15 차이)을 먼저 읽고 오면 이해가 더 수월합니다.
- Next.js App Router + TypeScript 기준으로 작성했습니다.
- 실무 코드를 익명화해 정리했습니다.
2. Server Component에서 fetch 사용 패턴
2-1. 기본 패턴 - 데이터 패칭 함수를 분리한다
Server Component 안에서 fetch를 직접 호출하기보다는, 데이터 패칭 로직을 별도 함수로 분리하는 편이 유지보수에 유리합니다. 같은 함수를 여러 컴포넌트에서 호출해도 Request Memoization이 중복 요청을 알아서 제거하므로 부담 없이 나눌 수 있습니다.
// lib/api/product.ts
// 데이터 패칭 함수를 별도 파일로 분리
type product = {
id: number;
name: string;
price: number;
};
type productdetail = product & {
description: string;
images: string[];
};
// 상품 목록 - 1시간마다 재검증 (자주 바뀌지 않음)
export async function getproductlist(): promise<product[]> {
const res = await fetch(`${process.env.backend_url}/products`, {
next: { revalidate: 3600, tags: ['products'] },
});
if (!res.ok) {
throw new error('상품 목록을 가져오는데 실패했습니다.');
}
return res.json();
}
// 상품 상세 - 동일하게 1시간 재검증, 태그는 개별 상품으로 구분
export async function getproductdetail(id: number): promise<productdetail> {
const res = await fetch(`${process.env.backend_url}/products/${id}`, {
next: { revalidate: 3600, tags: [`product-${id}`] },
});
if (!res.ok) {
throw new error('상품 정보를 가져오는데 실패했습니다.');
}
return res.json();
}
// 사용자 정보 - 캐시 없음 (사용자마다 다른 데이터)
export async function getmyprofile() {
const res = await fetch(`${process.env.backend_url}/members/me`, {
cache: 'no-store',
});
if (!res.ok) {
throw new error('사용자 정보를 가져오는데 실패했습니다.');
}
return res.json();
}
2-2. Server Component에서 호출
분리한 패칭 함수를 Server Component에서 직접
await로 호출합니다. 별도
useEffect나 로딩 상태 관리가 필요 없습니다.
// app/products/page.tsx
import { getproductlist } from '@/lib/api/product';
export default async function productspage() {
const products = await getproductlist();
return (
<ul>
{products.map((product) => (
<li key={product.id}>
{product.name} - {product.price.tolocalestring()}원
</li>
))}
</ul>
);
}
2-3. 여러 데이터를 병렬로 가져오기
독립적인 데이터를 순차적으로
await하면 불필요한 대기 시간이 생깁니다.
Promise.all로 병렬 처리하는 것이 좋습니다.
// app/products/[id]/page.tsx
import { getproductdetail } from '@/lib/api/product';
import { getcategorylist } from '@/lib/api/category';
type props = {
params: promise<{ id: string }>;
};
export default async function productdetailpage({ params }: props) {
const { id } = await params;
// 순차 처리 - 권장하지 않음 (총 대기시간 = a + b)
// const product = await getproductdetail(number(id));
// const categories = await getcategorylist();
// 병렬 처리 - 권장 (총 대기시간 = max(a, b))
const [product, categories] = await promise.all([
getproductdetail(number(id)),
getcategorylist(),
]);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
2-4. 에러 처리 - error.tsx와 함께 사용하기
Server Component에서 fetch가 실패해 에러를 던지면 Next.js의
error.tsx가 이를 잡아줍니다. 모든 컴포넌트마다 try-catch로 에러를 처리할 필요가 없습니다.
// app/products/error.tsx
// server component에서 throw된 에러를 자동으로 잡아줌
'use client';
type props = {
error: error;
reset: () => void;
};
export default function productserror({ error, reset }: props) {
return (
<div>
<p>{error.message}</p>
<button onclick={reset}>다시 시도</button>
</div>
);
}
3. 상황별 cache 옵션 선택 가이드
어떤 옵션을 써야 할지 헷갈릴 때 아래 기준으로 판단하면 됩니다.
이 데이터가 사용자마다 다른가?
│
└── yes → cache: 'no-store'
(프로필, 장바구니, 알림, 주문 내역 등)
│
└── no → 얼마나 자주 바뀌는가?
│
├── 거의 안 바뀜 (수개월 단위)
│ → cache: 'force-cache'
│ (약관, 정적 가이드, 앱 설정 등)
│
├── 주기적으로 바뀜 (분~시간 단위)
│ → next: { revalidate: n }
│ (상품 목록, 블로그 포스팅, 공지사항 등)
│
└── 관리자가 수정할 때 즉시 반영 필요
→ next: { revalidate: n, tags: ['태그명'] }
+ server action에서 revalidatetag 호출
3-1. 실무 데이터 유형별 권장 옵션 정리
| 데이터 유형 | 예시 | 권장 옵션 |
|---|---|---|
| 사용자 개인화 데이터 | 프로필, 장바구니, 주문 내역 |
cache: 'no-store'
|
| 실시간 데이터 | 재고 수량, 현재가 |
cache: 'no-store'
|
| 주기적 갱신 데이터 | 상품 목록, 공지사항 |
next: { revalidate: 3600 }
|
| 수동 갱신 필요 데이터 | CMS 콘텐츠, 관리자 수정 데이터 |
next: { tags: ['...'] }
+
revalidateTag
|
| 정적 데이터 | 카테고리, 약관, 앱 설정 |
cache: 'force-cache'
|
3-2. revalidateTag를 활용한 on-demand 갱신 패턴
// lib/api/product.ts
// 상품 데이터 패칭 - 태그 지정
export async function getproductlist() {
const res = await fetch(`${process.env.backend_url}/products`, {
next: { revalidate: 3600, tags: ['products'] },
});
return res.json();
}
// app/actions/product.ts
// 관리자가 상품 수정 시 호출하는 server action
'use server';
import { revalidatetag } from 'next/cache';
export async function updateproduct(id: number, data: formdata) {
await fetch(`${process.env.backend_url}/products/${id}`, {
method: 'patch',
body: data,
});
// 'products' 태그가 달린 캐시를 즉시 무효화
// → 다음 사용자 요청 시 새 데이터를 가져옴
revalidatetag('products');
revalidatetag(`product-${id}`);
}
4. fetch와 React Query - 역할을 어떻게 나누나?
4-1. 둘은 경쟁 관계가 아닙니다
App Router를 처음 접하면 "React Query를 계속 써도 되나요?" 하는 질문을 자주 합니다. 답부터 말하면 서로 맡는 영역이 다르니 함께 쓰는 게 자연스럽습니다.
| 구분 | Next.js fetch (Server Component) | React Query (Client Component) |
|---|---|---|
| 실행 환경 | 서버 | 브라우저 |
| 주요 역할 | 초기 데이터 렌더링, SEO, 서버 캐싱 | 사용자 인터랙션 후 데이터 갱신, 클라이언트 캐싱 |
| 캐싱 위치 | Next.js 서버 (Data Cache) | 브라우저 메모리 |
| 로딩 상태 관리 | Suspense / loading.tsx | isLoading, isFetching |
| 적합한 데이터 | 페이지 초기 렌더링 데이터 | 버튼 클릭, 검색, 무한 스크롤 등 |
4-2. 실무에서 자주 사용하는 패턴 - 초기 데이터는 서버에서, 이후 갱신은 클라이언트에서
Server Component에서 fetch로 초기 데이터를 가져온 뒤, 이를 React Query의
initialData로 넘기는 패턴입니다. 초기 로딩 없이 데이터가 바로 렌더링되고, 이후 사용자 인터랙션에 따른 갱신은 React Query가 맡습니다.
// app/products/page.tsx (server component)
import { getproductlist } from '@/lib/api/product';
import { productlistclient } from './_components/product-list-client';
export default async function productspage() {
// 서버에서 초기 데이터 패칭 (캐싱 적용)
const initialproducts = await getproductlist();
return (
<productlistclient initialdata={initialproducts} />
);
}
// app/products/_components/product-list-client.tsx (client component)
'use client';
import { usequery } from '@tanstack/react-query';
import { apifetch } from '@/lib/api-fetch';
type product = {
id: number;
name: string;
price: number;
};
type props = {
initialdata: product[];
};
export function productlistclient({ initialdata }: props) {
const { data: products } = usequery({
querykey: ['products'],
queryfn: () => apifetch<product[]>('/products'),
initialdata, // 서버에서 받은 초기 데이터로 세팅 → 첫 로딩 없음
staletime: 1000 * 60 * 5, // 5분간 fresh 상태 유지
});
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
4-3. 역할 분리 요약
server component (next.js fetch)
- 페이지 최초 진입 시 렌더링되는 데이터
- seo가 필요한 데이터
- 로그인 여부와 무관한 공용 데이터
- 예: 상품 목록, 블로그 포스팅, 카테고리
client component (react query)
- 사용자 인터랙션 후 갱신이 필요한 데이터
- 검색 필터, 정렬 변경, 무한 스크롤
- 사용자 개인화 데이터 (장바구니, 좋아요 상태)
- 폴링이 필요한 실시간성 데이터
- 예: 검색 결과, 찜 목록, 알림
5. 실무에서 자주 하는 실수와 해결법
실수 1. cache 옵션을 명시하지 않아 의도치 않은 동작이 발생한다
Next.js 15에서 cache 옵션을 생략하면 기본값인
auto no-cache로 동작합니다. 성능이 중요한 페이지에서 캐싱이 전혀 안 되고 있을 수 있습니다.
// 실수 - 옵션 미지정 (next.js 15에서 캐시 없음)
const res = await fetch('https://api.example.com/categories');
// 해결 - 의도를 명시적으로 표현
// 캐시를 원하는 경우
const res = await fetch('https://api.example.com/categories', {
cache: 'force-cache',
});
// 캐시를 원하지 않는 경우도 명시
const res = await fetch('https://api.example.com/me/profile', {
cache: 'no-store',
});
실수 2. 사용자 개인화 데이터에 캐시를 적용한다
Data Cache는 서버 전역 캐시입니다.
force-cache를 사용자별 데이터에 적용하면, A 사용자의 데이터가 B 사용자에게 보일 수 있습니다.
// 심각한 실수 - 사용자 개인화 데이터에 force-cache 적용
// a 사용자의 장바구니 데이터가 b 사용자에게 노출될 수 있음
const res = await fetch(`${process.env.backend_url}/me/cart`, {
cache: 'force-cache', // 절대 사용 금지
});
// 해결 - 사용자 개인화 데이터는 반드시 no-store
const res = await fetch(`${process.env.backend_url}/me/cart`, {
cache: 'no-store',
});
실수 3. 데이터를 순차적으로 fetch하여 불필요한 대기가 발생한다
서로 의존성이 없는 데이터를 순차적으로 fetch하면 응답 시간이 누적됩니다.
// 실수 - 순차 fetch (총 대기시간 = 300ms + 200ms = 500ms)
const products = await getproductlist(); // 300ms
const banners = await getbannerlist(); // 200ms
// 해결 - 병렬 fetch (총 대기시간 = max(300ms, 200ms) = 300ms)
const [products, banners] = await promise.all([
getproductlist(), // 300ms
getbannerlist(), // 200ms
]);
실수 4. 상대 경로로 Server Component에서 fetch를 호출한다
Server Component는 서버에서 실행됩니다. 상대 경로(/api/...)는 브라우저 환경에서만 의미가 있고, 서버에서는 호스트 정보가 없어 오류가 발생합니다.
// 실수 - server component에서 상대 경로 사용
const res = await fetch('/api/products'); // 오류 발생
// 해결 - 절대 경로 사용 (환경변수로 관리)
const res = await fetch(`${process.env.backend_url}/products`);
Route Handler 내부 fetch도 동일합니다
Route Handler 안에서 같은 Next.js 앱의 다른 Route Handler를 fetch로 호출할 때도 절대 경로가 필요합니다. 다만 이런 패턴은 네트워크 홉이 한 번 더 생기므로 로직을 직접 호출하는 편이 낫습니다.
실수 5. revalidate와 no-store를 함께 사용한다
// 실수 - 충돌하는 옵션 조합 (두 옵션 모두 무시됨)
const res = await fetch('https://api.example.com/products', {
cache: 'no-store',
next: { revalidate: 3600 }, // 경고 발생, 무시됨
});
// 해결 - 목적에 따라 하나만 선택
// 캐시 없이 항상 새로 가져오려면
const res1 = await fetch('https://api.example.com/products', {
cache: 'no-store',
});
// 1시간 캐시를 원하면
const res2 = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 },
});
6. 공부하면서 생겼던 의문점 (Q&A)
Q. 서버 컴포넌트에서 fetch 에러가 나면 전체 페이지가 터지나요?
A.
error.tsx를 배치한 범위 내에서는 해당 범위만 에러 UI로 교체됩니다. 전체 페이지가 터지는 것을 방지하려면
Suspense와
error.tsx를 적절히 조합하거나, 패칭 함수 내부에서 try-catch로 fallback 데이터를 반환하도록 처리하면 됩니다.
Q. generateStaticParams에서 fetch를 사용하면 캐싱이 되나요?
A. 됩니다.
generateStaticParams
내부의 fetch도 Request Memoization이 적용됩니다. 같은 URL을 여러 번 호출해도 실제 요청은 한 번만 발생합니다.
Q. React Query의 prefetchQuery와 서버 fetch 중 어떤 걸 써야 하나요?
A. 두 방법 모두 초기 데이터를 서버에서 가져와 클라이언트에 넘기는 목적으로 사용합니다.
prefetchQuery는 React Query의 HydrationBoundary와 함께 사용하는 방식으로, 클라이언트에서 즉시 React Query 캐시로 hydration됩니다. 반면 Server Component fetch +
initialData
방식은 구조가 단순합니다. 이미 React Query를 적극적으로 사용 중이라면
prefetchQuery
패턴이 일관성 측면에서 유리합니다.
Q. next.revalidate 값은 정확히 N초마다 갱신되나요?
A. 정확하지 않습니다.
revalidate: 3600은 "3600초 이후 첫 번째 요청이 들어오면 그때 재검증을 트리거한다"는 의미입니다. 그 첫 요청에는 여전히 오래된 캐시가 반환되고, 백그라운드에서 새 데이터를 가져온 뒤 다음 요청부터 갱신된 데이터가 제공됩니다. 이를 stale-while-revalidate 전략이라고 합니다.
7. 마무리
1탄에서 개념을 잡고 2탄에서 실무 패턴까지 살펴봤습니다. Next.js에서 fetch를 잘 쓴다는 건 단순히 API를 호출하는 데서 그치지 않고, 어떤 데이터를 언제 캐싱하고 언제 갱신할지를 분명하게 설계하는 일입니다.
핵심을 정리하면 다음과 같습니다.
cache 옵션은 항상 명시한다 → 사용자 개인화 데이터에는 절대 force-cache를 쓰지 않는다 → 독립적인 fetch는 Promise.all로 병렬 처리한다 → Server Component는 초기 렌더링, React Query는 인터랙션 이후 갱신을 담당한다
Next.js App Router에서 fetch를 다룰 때 Nextjs fetch 관련 1탄, 2탄 이 두 포스팅을 참고하시면 좋을 것 같습니다.
댓글