12.5 데이터 페칭 — fetch 확장, cache/revalidate, 서버 컴포넌트 DB 직접 조회
Next.js App Router는 fetch API를 확장하여 캐싱·재검증·메모이제이션을 내장했습니다.
서버 컴포넌트에서 데이터베이스를 직접 조회하거나, 외부 API를 호출하거나, 여러 전략을 조합할 수 있습니다.
Next.js의 fetch 확장
기본 개념
Next.js 15의 fetch는 Web Fetch API를 확장하여 세 가지 캐시 옵션을 제공합니다.
| 옵션 | 동작 | 비유 |
|---|---|---|
cache: 'force-cache' | 한 번 요청 후 영구 캐시 (SSG) | 인쇄된 신문 |
cache: 'no-store' | 매 요청마다 새로 fetch (SSR) | 실시간 속보 |
next: { revalidate: N } | N초마다 재검증 (ISR) | 주기적 뉴스 업데이트 |
// app/products/page.tsx
// ✅ 정적 생성 (SSG) — 빌드 시 한 번만 fetch
async function getStaticProducts() {
const res = await fetch('https://api.example.com/products', {
cache: 'force-cache',
});
return res.json();
}
// ✅ 서버 사이드 렌더링 (SSR) — 요청마다 새로 fetch
async function getDynamicProducts() {
const res = await fetch('https://api.example.com/products', {
cache: 'no-store',
});
return res.json();
}
// ✅ 증분 정적 재생성 (ISR) — 60초마다 재검증
async function getRevalidatedProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 60 },
});
return res.json();
}
Next.js 15의 변경 사항
Next.js 15부터 fetch의 기본 캐시 동작이 변경되었습니다.
// Next.js 14 이하: 기본값 force-cache (SSG)
// Next.js 15+: 기본값 no-store (SSR로 변경됨!)
// Next.js 15에서 명시적으로 캐시 설정 권장
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache', // SSG 원할 때 명시
});
기본 예제 — 서버 컴포넌트에서 데이터 페칭
단순 fetch 예제
// app/posts/page.tsx
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
async function getPosts(): Promise<Post[]> {
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
next: { revalidate: 3600 }, // 1시간마다 재검증
});
if (!res.ok) {
throw new Error('Failed to fetch posts');
}
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<main>
<h1>게시글 목록</h1>
<ul>
{posts.slice(0, 10).map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.body}</p>
</li>
))}
</ul>
</main>
);
}
동적 라우트 + 데이터 페칭
// app/posts/[id]/page.tsx
interface Post {
id: number;
title: string;
body: string;
}
async function getPost(id: string): Promise<Post> {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
throw new Error('Post not found');
}
return res.json();
}
// 정적 경로 생성 (SSG)
export async function generateStaticParams() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
const posts: Post[] = await res.json();
return posts.slice(0, 10).map((post) => ({
id: String(post.id),
}));
}
export default async function PostDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params; // Next.js 15에서 params는 Promise
const post = await getPost(id);
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
서버 컴포넌트에서 DB 직접 조회
서버 컴포넌트는 Node.js 환경에서 실행되므로 데이터베이스를 직접 조회할 수 있습니다. API 레이어 없이 DB 결과를 바로 렌더링할 수 있어 네트워크 왕복이 줄어듭니다.
Prisma + PostgreSQL 예제
// lib/db.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const db =
globalForPrisma.prisma ||
new PrismaClient({
log: ['query'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
}
// app/dashboard/page.tsx
import { db } from '@/lib/db';
// 서버 컴포넌트에서 DB 직접 조회
export default async function DashboardPage() {
// API fetch 없이 DB 직접 쿼리
const users = await db.user.findMany({
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 10,
});
const totalCount = await db.user.count();
return (
<div>
<h1>대시보드</h1>
<p>전체 사용자: {totalCount}명</p>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} — {user.email}
</li>
))}
</ul>
</div>
);
}
병렬 DB 조회로 성능 최적화
// app/dashboard/stats/page.tsx
import { db } from '@/lib/db';
export default async function StatsPage() {
// ❌ 순차적 조회 — 느림
// const users = await db.user.count();
// const posts = await db.post.count();
// const comments = await db.comment.count();
// ✅ 병렬 조회 — 빠름
const [userCount, postCount, commentCount, recentPosts] = await Promise.all([
db.user.count(),
db.post.count(),
db.comment.count(),
db.post.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
include: { author: { select: { name: true } } },
}),
]);
return (
<div>
<h1>통계</h1>
<dl>
<dt>사용자</dt><dd>{userCount}</dd>
<dt>게시글</dt><dd>{postCount}</dd>
<dt>댓글</dt><dd>{commentCount}</dd>
</dl>
<h2>최근 게시글</h2>
<ul>
{recentPosts.map((post) => (
<li key={post.id}>
{post.title} — {post.author.name}
</li>
))}
</ul>
</div>
);
}
실전 예제 — 전체 데이터 페칭 패턴
로딩 UI와 Suspense 연동
// app/products/loading.tsx ← 자동으로 Suspense fallback 역할
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded mb-4" />
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-32 bg-gray-200 rounded mb-2" />
))}
</div>
);
}
// app/products/page.tsx
import { Suspense } from 'react';
import ProductList from '@/components/ProductList';
import ProductSkeleton from '@/components/ProductSkeleton';
// 개별 컴포넌트에 Suspense 적용으로 독립적 스트리밍
export default function ProductsPage() {
return (
<main>
<h1>상품 목록</h1>
<Suspense fallback={<ProductSkeleton />}>
<ProductList />
</Suspense>
</main>
);
}
// components/ProductList.tsx
interface Product {
id: number;
title: string;
price: number;
category: string;
image: string;
}
async function getProducts(): Promise<Product[]> {
const res = await fetch('https://fakestoreapi.com/products', {
next: { revalidate: 300 }, // 5분마다 재검증
});
if (!res.ok) throw new Error('상품 목록을 불러오지 못했습니다.');
return res.json();
}
export default async function ProductList() {
const products = await getProducts();
return (
<ul className="grid grid-cols-3 gap-4">
{products.map((product) => (
<li key={product.id} className="border rounded p-4">
<img src={product.image} alt={product.title} className="h-48 object-contain" />
<h3 className="font-bold mt-2">{product.title}</h3>
<p className="text-blue-600">${product.price}</p>
</li>
))}
</ul>
);
}
에러 처리 — error.tsx
// app/products/error.tsx
'use client'; // 에러 바운더리는 클라이언트 컴포넌트
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// 에러 모니터링 서비스에 로그 전송
console.error('Products page error:', error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center min-h-64">
<h2 className="text-xl font-bold mb-4">상품 목록을 불러오지 못했습니다</h2>
<p className="text-gray-500 mb-4">{error.message}</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
다시 시도
</button>
</div>
);
}
태그 기반 캐시 무효화
// app/actions/revalidate.ts
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';
// 특정 태그의 캐시를 무효화
export async function revalidateProducts() {
revalidateTag('products'); // 'products' 태그를 가진 모든 캐시 무효화
}
// 특정 경로 무효화
export async function revalidateProductPage(id: string) {
revalidatePath(`/products/${id}`);
}
// 태그와 함께 fetch
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: {
revalidate: 3600,
tags: ['products'], // 태그 지정
},
});
return res.json();
}
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: {
revalidate: 3600,
tags: ['products', `product-${id}`], // 복수 태그
},
});
return res.json();
}
요청 메모이제이션 (Request Memoization)
같은 렌더 트리 안에서 동일한 URL+옵션으로 fetch를 여러 번 호출해도 한 번만 네트워크 요청이 발생합니다.
// 레이아웃과 페이지에서 같은 fetch → 자동 중복 제거
// app/layout.tsx
async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`, {
cache: 'no-store',
});
return res.json();
}
export default async function Layout({ children }: { children: React.ReactNode }) {
const user = await getUser('me'); // 1번 요청
return (
<div>
<nav>안녕하세요, {user.name}</nav>
{children}
</div>
);
}
// app/profile/page.tsx
async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`, {
cache: 'no-store',
});
return res.json();
}
export default async function ProfilePage() {
const user = await getUser('me'); // 실제로는 추가 요청 없음 — 메모이제이션!
return <p>{user.email}</p>;
}
cache() 함수로 DB 조회 메모이제이션
fetch를 사용하지 않는 DB 직접 조회는 React의 cache() 함수로 메모이제이션합니다.
// lib/queries.ts
import { cache } from 'react';
import { db } from '@/lib/db';
// cache()로 감싸면 같은 렌더 트리에서 중복 조회 방지
export const getUser = cache(async (id: string) => {
return db.user.findUnique({
where: { id },
include: { profile: true },
});
});
export const getPostsByUser = cache(async (userId: string) => {
return db.post.findMany({
where: { authorId: userId },
orderBy: { createdAt: 'desc' },
});
});
// app/users/[id]/page.tsx
import { getUser, getPostsByUser } from '@/lib/queries';
export default async function UserPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// 병렬 조회 — getUser는 cache()로 메모이제이션됨
const [user, posts] = await Promise.all([
getUser(id),
getPostsByUser(id),
]);
if (!user) return <p>사용자를 찾을 수 없습니다.</p>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<h2>게시글 ({posts.length})</h2>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
스트리밍 SSR과 순차적 데이터 페칭
독립적인 컴포넌트 스트리밍
// app/dashboard/page.tsx
import { Suspense } from 'react';
// 각 컴포넌트가 독립적으로 데이터를 fetch하고 스트리밍됨
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<div>통계 로딩...</div>}>
<StatsCard />
</Suspense>
<Suspense fallback={<div>최근 주문 로딩...</div>}>
<RecentOrders />
</Suspense>
<Suspense fallback={<div>인기 상품 로딩...</div>}>
<TopProducts />
</Suspense>
<Suspense fallback={<div>사용자 활동 로딩...</div>}>
<UserActivity />
</Suspense>
</div>
);
}
// 각 컴포넌트는 독립적으로 데이터 fetch
async function StatsCard() {
// 느린 API를 시뮬레이션
const stats = await fetch('https://api.example.com/stats', {
cache: 'no-store',
}).then((r) => r.json());
return (
<div className="border rounded p-4">
<h3>통계</h3>
<p>방문자: {stats.visitors}</p>
<p>매출: {stats.revenue}</p>
</div>
);
}
순차 페칭이 필요한 경우
// app/checkout/page.tsx
// 사용자 정보 → 장바구니 → 추천 상품 순으로 의존성이 있는 경우
async function CheckoutPage() {
// 1단계: 사용자 인증 확인
const user = await getUser();
if (!user) redirect('/login');
// 2단계: 사용자의 장바구니 조회 (userId 필요)
const cart = await getCart(user.id);
// 3단계는 Suspense로 분리 — 병렬 처리 가능
return (
<div>
<h1>결제</h1>
<CartSummary cart={cart} />
<Suspense fallback={<div>추천 상품 로딩...</div>}>
<RecommendedItems userId={user.id} />
</Suspense>
</div>
);
}
// 서버 컴포넌트 Props로 userId 전달
async function RecommendedItems({ userId }: { userId: string }) {
const items = await getRecommendations(userId);
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
고수 팁
1. unstable_cache로 함수 단위 캐싱
fetch를 사용하지 않는 커스텀 데이터 소스(DB, SDK 등)도 캐시 가능합니다.
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
// 함수 결과를 캐시 — 1시간 후 재검증, 'products' 태그
export const getCachedProducts = unstable_cache(
async () => {
return db.product.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
});
},
['products-list'], // 캐시 키
{
revalidate: 3600, // 1시간
tags: ['products'], // 무효화 태그
}
);
// 사용
export default async function ProductsPage() {
const products = await getCachedProducts(); // 캐시에서 반환
return <ProductGrid products={products} />;
}
2. 조건부 캐시 전략
// 환경 또는 사용자 역할에 따라 캐시 전략 분기
async function getPageData(isAdmin: boolean) {
const cacheOption = isAdmin
? { cache: 'no-store' as const } // 관리자: 항상 최신
: { next: { revalidate: 300 } }; // 일반: 5분 캐시
const res = await fetch('https://api.example.com/data', cacheOption);
return res.json();
}
3. Zod로 API 응답 타입 검증
import { z } from 'zod';
const PostSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
userId: z.number(),
});
const PostsSchema = z.array(PostSchema);
async function getPosts() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
next: { revalidate: 3600 },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw = await res.json();
const posts = PostsSchema.parse(raw); // 런타임 타입 검증
return posts;
}
4. 데이터 레이어 분리 패턴
// lib/services/product.service.ts
import { cache } from 'react';
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
// 짧은 캐시: 실시간에 가까운 데이터
export const getProductById = cache(async (id: string) => {
return db.product.findUnique({ where: { id } });
});
// 긴 캐시: 자주 바뀌지 않는 데이터
export const getCategories = unstable_cache(
async () => db.category.findMany({ orderBy: { name: 'asc' } }),
['categories'],
{ revalidate: 86400, tags: ['categories'] } // 24시간
);
// 캐시 없음: 항상 최신 필요
export async function getUserOrders(userId: string) {
return db.order.findMany({
where: { userId },
include: { items: true },
orderBy: { createdAt: 'desc' },
});
}
5. 캐시 계층 이해
요청 → Edge Cache (CDN) → Next.js Full Route Cache
→ React Server Component Cache (메모이제이션)
→ Data Cache (fetch/unstable_cache)
→ Origin Server / DB
- Full Route Cache: HTML + RSC Payload 전체를 캐시 (정적 라우트)
- Data Cache: 개별
fetch응답 캐시 (영구, 명시적 재검증 필요) - Request Memoization: 단일 렌더 트리 내 중복 제거 (요청 완료 시 소멸)
6. 개발 환경 캐시 비활성화
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// 개발 환경에서는 캐시를 끄고 항상 최신 데이터 확인
experimental: {
staleTimes: {
dynamic: 0, // 동적 라우트: 캐시 없음
static: 300, // 정적 라우트: 5분
},
},
};
export default nextConfig;