서버 컴포넌트에서 데이터 페칭
서버 컴포넌트에서 fetch를 직접 호출하고 캐시·재검증·동적 params·React.cache로 요청 중복을 제어합니다.
Next.js 16 App Router의 가장 큰 변화 중 하나는 서버 컴포넌트(Server Components)의 도입입니다.
이 패러다임은 데이터 페칭 방식을 근본적으로 바꿨고, 애플리케이션 성능과 개발 경험을 함께 끌어올렸습니다.
이전에는 클라이언트의 useEffect나 getServerSideProps로 데이터를 가져왔다면,
이제는 더 직관적인 흐름으로 데이터를 다룰 수 있습니다.
이 절에서는 서버 컴포넌트에서 데이터를 페칭하는 핵심 원리, 구체적인 방법, 그리고 그로 인해 얻을 수 있는 이점들을 자세히 살펴보겠습니다.
먼저 데이터 요청 로직은 서버에 남고, 서버가 만든 렌더 결과가 HTML과 RSC Payload로 브라우저에 전달되는 흐름을 확인합니다.
서버 컴포넌트에서의 데이터 페칭 기본 원리
App Router의 모든 컴포넌트(페이지, 레이아웃 등)는 기본적으로 서버 컴포넌트입니다.
서버 컴포넌트는 클라이언트(브라우저)가 아닌 서버 환경에서 렌더링되고 실행됩니다.
이 특성 덕분에 데이터 페칭이 매우 효율적이고 안전해집니다.
이 절의 API 예시는 외부 네트워크 변동을 줄이기 위해 로컬 Mock 서버(json-server, http://localhost:4000) 기준으로 통일합니다.
포트 정책은 4000=Mock API, 4100=별도 백엔드/실시간 서버로 분리해 두면 트랙 간 실행 충돌을 줄일 수 있습니다.
npx json-server --watch db.json --port 4000db.json에는 users, posts, todos 리소스를 준비하면 6장 예제를 안정적으로 재현할 수 있습니다.
async/await지원: 서버 컴포넌트는 비동기 함수로 작성될 수 있으며,async/await문법을 사용하여 데이터를 직접 페칭할 수 있습니다. 이는 마치 백엔드 코드처럼 데이터베이스 쿼리나 API 호출을 작성할 수 있음을 의미합니다.- 서버에서 직접 실행: 데이터 페칭 로직이 클라이언트 번들에 포함되지 않고 서버에서 직접 실행됩니다. 따라서 API 키와 같은 민감한 정보가 클라이언트에 노출될 위험이 없습니다.
- 워터폴(Waterfall) 직접 제어: 서로 독립적인 요청은 먼저 시작한 뒤
Promise.all로 기다리고, 서로 다른 UI 구간은 필요에 따라Suspense경계로 나눕니다. 순서대로await한 요청을 Next.js가 자동으로 병렬화하지는 않습니다. - 렌더 중 중복 제거와 요청 간 캐시 구분: 같은 서버 렌더 안의 동일한
GET요청은 메모이제이션될 수 있지만, 다음 사용자 요청까지 응답을 재사용하는 Data Cache와는 다른 기능입니다. Next.js 15 이후 요청 간 캐시는cache: 'force-cache'처럼 명시합니다.
fetch API를 사용한 데이터 페칭
서버 컴포넌트에서 데이터를 페칭하는 가장 일반적이고 권장되는 방법은 네이티브 fetch API를 사용하는 것입니다.
Next.js는 이 fetch 함수를 자동으로 확장하여 캐싱, 재검증(revalidation) 등을 추가합니다.
interface User {
id: number;
firstName: string;
lastName: string;
email: string;
}
// 이 함수는 서버에서 실행됩니다.
async function getUsers(): Promise<User[]> {
// fetch API를 사용하여 로컬 mock API에서 사용자 데이터를 가져옵니다.
const res = await fetch('http://localhost:4000/users?_limit=20');
// 응답이 성공적이지 않으면 에러를 던집니다.
if (!res.ok) {
// throw new Error('Failed to fetch users'); // 실제 서비스에서는 더 구체적인 에러 처리 필요
return []; // 예시를 위해 빈 배열 반환
}
// JSON 형태로 파싱하여 반환합니다.
const payload = await res.json();
return payload;
}
// 페이지 컴포넌트를 async 함수로 정의합니다.
export default async function UsersPage() {
const users = await getUsers(); // 서버에서 데이터를 비동기적으로 가져옵니다.
return (
<div>
<h1>사용자 목록</h1>
{users.length > 0 ? (
<ul>
{users.map((user) => (
<li key={user.id} style={{ marginBottom: '10px' }}>
<strong>{user.firstName} {user.lastName}</strong> ({user.email})
</li>
))}
</ul>
) : (
<p>사용자 데이터를 불러오는 데 실패했거나 데이터가 없습니다.</p>
)}
</div>
);
}실습:
src/app/users 폴더를 만들고 그 안에 page.tsx 파일을 위 내용으로 생성합니다.
개발 서버가 실행 중이라면 (npm run dev), http://localhost:3000/users로 접속해 페이지가 로드될 때 사용자 목록이 즉시 보이는지 확인해 보세요.
클라이언트 측 JavaScript 로드를 기다릴 필요 없이, 서버에서 모든 데이터가 페칭되고 HTML로 변환되어 전달됩니다.
fetch 옵션을 사용한 캐싱 및 재검증 전략
Next.js의 fetch 확장은 캐싱 동작을 세밀하게 제어할 수 있는 다양한 옵션을 제공합니다.
이는 애플리케이션의 성능과 데이터 신선도(freshness)를 최적화하는 데 매우 중요합니다.
캐싱 전략 (cache 옵션)
-
'force-cache'(명시적 캐시): 요청된 데이터를 Data Cache에 저장하고, 이후 요청에서 가능한 경우 캐시된 응답을 사용합니다.캐시된 데이터가 없으면 네트워크에서 가져옵니다.
Next.js 15 이후 기본
fetch는 캐시되지 않으므로 재사용할 데이터에만 이 옵션을 직접 지정합니다. -
'no-store': 요청된 데이터를 캐시하지 않고, 항상 네트워크에서 새로운 데이터를 가져옵니다.실시간으로 변하는 데이터(예: 주식 시세, 채팅 메시지)에 적합합니다.
// Next.js 데이터 캐시를 사용하지 않고 요청 시 원본에 조회 const res = await fetch('http://localhost:4000/realtime-data', { cache: 'no-store' });
재검증 전략 (next.revalidate 옵션)
revalidate 옵션은 캐시가 지정한 시간보다 오래된 뒤 들어온 다음 요청이 백그라운드 재검증을 촉발할 수 있도록 설정합니다.
이를 ISR (Incremental Static Regeneration)이라고도 합니다.
interface Product {
id: number;
name: string;
price: number;
timestamp: string; // 데이터가 언제 페칭되었는지 확인하기 위함
}
async function getProducts(): Promise<Product[]> {
const res = await fetch('http://localhost:4000/products', { // 실제 API 주소로 변경
// 캐시가 10초 이상 지난 뒤 새 요청이 들어오면 재검증할 수 있도록 설정
next: { revalidate: 10 },
});
if (!res.ok) {
return [];
}
const products = await res.json();
// 현재 페칭 시점의 타임스탬프 추가 (확인용)
return products.map((p: any) => ({ ...p, timestamp: new Date().toLocaleTimeString() }));
}
export default async function RevalidatedPage() {
const products = await getProducts();
return (
<div>
<h1>재검증되는 상품 목록</h1>
<p>캐시가 <strong>10초</strong> 이상 지난 뒤 들어온 요청이 새 데이터 재검증을 시작할 수 있습니다.</p>
<p>마지막 페칭 시간: <strong>{products[0]?.timestamp || 'N/A'}</strong></p>
<ul>
{products.map((p) => (
<li key={p.id}>{p.name} - ${p.price}</li>
))}
</ul>
<p>
팁: 페이지를 새로고침하면 (하드 새로고침 아님) 10초 이내에는 캐시된 데이터가 보이고, 10초 후에는 새로운 데이터로 업데이트됩니다.
</p>
</div>
);
}참고: 위 예시를 제대로 테스트하려면 http://localhost:4000/products 대신 실제 동작하는 더미 API를 사용하거나, 간단한 로컬 API를 만들어 테스트해야 합니다.
더미 API는 데이터 변동 폭이 작을 수 있어 재검증 효과가 과소하게 보일 수 있습니다.
revalidate의 작동 방식
사용자가 페이지에 처음 접근하면, Next.js는 데이터를 가져와 페이지를 렌더링하고 캐시합니다.
이후 revalidate 시간(예: 10초) 이내에 다시 요청이 오면, 캐시된 데이터를 즉시 반환합니다.
revalidate 시간이 경과한 후 요청이 오면, Next.js는 즉시 오래된 캐시 데이터를 사용자에게 반환하고, 동시에 백그라운드에서 새로운 데이터를 다시 가져와 캐시를 업데이트합니다.
다음 요청부터는 업데이트된 새로운 캐시 데이터를 반환합니다.
이 방식은 캐시 응답을 먼저 활용하면서 유효기간이 지난 뒤 들어온 요청을 계기로 데이터를 갱신해, 응답성과 허용 가능한 신선도 사이를 조정합니다.
동적 라우트 파라미터를 사용한 데이터 페칭
이전 4장 1절에서 다룬 동적 라우트와 결합하여, URL 파라미터를 사용하여 특정 데이터를 페칭할 수 있습니다.
페이지 컴포넌트는 params prop을 통해 동적 라우트 세그먼트의 값을 전달받습니다.
import Link from 'next/link';
interface UserDetailPageProps {
params: Promise<{
id: string; // URL에서 추출될 사용자 ID
}>;
}
interface User {
id: number;
firstName: string;
lastName: string;
username: string;
email: string;
phone: string;
domain: string;
}
// 특정 사용자 데이터를 가져오는 함수
async function getUser(id: string): Promise<User> {
const res = await fetch(`http://localhost:4000/users/${id}`);
if (!res.ok) {
throw new Error('Failed to fetch user');
}
return res.json();
}
// SSG를 위해 빌드 시 어떤 ID를 미리 생성할지 정의 (선택 사항)
export async function generateStaticParams() {
const res = await fetch('http://localhost:4000/users?_limit=20');
const payload = await res.json();
const users: User[] = payload;
return users.map((user) => ({
id: user.id.toString(),
}));
}
export default async function UserDetailPage({ params }: UserDetailPageProps) {
const { id } = await params;
const user = await getUser(id); // 서버에서 특정 사용자 데이터 페칭
return (
<div>
<h1>사용자 상세 정보</h1>
<h2>{user.firstName} {user.lastName} (@{user.username})</h2>
<p>이메일: {user.email}</p>
<p>전화: {user.phone}</p>
<p>
도메인: {user.domain ? (
<a href={`https://${user.domain}`} target="_blank" rel="noopener noreferrer">{user.domain}</a>
) : '-'}
</p>
<Link href="/users">목록으로 돌아가기</Link>
</div>
);
}실습:
src/app/users/[id] 폴더를 만들고 그 안에 page.tsx 파일을 위 내용으로 생성합니다.
http://localhost:3000/users/1 또는 http://localhost:3000/users/5와 같이 접속하여 특정 사용자 상세 페이지가 잘 로드되는지 확인해 보세요.
React.cache를 사용한 렌더링 중복 제거
같은 요청의 컴포넌트 트리에서 ORM이나 SDK 함수를 같은 인자로 여러 번 호출해야 한다면 React.cache로 해당 렌더 패스의 결과를 메모이제이션할 수 있습니다.
import { cache } from 'react';
import { db } from './db';
export const getUser = cache(async (id: string) => {
console.log('query user', id);
return db.user.findUniqueOrThrow({ where: { id } });
});import { getUser } from '@/lib/users';
async function UserSummary({ id }: { id: string }) {
const user = await getUser(id);
return <h1>{user.name}</h1>;
}
async function UserPermissions({ id }: { id: string }) {
const user = await getUser(id);
return <p>권한: {user.role}</p>;
}
export default async function UserPage({
params,
}: PageProps<'/users/[id]'>) {
const { id } = await params;
return (
<>
<UserSummary id={id} />
<UserPermissions id={id} />
</>
);
}두 자식 컴포넌트가 같은 렌더에서 getUser(id)를 호출하므로 데이터베이스 조회는 한 번만 실행됩니다.
이 메모이제이션은 현재 서버 요청과 렌더 패스 안에서만 유효합니다.
다른 사용자의 요청이나 다음 페이지 방문까지 결과를 보존하려면 Next.js의 명시적 데이터 캐시 전략을 별도로 선택해야 합니다.
데이터 페칭 전략은 fetch 옵션, revalidate, React.cache가 서로 다른 층에서 작동한다는 점을 나눠 보면 선택이 쉬워집니다.
서버 컴포넌트의 데이터 페칭은 App Router에서 렌더링 시점과 캐싱 방식을 결정합니다.
fetch 옵션과 React.cache는 요청 특성에 맞춰 조정해야 안정적인 데이터 흐름을 만들 수 있습니다.
이 다이어그램은 서버 컴포넌트에서 데이터 페칭을 Next.js 프로젝트에 넣을 때 결정해야 할 파일 위치와 런타임 경계를 정리합니다.
서버에서 끝낼 로직과 브라우저로 보낼 상호작용을 나누는 기준입니다.
마지막으로 서버 컴포넌트 fetch 옵션을 캐시, 재검증, 중복 요청 제거 기준으로 묶어 봅니다.