서버 컴포넌트에서 데이터 페칭
서버 컴포넌트에서 fetch를 직접 호출하고 캐시·재검증·동적 params·React.cache로 요청 중복을 제어합니다.
Next.js 16 App Router는 서버 컴포넌트(Server Components)를 바탕으로 데이터와 UI를 서버에서 준비합니다.
이 패러다임은 데이터 페칭 방식을 근본적으로 바꿨고, 애플리케이션 성능과 개발 경험을 함께 끌어올렸습니다.
이전에는 클라이언트의 useEffect나 getServerSideProps로 데이터를 가져왔다면,
이제는 더 직관적인 흐름으로 데이터를 다룰 수 있습니다.
이 절에서는 서버 컴포넌트에서 데이터를 페칭하는 핵심 원리, 구체적인 방법, 그리고 그로 인해 얻을 수 있는 이점들을 자세히 살펴보겠습니다.
먼저 데이터 요청 로직은 서버에 남고, 서버가 만든 렌더 결과가 HTML과 RSC Payload로 브라우저에 전달되는 흐름을 확인합니다.
서버 컴포넌트에서의 데이터 페칭 기본 원리
App Router의 페이지와 레이아웃은 기본적으로 서버 컴포넌트입니다. 다만 "use client" 경계에서 임포트한 모듈은 클라이언트 모듈 그래프에 속합니다.
서버 컴포넌트는 클라이언트(브라우저)가 아닌 서버 환경에서 렌더링되고 실행됩니다.
이 특성 덕분에 데이터 페칭이 매우 효율적이고 안전해집니다.
이 절의 API 예시는 외부 네트워크 변동을 줄이기 위해 로컬 Mock 서버(json-server, http://localhost:4000) 기준으로 통일합니다.
포트 정책은 4000=Mock API, 4100=별도 백엔드/실시간 서버로 분리해 두면 트랙 간 실행 충돌을 줄일 수 있습니다.
npx [email protected] --watch db.json --port 40006장의 _limit과 배열 응답 예시는 [email protected]를 기준으로 합니다. db.json에는 각 코드의 인터페이스에 맞는 users, posts, todos, products 배열과 realtime-data 리소스를 준비합니다. 예를 들어 할 일의 본문 필드는 title이 아니라 todo입니다.
이 장은 Next.js 16에서 cacheComponents를 활성화하지 않은 일반 App Router 모델을 사용합니다. 옵션별 서버 Data Cache와 라우트 프리렌더 결과를 구분하며, Cache Components의 use cache 모델과 혼용하지 않습니다.
async/await지원: 서버 컴포넌트는 비동기 함수로 작성될 수 있으며,async/await문법을 사용하여 데이터를 직접 페칭할 수 있습니다. 이는 마치 백엔드 코드처럼 데이터베이스 쿼리나 API 호출을 작성할 수 있음을 의미합니다.- 서버에서 직접 실행: 데이터 페칭 로직이 클라이언트 번들에 포함되지 않고 서버에서 직접 실행됩니다. API 키는 서버에 둘 수 있지만 렌더 결과나 클라이언트 props에 넣은 값은 브라우저에 전달되므로, 출력할 필드를 별도로 골라야 합니다.
- 워터폴(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');
// 이 예제는 HTTP 실패 응답을 빈 배열로 처리합니다.
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로 접속해 성공 응답의 사용자 목록이 HTML에 포함되는지 확인해 보세요.
이 목록은 서버에서 읽어 HTML로 표시합니다. getUsers는 HTTP 실패 응답만 빈 배열로 바꾸며, 연결 실패나 JSON 파싱 오류는 그대로 던집니다. User[] 타입 선언이 런타임 응답 검증을 대신하지는 않습니다.
fetch 옵션을 사용한 캐싱 및 재검증 전략
같은 렌더에서 중복 호출을 줄이는 기능과 요청을 넘어 결과를 재사용하는 캐시를 구분합니다. Next.js 16에서 Cache Components를 활성화하지 않은 예제 기준입니다.
| 기능 | 재사용 범위 | 원문의 연결점 |
|---|---|---|
| GET 메모이제이션 | 동일 서버 렌더 패스 | 같은 URL·옵션의 fetch 호출 결과를 공유합니다. 요청 간 Data Cache와는 별개입니다. |
| React cache | 같은 서버 렌더 문맥 | 같은 memoized 함수와 같은 인자로 ORM·SDK 호출을 공유합니다. 새 요청까지 영구 저장하지 않습니다. |
| Data Cache | 요청 간 데이터 응답 | force-cache·next.revalidate 등 명시한 정책으로 재사용합니다. |
| 라우트 결과 | 프리렌더한 HTML·RSC | 데이터 응답 캐시와 별도로 화면 결과를 재사용합니다. 빌드·재검증·동적 렌더 여부가 영향을 줍니다. |
- GET 메모이제이션
- 재사용 범위: 동일 서버 렌더 패스원문의 연결점: 같은 URL·옵션의
fetch호출 결과를 공유합니다. 요청 간 Data Cache와는 별개입니다. - React cache
- 재사용 범위: 같은 서버 렌더 문맥원문의 연결점: 같은 memoized 함수와 같은 인자로 ORM·SDK 호출을 공유합니다. 새 요청까지 영구 저장하지 않습니다.
- Data Cache
- 재사용 범위: 요청 간 데이터 응답원문의 연결점:
force-cache·next.revalidate등 명시한 정책으로 재사용합니다. - 라우트 결과
- 재사용 범위: 프리렌더한 HTML·RSC원문의 연결점: 데이터 응답 캐시와 별도로 화면 결과를 재사용합니다. 빌드·재검증·동적 렌더 여부가 영향을 줍니다.
Next.js의 fetch 확장은 캐싱 동작을 세밀하게 제어할 수 있는 다양한 옵션을 제공합니다.
이는 애플리케이션의 성능과 데이터 신선도(freshness)를 최적화하는 데 매우 중요합니다.
캐싱 전략 (cache 옵션)
-
'force-cache'(명시적 캐시): 요청된 데이터를 Data Cache에 저장하고, 이후 요청에서 가능한 경우 캐시된 응답을 사용합니다.캐시된 데이터가 없으면 네트워크에서 가져옵니다.
기본
fetch는 요청 간 Data Cache 저장을 명시하지 않습니다. 그래도 정적으로 프리렌더된 라우트의 HTML·RSC 결과는 재사용될 수 있으므로 두 캐시를 구분합니다. -
'no-store': 서버 Data Cache를 사용하지 않고 요청 시 원본을 조회합니다. 개발 중 HMR 캐시나 원본 API 자체의 캐시까지 없애는 의미는 아닙니다.실시간으로 변하는 데이터(예: 주식 시세, 채팅 메시지)에 적합합니다.
// Next.js 데이터 캐시를 사용하지 않고 요청 시 원본에 조회 const res = await fetch('http://localhost:4000/realtime-data', { cache: 'no-store' });
재검증 전략 (next.revalidate 옵션)
revalidate 옵션은 캐시가 지정한 시간보다 오래된 뒤 들어온 다음 요청이 백그라운드 재검증을 촉발할 수 있도록 설정합니다.
정적 라우트의 재생성과 연결되는 흐름은 6장 4절의 ISR에서 다룹니다.
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();
// fetch 응답 뒤 이 함수에서 생성한 시각 (원본 갱신 시각은 아님)
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>
팁: 만료 뒤 요청은 기존 응답을 먼저 받을 수 있습니다. 재검증 성공 후 응답을 비교하세요.
</p>
</div>
);
}products의 원본 필드를 수정하고 일반 next build·next start 환경에서 응답을 비교합니다. 표시한 timestamp는 fetch 뒤의 new Date() 값이므로 Data Cache 갱신 여부나 원본 데이터의 갱신 시각을 단독으로 증명하지 않습니다.
revalidate의 작동 방식
빌드 또는 첫 생성에서 데이터를 읽고 정적 라우트 결과를 준비합니다. 캐시가 없는 첫 요청과 이미 생성된 응답의 재사용은 다릅니다.
이후 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와 같이 접속하여 해당 ID가 실제 Mock 데이터에 존재할 때 상세 페이지가 로드되는지 확인해 보세요. 이 원문은 ID 형식 검증과 notFound()를 구현하지 않았으며, HTTP 실패에는 일반 오류를 던집니다.
React.cache를 사용한 렌더링 중복 제거
아래 ORM 예제는 @/lib/db의 db.user.findUnique 구현이 준비된 별도 대안입니다. PageProps는 Next.js의 next dev·next build·next typegen으로 생성되는 전역 타입을 사용합니다. 앞의 같은 경로 페이지에 코드를 누적하지 않고 교체합니다.
같은 요청의 컴포넌트 트리에서 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의 명시적 데이터 캐시 전략을 별도로 선택해야 합니다.