App Router data fetching
Page Router의 데이터 페칭 방식과 한계
Page Router에서는 서버 컴포넌트라는 개념이 없었다. 모든 컴포넌트가 클라이언트 컴포넌트로서 서버와 클라이언트 양쪽에서 실행되었기 때문에, 서버에서만 데이터를 가져오려면 getServerSideProps나 getStaticProps 같은 특수한 함수를 사용해야 했다.
// Page Router 방식
export default function Page({ data }) {
return <div>{data.title}</div>;
}
export async function getServerSideProps() {
const res = await fetch('<https://api.example.com/data>');
const data = await res.json();
return {
props: { data }
};
}
이 방식의 문제는 데이터 흐름의 비효율성이다. 서버에서 가져온 데이터는 항상 페이지 최상단에 위치하게 되고, 실제로 그 데이터가 필요한 하위 컴포넌트까지 props나 Context API로 전달해야 한다. 컴포넌트 트리가 깊어질수록 props drilling이 심해지고, 데이터가 필요한 곳에서 직접 가져올 수 없다는 근본적인 제약이 있었다.
App Router의 해결 방법
App Router에서는 서버 컴포넌트가 추가되면서 이런 문제가 해결되었다. 서버 컴포넌트는 서버에서만 실행되기 때문에 컴포넌트 함수 자체를 async로 선언하고 내부에서 직접 데이터를 가져올 수 있다.
// App Router 방식
async function PostList() {
const res = await fetch('<https://api.example.com/posts>');
const posts = await res.json();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
위 코드에서 PostList 컴포넌트는 서버 컴포넌트다. 서버에서만 실행되므로 브라우저 환경에서 발생할 수 있는 비동기 처리 문제를 신경 쓸 필요가 없다. 기존에는 클라이언트 컴포넌트에서 async 키워드를 사용하면 브라우저에서 동작할 때 문제를 일으킬 수 있어 권장되지 않았지만, 서버 컴포넌트는 브라우저에 전송되지 않으므로 안전하게 사용할 수 있다.
주요 변화점
이제 getServerSideProps, getStaticProps 같은 함수를 사용하지 않아도 된다. 데이터가 필요한 컴포넌트에서 직접 가져오면 된다. Next.js 공식 문서에서 강조하는 "fetching data where it's needed"라는 best practice가 이제 실제로 가능해진 것이다.
// 페이지 컴포넌트
export default function BlogPage() {
return (
<div>
<Header />
<PostList /> {/* 데이터가 필요한 곳에서 직접 fetch */}
<Sidebar />
</div>
);
}
// 데이터가 필요한 컴포넌트
async function PostList() {
const posts = await fetchPosts();
return <ul>...</ul>;
}
데이터를 상위에서 하위로 전달할 필요가 없어지면서 컴포넌트 구조가 단순해지고, 각 컴포넌트가 자신이 필요한 데이터를 독립적으로 관리할 수 있게 되었다.
서버 컴포넌트에서 데이터 페칭 패턴
서버 컴포넌트에서 데이터를 가져오는 기본 패턴은 다음과 같다.
async function ProductDetail({ id }) {
// 1. fetch 요청
const res = await fetch(`https://api.example.com/products/${id}`);
// 2. 에러 처리
if (!res.ok) {
throw new Error('Failed to fetch product');
}
// 3. 데이터 파싱
const product = await res.json();
// 4. 렌더링
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>Price: ${product.price}</p>
</div>
);
}
컴포넌트 함수를 async로 선언하고, 내부에서 await로 비동기 작업을 처리한다. 이 컴포넌트는 서버에서 데이터를 가져온 후 완성된 HTML을 클라이언트에 전송하므로, 사용자는 로딩 상태 없이 바로 완성된 콘텐츠를 볼 수 있다.
fetch API 확장 기능
App Router의 서버 컴포넌트에서 사용하는 fetch는 Web API의 표준 fetch를 확장한 것으로, Next.js만의 추가 기능을 제공한다.
// 캐싱 제어
const res = await fetch('<https://api.example.com/data>', {
cache: 'force-cache' // 기본값: 캐시 사용
});
const res2 = await fetch('<https://api.example.com/data>', {
cache: 'no-store' // 매번 새로 가져오기
});
// 재검증 시간 설정
const res3 = await fetch('<https://api.example.com/data>', {
next: { revalidate: 60 } // 60초마다 재검증
});
이 옵션들을 통해 SSG, SSR, ISR 방식을 자연스럽게 구현할 수 있다.
병렬 데이터 페칭
여러 데이터를 동시에 가져와야 할 때는 Promise.all을 사용하면 된다.
async function Dashboard() {
const [user, posts, comments] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json())
]);
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
<CommentList comments={comments} />
</div>
);
}
세 개의 API 요청이 순차적이 아닌 병렬로 실행되어 전체 로딩 시간이 단축된다.
클라이언트 컴포넌트에서의 데이터 페칭
클라이언트 컴포넌트에서는 여전히 useEffect나 React Query 같은 라이브러리를 사용해 데이터를 가져온다.
'use client'
import { useState, useEffect } from 'react';
export default function ClientComponent() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/data')
.then(r => r.json())
.then(setData);
}, []);
if (!data) return <div>Loading...</div>;
return <div>{data.title}</div>;
}
서버 컴포넌트에서는 async/await로 간단하게 처리하고, 클라이언트 컴포넌트에서는 기존 방식을 사용하는 것이 일반적인 패턴이다.