서버 컴포넌트에서 데이터 페칭
서버 컴포넌트에서 fetch를 직접 호출하고 캐시·재검증·동적 params·React.cache로 요청 중복을 제어합니다.
6장에서는 데이터 페칭과 렌더링 방식, 캐싱을 본격적으로 다룹니다. Next.js 16 App Router는 서버 컴포넌트(Server Components)를 바탕으로 데이터와 UI를 서버에서 준비합니다. 이 방식은 데이터를 가져오는 방법을 근본적으로 바꾸어 애플리케이션의 성능과 개발 경험을 함께 높였습니다. 예전에는 클라이언트의 useEffect나 getServerSideProps로 데이터를 가져왔다면, 이제는 더 직관적인 흐름으로 데이터를 다룰 수 있습니다.
이 글에서는 서버 컴포넌트에서 데이터를 가져오는 핵심 원리와 구체적인 방법, 그리고 그 장점을 자세히 살펴봅니다. 데이터 페칭 로직은 서버에 남고, 서버가 만든 렌더링 결과는 HTML과 RSC Payload(서버 컴포넌트의 렌더링 결과를 React가 직렬화한 데이터)로 브라우저에 전달됩니다. RSC Payload에 무엇이 담기는지는 7장 1절 서버 컴포넌트에서 자세히 다룹니다.
서버 컴포넌트에서 데이터를 가져오는 원리
App Router의 페이지와 레이아웃은 기본으로 서버 컴포넌트이며, "use client" 파일과 그 파일이 가져온 모듈만 클라이언트 모듈 그래프에 속합니다. 서버 컴포넌트는 브라우저가 아닌 서버 환경에서 렌더링되고 실행되므로 데이터 페칭이 효율적이고 안전해집니다.
이 글의 API 예시는 외부 네트워크의 변동을 줄이려고 로컬 목 API(json-server, http://localhost:4000)를 기준으로 맞추며, 포트는 4000번을 목 API, 4100번을 별도의 백엔드나 실시간 서버로 나누어 두면 트랙 사이의 실행 충돌을 줄일 수 있습니다.
npx [email protected] --watch db.json --port 40006장의 _limit과 배열 응답 예시는 json-server 0.17.4를 기준으로 합니다. 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 화면(6장 5절)에는 두 문장이 나오고 두 번째에 취소선이 생깁니다. 이처럼 입력의 수와 필드를 맞춰야 빈 화면이나 필드 오타를 캐시 문제와 구분할 수 있습니다.
이 장은 Next.js 16에서 cacheComponents를 켜지 않은 일반 App Router 모델을 씁니다. 옵션마다의 Data Cache와 라우트의 사전 렌더링 결과를 구분하며, Cache Components의 use cache 모델과 섞어 쓰지 않습니다.
서버 컴포넌트는 비동기 함수로 작성할 수 있어 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 }
]
}위의 목 API 명령을 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가 필요하지 않고, 6장 2절과 4절에서 다룰 products 상세 페이지와도 경로가 겹치지 않습니다. 비교 라우트와 상위 레이아웃에는 cookies, headers, connection처럼 요청이 들어와야 결과가 정해지는 요청 시점 API(Request-time API)나 다른 no-store 요청, 더 짧은 재검증 설정이 없다고 전제하며, SSR 페이지에만 뒤에서 connection을 넣습니다. cacheComponents를 켜거나 output: 'export'를 쓰는 설정과는 함께 비교하지 않으며, 프로젝트의 잠금 파일과 설치된 Next.js 16, React, Node.js 버전도 기록해 두면 설정의 차이를 확인하기 쉽습니다.
fetch API로 데이터 가져오기
서버 컴포넌트에서 데이터를 가져오는 가장 일반적이고 권장되는 방법은 기본 fetch API를 쓰는 것이며, Next.js는 이 fetch를 확장해 캐싱과 재검증(revalidation) 같은 기능을 더합니다. 아래 예제는 페이지 컴포넌트를 async 함수로 만들고, 렌더링 중에 await getUsers()로 받은 배열로 목록을 그립니다. 이 fetch에는 캐시 옵션이 없으므로, 요청 사이에 Data Cache에 저장하라고 정하지 않은 기본 호출입니다.
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 16에서 Cache Components를 켜지 않은 이 글의 예제에서, 같은 렌더링 안에서 호출을 함께 쓰는 것과 새 요청에서도 결과를 재사용하는 것은 범위가 다릅니다. GET 메모이제이션은 같은 서버 렌더링 안에서 URL과 옵션이 같은 fetch 호출의 결과를 함께 쓰며 요청 사이의 Data Cache와는 별개이고, React cache는 같은 서버 렌더링에서 같은 메모 함수와 같은 인자로 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 문서의 fetch).
// 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.toLocaleString('ko-KR')}원</li>
))}
</ul>
<p>
팁: 만료 뒤 요청은 기존 응답을 먼저 받을 수 있습니다. 재검증 성공 후 응답을 비교하세요.
</p>
</div>
);
}products의 원본 필드를 고친 뒤, 일반적인 next build와 next start 환경에서 응답을 비교합니다. 화면에 보이는 timestamp는 fetch 뒤의 new Date() 값이므로, Data Cache가 갱신되었는지나 원본 데이터가 언제 바뀌었는지를 혼자서 알려 주지 않습니다.
revalidate는 이렇게 동작합니다. 빌드나 처음 생성할 때 데이터를 읽어 정적 라우트의 결과를 준비하며, 캐시가 없는 첫 요청과 이미 만든 응답을 재사용하는 것은 다릅니다. 이후 revalidate 시간(예: 10초) 안에 다시 요청이 오면 캐시된 데이터를 곧바로 돌려줍니다. 시간이 지난 뒤에 요청이 오면 Next.js는 오래된 캐시 데이터를 곧바로 돌려주는 동시에 백그라운드에서 새 데이터를 가져와 캐시를 갱신하고, 재검증이 성공한 뒤의 요청에는 새 캐시를 씁니다. 재검증이 실패하면 이전의 성공 결과를 유지하므로, 시간이 지났다는 것만으로 최신 값이 보장되지는 않습니다.
이 방식은 캐시된 응답을 먼저 쓰면서 유효 기간이 지난 뒤의 요청을 계기로 데이터를 갱신해, 응답 속도와 허용할 수 있는 신선도 사이를 조정합니다. 다만 이전 결과가 없는 첫 생성까지 실패했다면 유지할 성공 결과가 없으며, 위의 getProducts는 HTTP 실패를 빈 배열로 바꾸므로 그 경우에는 재검증 오류가 던져지지 않습니다. 같은 상품의 세 응답을 비교하는 순서와 그림은 서버 사이드 렌더링 (SSR)의 상품 비교에서 이어 갑니다.
더 깊이: 캐시 항목을 가르는 기준과 개발 서버에서 헷갈리기 쉬운 동작
force-cache로 저장한 응답은 URL만으로 구분되지 않습니다. Next.js 문서의 fetch에 따르면 캐시는 요청의 URL, 메서드, 헤더, 본문이 모두 맞아야 같은 항목으로 보며, 하나라도 다르면 따로 저장합니다. 저장되는 것은 상태 코드가 200인 응답뿐입니다. 캐시는 직접 고르는 기능이라서, force-cache를 지정하면 POST 요청이나 authorization, cookie 헤더를 보내는 요청도 서버 캐시에 저장됩니다. 사용자마다 다른 응답에 이 옵션을 붙일 때는 그 결과가 서버에 남는다는 점을 먼저 따져 봅니다.
같은 라우트 안에서 옵션이 부딪힐 때의 규칙도 정해져 있습니다. 같은 URL의 fetch 두 개가 서로 다른 revalidate를 지정하면 더 짧은 값이 쓰이고, 어느 fetch가 라우트의 기본 재검증 간격보다 짧은 값을 지정하면 라우트 전체의 재검증 간격도 줄어듭니다. { revalidate: 3600, cache: 'no-store' }처럼 서로 맞지 않는 옵션을 함께 쓰면 두 옵션이 모두 무시되고, 개발 모드에서는 터미널에 경고가 나옵니다. 한 렌더링 안의 GET 메모이제이션은 AbortController의 signal을 넘기면 끌 수 있으며, React 컴포넌트 트리 밖에 있는 Route Handler에서는 처음부터 적용되지 않습니다.
개발 서버의 결과를 해석할 때도 주의가 필요합니다. 개발 중에는 HMR로 서버 컴포넌트가 다시 실행될 때 이전 fetch 응답을 다시 쓰는 HMR 캐시가 있어서, 기본 호출이나 no-store 요청도 HMR 새로고침 사이에는 새 데이터를 보여 주지 않을 수 있습니다. 이 캐시는 다른 페이지로 이동하거나 전체 문서를 새로고침하면 비워집니다. 반대로 개발자 도구에서 캐시를 끄거나 캐시를 건너뛰는 새로고침(hard refresh)을 하면 브라우저가 cache-control: no-cache 헤더를 보내는데, 개발 모드의 Next.js는 이 헤더가 있으면 cache, next.revalidate, next.tags 옵션을 무시하고 원본에서 가져옵니다. 그래서 캐시 정책은 개발 서버가 아니라 next build와 next start로 확인합니다.
동적 라우트 매개변수로 데이터 가져오기
4장 1절의 동적 라우트와 결합하면 URL 매개변수로 특정 데이터를 가져올 수 있으며, 페이지 컴포넌트는 params prop으로 동적 라우트 세그먼트의 값을 받습니다. 아래 상세 페이지는 목록과 같은 목 API를 쓰되, await params로 꺼낸 id를 주소 끝에 붙여 사용자 한 명을 조회합니다. generateStaticParams는 빌드할 때 목록을 조회해 미리 만들 id들을 돌려주는데, 라우트 매개변수는 URL의 일부라서 문자열이어야 하므로 숫자 id를 toString()으로 바꿉니다.
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이나 /users/5처럼 들어가 그 ID가 실제 목 데이터에 있을 때 상세 페이지가 나오는지 확인해 보세요. 이 코드는 ID의 형식 검증과 notFound()를 구현하지 않았으며, HTTP 실패에는 일반 오류를 던집니다.
React.cache로 렌더링 중의 중복 없애기
같은 요청의 컴포넌트 트리에서 ORM이나 SDK 함수를 같은 인자로 여러 번 불러야 한다면, React.cache로 그 렌더링의 결과를 메모이제이션할 수 있습니다. 아래 ORM 예제는 @/lib/db의 db.user.findUnique 같은 단건 조회가 준비되어 있다고 가정한 별도의 대안입니다. 실제로 부르는 메서드는 db.user.findUniqueOrThrow이며, 돌려주는 객체에는 자식이 읽는 name과 role이 있어야 하고, 목 API를 준비했다고 이 DB 모듈까지 생기지는 않습니다. PageProps는 Next.js의 next dev, next build, next typegen이 만드는 전역 타입을 쓰며, 앞의 같은 경로 페이지에 코드를 쌓지 않고 이 코드로 바꿉니다.
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 } });
});console.log('query user', id)는 메모 함수 안에 있으므로, 실제 조회가 일어날 때만 서버 터미널에 찍힙니다. 다음 페이지를 열었을 때 같은 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)를 부르므로, 같은 서버 렌더링에서 같은 문자열 id를 읽으면 조회 결과를 함께 씁니다. 자식마다 cache를 따로 불러 함수를 만들면 함께 쓸 캐시도 달라집니다. 조회가 오류를 던지면 같은 인자의 호출은 그 오류도 함께 받으므로, 중복 제거를 실패했을 때의 재시도로 해석하지 않습니다(React 문서의 cache).
이 메모이제이션은 지금의 서버 요청과 렌더링 안에서만 유효합니다. 다른 사용자의 요청이나 다음 페이지 방문까지 결과를 보존하려면 Next.js의 명시적인 Data Cache 전략을 따로 골라야 합니다.
정리
- 서버 컴포넌트는
async/await로 데이터를 직접 가져오며, 그 로직은 클라이언트 번들에 들어가지 않지만 렌더링 결과와 props에 넣은 값은 브라우저로 전달됩니다. - 기다리는 요청끼리 서로 기대지 않는다면 먼저 시작한 뒤
Promise.all로 기다려 워터폴을 줄입니다. - 같은 렌더링 안의 GET 메모이제이션과 React
cache는 한 요청 안에서만, Data Cache와 라우트 결과 캐시는 요청 사이에서 재사용됩니다. - Next.js 15 이후
fetch는 기본으로 요청 사이에 캐시되지 않으므로force-cache나next.revalidate로 정책을 직접 정합니다. - 재검증은 기간이 지난 뒤의 요청을 계기로 백그라운드에서 이루어지고, 실패하면 이전의 성공 결과를 유지합니다.
연습 문제
1. React cache가 줄이는 조회
DB에 u1과 u2가 있고 모든 조회가 성공한다고 가정하세요. 같은 서버 렌더링에서 위의 내보낸 함수로 u1, u1, u2를 차례로 읽은 뒤, 다음 독립된 서버 요청에서 u1을 다시 읽습니다. 각 요청에서 실제 DB 함수가 계산해야 하는 서로 다른 입력은 몇 개일까요? 두 자식이 각자 cache로 별도의 함수를 만들면 u1 호출을 반드시 함께 쓸 수 있을까요?
해설 보기
첫 렌더링은 u1과 u2의 두 입력이라 조회가 두 번이고, 다음 요청은 새 렌더링의 u1이라 한 번입니다. 같은 내보낸 함수를 쓴다는 것이 전제이며, 따로 만든 메모 함수끼리는 캐시를 함께 쓰지 않습니다.
이 계산은 렌더링과 입력마다 함께 쓰는 규칙을 적용한 것일 뿐, 개발 서버의 모든 로그 개수나 요청 사이의 영구 저장을 확인한 결과는 아닙니다.
2. 차례로 기다리는 두 요청
한 페이지에서 const user = await getUser(id); const posts = await getPosts();처럼 서로 관계없는 두 요청을 차례로 기다립니다. 각 요청이 1초씩 걸린다면 페이지는 얼마나 기다릴까요? 어떻게 고치면 줄일 수 있을까요?
해설 보기
첫 요청이 끝난 뒤에야 둘째 요청이 시작되므로 약 2초를 기다립니다. 두 요청이 서로 기대지 않는다면 const [user, posts] = await Promise.all([getUser(id), getPosts()]);처럼 함께 시작해 기다리면, 대략 더 오래 걸리는 요청의 시간(약 1초)만 기다리게 됩니다. Next.js가 차례로 쓴 await를 저절로 병렬로 바꿔 주지는 않습니다.
3. 빈 배열과 오류
getProducts가 HTTP 실패 때 빈 배열을 돌려주는 대신 Error를 던지도록 바꾸면, revalidate: 10인 페이지에서 재검증 도중 API가 500을 돌려줄 때 어떤 차이가 생길까요?
해설 보기
빈 배열을 돌려주면 렌더러에게는 정상적인 빈 목록이므로, 재검증 결과로 빈 화면이 저장되어 이후 사용자에게 상품이 하나도 없는 목록이 보일 수 있습니다. Error를 던지면 재검증이 실패한 것으로 처리되어 이전의 성공 결과를 유지하므로, 사용자는 오래되었지만 상품이 있는 목록을 계속 봅니다.
다만 이전의 성공 결과가 없는 첫 생성에서 오류를 던지면 유지할 결과가 없으므로, 그때는 오류 화면 같은 다른 처리가 필요합니다.
다음 글 정적 데이터 생성 (SSG)에서는 빌드할 때 데이터를 읽어 정적 페이지를 만드는 방법과, 같은 상품으로 그 결과가 언제 바뀌는지 확인합니다.