서버 컴포넌트에서 데이터 페칭
Next.js 15의 App Router에서 가장 혁신적인 변화 중 하나는 서버 컴포넌트(Server Components) 의 도입입니다. 이 새로운 패러다임은 데이터 페칭 방식을 근본적으로 변화시켰으며, 애플리케이션의 성능과 개발 경험을 크게 향상시킵니다. 이전에는 클라이언트 측에서 useEffect
나 getServerSideProps
와 같은 함수를 통해 데이터를 가져왔지만, 이제는 훨씬 더 직관적이고 강력한 방법으로 데이터를 다룰 수 있습니다.
이 절에서는 서버 컴포넌트에서 데이터를 페칭하는 핵심 원리, 구체적인 방법, 그리고 그로 인해 얻을 수 있는 이점들을 자세히 살펴보겠습니다.
서버 컴포넌트에서의 데이터 페칭 기본 원리
App Router의 모든 컴포넌트(페이지, 레이아웃 등)는 기본적으로 서버 컴포넌트입니다. 서버 컴포넌트는 클라이언트(브라우저)가 아닌 서버 환경에서 렌더링되고 실행됩니다. 이 특성 덕분에 데이터 페칭이 매우 효율적이고 안전해집니다.
핵심 원리
async/await
지원: 서버 컴포넌트는 비동기 함수로 작성될 수 있으며,async/await
문법을 사용하여 데이터를 직접 페칭할 수 있습니다. 이는 마치 백엔드 코드처럼 데이터베이스 쿼리나 API 호출을 작성할 수 있음을 의미합니다.- 서버에서 직접 실행: 데이터 페칭 로직이 클라이언트 번들에 포함되지 않고 서버에서 직접 실행됩니다. 따라서 API 키와 같은 민감한 정보가 클라이언트에 노출될 위험이 없습니다.
- 워터폴(Waterfall) 방지 최적화: Next.js는 여러 데이터 요청을 자동으로 최적화하여 불필요한 직렬 요청(워터폴)을 줄입니다.
- 자동 캐싱 및 중복 제거: Next.js는
fetch
API를 확장하여 요청을 자동으로 캐싱하고 중복 요청을 제거하는 강력한 기능을 내장하고 있습니다.
fetch
API를 사용한 데이터 페칭
서버 컴포넌트에서 데이터를 페칭하는 가장 일반적이고 권장되는 방법은 네이티브 fetch
API를 사용하는 것입니다. Next.js는 이 fetch
함수를 자동으로 확장하여 캐싱, 재검증(revalidation) 등 강력한 기능을 추가합니다.
// src/app/users/page.tsx (새로 생성할 페이지)
interface User {
id: number;
name: string;
email: string;
}
// 이 함수는 서버에서 실행됩니다.
async function getUsers(): Promise<User[]> {
// fetch API를 사용하여 외부 API에서 사용자 데이터를 가져옵니다.
const res = await fetch('https://jsonplaceholder.typicode.com/users');
// 응답이 성공적이지 않으면 에러를 던집니다.
if (!res.ok) {
// throw new Error('Failed to fetch users'); // 실제 서비스에서는 더 구체적인 에러 처리 필요
return []; // 예시를 위해 빈 배열 반환
}
// JSON 형태로 파싱하여 반환합니다.
return res.json();
}
// 페이지 컴포넌트를 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.name}</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'
(기본값): 요청된 데이터를 캐시하고, 가능한 경우 캐시된 데이터를 사용합니다. 캐시된 데이터가 없으면 네트워크에서 가져옵니다.fetch
의 기본 동작입니다. -
'no-store'
: 요청된 데이터를 캐시하지 않고, 항상 네트워크에서 새로운 데이터를 가져옵니다. 실시간으로 변하는 데이터(예: 주식 시세, 채팅 메시지)에 적합합니다.// 항상 최신 데이터를 가져옴 const res = await fetch('https://api.example.com/realtime-data', { cache: 'no-store' });
-
'no-cache'
: 캐시된 데이터가 있더라도 항상 네트워크에서 데이터를 재검증합니다. 데이터가 변경되지 않았다면 304 Not Modified 응답을 통해 캐시된 데이터를 재사용할 수 있습니다.// 캐시를 사용하되, 항상 서버에서 재검증 const res = await fetch('https://api.example.com/often-updated-data', { cache: 'no-cache' });
재검증 전략 (next.revalidate
옵션)
revalidate
옵션은 특정 시간(초)마다 캐시된 데이터를 백그라운드에서 다시 가져오도록 설정합니다. 이를 ISR (Incremental Static Regeneration) 이라고도 합니다.
// src/app/revalidated-data/page.tsx (새로 생성할 페이지)
interface Product {
id: number;
name: string;
price: number;
timestamp: string; // 데이터가 언제 페칭되었는지 확인하기 위함
}
async function getProducts(): Promise<Product[]> {
const res = await fetch('https://api.example.com/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>
);
}
참고: 위 예시를 제대로 테스트하려면 https://api.example.com/products
대신 실제 동작하는 더미 API를 사용하거나, 간단한 로컬 API를 만들어 테스트해야 합니다. JSONPlaceholder는 데이터가 변하지 않으므로 효과를 직접 보기 어렵습니다.
revalidate
의 작동 방식
사용자가 페이지에 처음 접근하면, Next.js는 데이터를 가져와 페이지를 렌더링하고 캐시합니다.
이후 revalidate
시간(예: 10초) 이내에 다시 요청이 오면, 캐시된 데이터를 즉시 반환합니다.
revalidate
시간이 경과한 후 요청이 오면, Next.js는 즉시 오래된 캐시 데이터를 사용자에게 반환하고, 동시에 백그라운드에서 새로운 데이터를 다시 가져와 캐시를 업데이트합니다.
다음 요청부터는 업데이트된 새로운 캐시 데이터를 반환합니다.
이 방식은 사용자가 항상 빠르게 응답을 받을 수 있도록 하면서도, 주기적으로 데이터의 신선도를 유지할 수 있게 해줍니다.
동적 라우트 파라미터를 사용한 데이터 페칭
이전 4장 1절에서 다룬 동적 라우트와 결합하여, URL 파라미터를 사용하여 특정 데이터를 페칭할 수 있습니다. 페이지 컴포넌트는 params
prop을 통해 동적 라우트 세그먼트의 값을 전달받습니다.
// src/app/users/[id]/page.tsx (새로 생성할 페이지)
interface UserDetailPageProps {
params: {
id: string; // URL에서 추출될 사용자 ID
};
}
interface User {
id: number;
name: string;
username: string;
email: string;
phone: string;
website: string;
}
// 특정 사용자 데이터를 가져오는 함수
async function getUser(id: string): Promise<User> {
const res = await fetch(`https://jsonplaceholder.typicode.com/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('https://jsonplaceholder.typicode.com/users');
const users: User[] = await res.json();
return users.map((user) => ({
id: user.id.toString(),
}));
}
export default async function UserDetailPage({ params }: UserDetailPageProps) {
const { id } = params;
const user = await getUser(id); // 서버에서 특정 사용자 데이터 페칭
return (
<div>
<h1>사용자 상세 정보</h1>
<h2>{user.name} (@{user.username})</h2>
<p>이메일: {user.email}</p>
<p>전화: {user.phone}</p>
<p>웹사이트: <a href={`http://${user.website}`} target="_blank" rel="noopener noreferrer">{user.website}</a></p>
<Link href="/users"><a>목록으로 돌아가기</a></Link>
</div>
);
}
실습:
src/app/users/[id]
폴더를 만들고 그 안에 page.tsx
파일을 위 내용으로 생성합니다. http://localhost:3000/users/1
또는 http://localhost:3000/users/5
와 같이 접속하여 특정 사용자 상세 페이지가 잘 로드되는지 확인해 보세요.
React.cache
를 사용한 중복 요청 제거
Next.js는 동일한 fetch
요청에 대해 자동으로 캐싱하고 중복을 제거하지만, 만약 fetch
가 아닌 다른 데이터 페칭 라이브러리(예: axios
, ORM 등)를 사용하거나, 동일한 데이터를 여러 컴포넌트에서 호출하는 상황이라면 React.cache
를 사용하여 수동으로 중복 요청을 제거할 수 있습니다.
// src/lib/api.ts (새로 만들 파일)
import { cache } from 'react';
interface Post { /* ... */ }
// 이 함수는 Next.js의 캐싱 메커니즘을 활용하여 동일한 호출에 대해 중복 요청을 방지합니다.
export const getPostsCached = cache(async () => {
console.log('Fetching posts from API...'); // 이 로그는 한 번만 찍힙니다.
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
return res.json();
});
export const getPostCached = cache(async (id: string) => {
console.log(`Fetching post ${id} from API...`); // 이 로그도 각 id별로 한 번만 찍힙니다.
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
return res.json();
});
// src/app/blog/page.tsx (기존 또는 새로 만들 페이지)
import Link from 'next/link';
import { getPostsCached } from '../../lib/api'; // 캐시된 함수 임포트
export default async function BlogPage() {
const posts = await getPostsCached(); // 여기에서 한 번 호출
return (
<div>
<h1>블로그 게시물</h1>
<ul>
{posts.map((post: any) => (
<li key={post.id}>
<Link href={`/blog/${post.id}`}><a>{post.title}</a></Link>
</li>
))}
</ul>
</div>
);
}
// src/app/blog/[id]/page.tsx (기존 또는 새로 만들 페이지)
import { getPostCached } from '../../../lib/api'; // 캐시된 함수 임포트
interface PostDetailPageProps {
params: { id: string };
}
export default async function BlogDetailPage({ params }: PostDetailPageProps) {
const post = await getPostCached(params.id); // 여기에서 또 호출하지만, 동일 id면 캐시 사용
return (
<div>
<h1>{post.title}</h1>
<p>{post.body}</p>
<Link href="/blog"><a>목록으로 돌아가기</a></Link>
</div>
);
}
React.cache
로 래핑된 함수는 동일한 인자로 여러 번 호출되더라도 실제로 네트워크 요청은 한 번만 발생하도록 보장합니다. 이는 컴포넌트 트리 내에서 데이터를 중복으로 페칭하는 것을 효과적으로 방지하여 성능을 최적화합니다.
서버 컴포넌트에서의 데이터 페칭은 Next.js App Router 개발의 핵심이며, 애플리케이션의 성능과 확장성에 지대한 영향을 미칩니다. fetch
API와 그 옵션, 그리고 React.cache
를 적절히 활용하여 효율적이고 안정적인 데이터 관리를 할 수 있기를 바랍니다.