서버 컴포넌트에서 데이터 페칭
서버 컴포넌트에서 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입니다.
처음 실습한다면 products 예시와 함께 다음 리소스도 준비하세요. 아래 JSON은 기존 파일 전체를 대체하지 않고, 필요한 배열·객체를 추가할 때 참고할 항목입니다. 이미 같은 이름의 리소스가 있다면 두 키를 중복해 붙이지 말고 기존 항목의 필드를 확인합니다. 앞 장의 posts는 유지합니다.
{
"users": [
{ "id": 1, "firstName": "민수", "lastName": "김", "username": "minsu", "email": "[email protected]", "phone": "010-0000-0000", "domain": "example.test" },
{ "id": 5, "firstName": "지수", "lastName": "이", "username": "jisu", "email": "[email protected]", "phone": "010-0000-0005", "domain": "example.test" }
],
"todos": [
{ "id": 1, "userId": 1, "todo": "원본 응답 확인하기", "completed": false },
{ "id": 2, "userId": 1, "todo": "렌더 결과와 비교하기", "completed": true }
],
"realtime-data": { "value": 10 }
}/users의 목록과 /users/1의 단건 응답은 형태가 다릅니다. 목록 요청의 결과가 배열인지 먼저 확인하고 페이지에 옮깁니다. 위 할 일은 두 개이므로 _limit=5는 다섯 개를 만들어 주지 않고 최대 다섯 개까지만 요청합니다. 성공한 /client-data 화면에는 두 문장이 나오고 두 번째에 취소선이 생깁니다. 이처럼 입력 수와 필드를 맞춰야 빈 화면·필드 오타를 캐시 문제와 구분할 수 있습니다.
이 장은 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'처럼 명시합니다.
같은 상품으로 비교할 준비
캐시를 이해할 때는 화면의 시각보다 원본의 값이 바뀌었는지를 먼저 보는 편이 좋습니다. 이어지는 SSG와 SSR에서는 같은 상품의 가격을 299000에서 349000으로 바꾸고, 세 라우트가 언제 새 가격을 보여주는지 비교합니다.
현재 db.json의 products 배열에 아래 상품을 준비합니다. 이미 다른 상품이 있으면 이 항목만 추가하거나 수정하고, users·posts·todos 등 다른 리소스는 그대로 둡니다. 다음 JSON은 파일 전체를 덮어쓸 내용이 아니라 products 항목입니다.
{
"products": [
{ "id": 1, "name": "스마트워치 X", "price": 299000 }
]
}위 Mock 서버 명령을 db.json이 있는 폴더에서 실행한 뒤 http://localhost:4000/products/1을 열어 id·name·price가 나오는지 확인합니다. 404라면 리소스 이름과 상품 ID를, 연결 오류라면 서버가 실행 중인지와 포트를 먼저 확인합니다.
세 페이지는 src/app/render-compare/ssg/page.tsx, src/app/render-compare/ssr/page.tsx, src/app/render-compare/isr/page.tsx에 따로 만듭니다. 모두 고정 경로이므로 generateStaticParams가 필요하지 않고, 앞서 만든 products/[id] 페이지를 바꾸지 않아도 됩니다.
비교 라우트와 상위 레이아웃에는 cookies·headers·connection 같은 요청 시점 API, 다른 no-store 요청, 더 짧은 재검증 설정이 없는 것을 전제로 합니다. SSR 페이지에만 아래에서 connection을 넣습니다. cacheComponents를 켜거나 output: 'export'를 사용하는 설정과는 함께 비교하지 않습니다. 프로젝트의 잠금 파일과 설치된 Next.js 16·React·Node.js 버전도 남겨두면 설정 차이를 확인하기 쉽습니다.
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[] 타입 선언이 런타임 응답 검증을 대신하지는 않습니다.
HTTP 실패 때 []를 반환하면 렌더러에는 정상적으로 반환된 빈 목록입니다. 정적 생성이나 재검증에서도 빈 화면이 결과로 저장될 수 있으므로, 아래의 “오류를 던지면 이전 성공 결과를 유지한다”는 경우와 구분합니다.
fetch 옵션을 사용한 캐싱 및 재검증 전략
NEXT · CH6-1
렌더 중복 제거와 요청 간 캐시를 구분합니다
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본문 예제에서의 쓰임: 데이터 응답 캐시와 별도로 화면 결과를 재사용합니다. 빌드·재검증·동적 렌더 여부가 영향을 줍니다.
getUsers는 HTTP 실패 시 빈 배열을 반환하고, 상품의 timestamp는 fetch 뒤에 만든 시각입니다. 표시된 시각 하나로 원본 데이터의 갱신이나 캐시 적중을 판정할 수 없습니다.
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는 즉시 오래된 캐시 데이터를 사용자에게 반환하고, 동시에 백그라운드에서 새로운 데이터를 다시 가져와 캐시를 업데이트합니다.
재검증이 성공한 뒤의 요청에는 새 캐시를 사용합니다. 실패하면 이전 성공 결과를 유지하며, 시간이 지났다는 사실만으로 최신값을 보장하지 않습니다.
이 방식은 캐시 응답을 먼저 활용하면서 유효기간이 지난 뒤 들어온 요청을 계기로 데이터를 갱신해, 응답성과 허용 가능한 신선도 사이를 조정합니다.
이전 결과가 없는 첫 생성까지 실패한 경우에는 유지할 성공 결과가 없습니다. 또한 위 getProducts는 HTTP 실패를 빈 배열로 바꾸므로 그 경우에는 재검증 오류가 던져지지 않습니다. 같은 상품의 세 응답을 비교하는 순서와 그림은 SSR 절의 상품 비교에서 이어갑니다.
동적 라우트 파라미터를 사용한 데이터 페칭
이전 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 같은 단건 조회 계열이 준비된 별도 대안입니다. 실제 코드가 호출하는 메서드는 db.user.findUniqueOrThrow이며, 반환 객체에는 자식이 읽는 name과 role이 있어야 합니다. Mock API를 준비했다고 이 DB 모듈까지 생기는 것은 아닙니다. 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} />
</>
);
}두 자식은 같은 모듈에서 export한 동일한 memoized 함수 getUser(id)를 호출합니다. 그래서 동일한 서버 렌더 문맥에서 같은 문자열 id를 읽으면 조회 결과를 공유합니다. 각 자식 안에서 cache를 따로 호출해 함수를 만들면 공유할 캐시도 달라집니다. 조회가 오류를 던지면 같은 인자의 호출은 그 오류도 공유하므로, 중복 제거를 실패 재시도로 해석하지 않습니다.
확인 문제
DB에 u1·u2가 존재하고 모든 조회가 성공한다고 가정하세요. 같은 서버 렌더 문맥에서 위 export 함수로 u1, u1, u2를 차례로 읽은 뒤, 다음 독립된 서버 요청에서 u1을 다시 읽습니다. 각 요청에서 실제 DB 함수가 계산해야 하는 서로 다른 입력은 몇 개인가요? 두 자식이 각자 cache로 별도 함수를 만들면 u1 호출을 반드시 공유할 수 있나요?
중복 제거 범위 해설
첫 문맥은 u1과 u2 두 입력이라 조회 두 번, 다음 요청은 새 문맥의 u1이라 한 번입니다. 같은 export 함수를 사용하는 것이 전제이며, 별도 memoized 함수는 캐시를 공유하지 않습니다. 이 계산은 렌더 문맥·입력별 공유 규칙이고 개발 서버의 모든 로그 개수나 요청 사이 영구 저장을 증명한 결과는 아닙니다.
이 메모이제이션은 현재 서버 요청과 렌더 패스 안에서만 유효합니다.
다른 사용자의 요청이나 다음 페이지 방문까지 결과를 보존하려면 Next.js의 명시적 데이터 캐시 전략을 별도로 선택해야 합니다.