데이터 페칭과 캐싱
Server Component에서 `async / await`로 데이터를 직접 가져오는 패턴, Next.js 15의 fetch 캐시 옵션 (`force-cache` / `no-store` / `revalidate`), 라우트 레벨 옵션, 그리고 병렬 페칭과 `error.tsx`까지.
24장에서 Server와 Client의 경계를 정리했습니다. 본 챕터에서는 Server Component의 가장 강력한 기능 — 데이터 페칭이 단순해진다는 점을 본격적으로 다룹니다.
본 챕터의 패턴은 10장 (useEffect)과 21장 (fetch와 API 타이핑)에서 본 클라이언트 사이드 페칭의 보일러플레이트가 어떻게 사라지는지를 정면으로 보여 줍니다. 그리고 다음 26장 Suspense와 use()에서 본 챕터의 페칭 위에 점진적 렌더링을 얹게 됩니다.
클라이언트 사이드 페칭의 복잡함 #
기억나시나요? 10장에서 useEffect로 데이터를 가져올 때의 패턴.
'use client';
import { useEffect, useState } from 'react';
type User = { id: number; name: string };
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => { if (!cancelled) setUser(data); })
.catch(err => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [userId]);
if (loading) return <p>로딩 중...</p>;
if (error) return <p>에러: {error}</p>;
if (!user) return null;
return <p>{user.name}</p>;
}이게 표준 패턴이었습니다. 3개의 state, useEffect, race condition 처리, 로딩 / 에러 분기까지. 같은 작업의 보일러플레이트가 매번 반복됩니다.
Server Component에서는 #
같은 일을 Server Component로 하면 이렇게 됩니다.
type User = { id: number; name: string };
type Props = {
params: Promise<{ userId: string }>;
};
export default async function UserProfile({ params }: Props) {
const { userId } = await params;
const user: User = await fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json());
return <p>{user.name}</p>;
}이게 전부입니다. 차이가 한눈에 보입니다.
- state를 만들 필요 없음 (서버에서 한 번 실행되고 끝이라 state 개념 자체가 없음)
- 로딩 상태를 신경 안 써도 됨 (페칭이 끝나야 HTML이 클라이언트로 가니까 “로딩 중” 인 상태가 클라이언트엔 존재 안 함)
- race condition 없음 (서버 한 번 실행, 끝)
- 에러는 그냥 throw → 가까운
error.tsx가 잡음
이 단순함이 Server Component가 풀어내는 핵심 가치 중 하나입니다. 21장에서 정의해 둔 User 타입을 그대로 가져다 쓰면서, 동시에 클라이언트로 가는 코드는 거의 없는 코드입니다.
직접 fetch 외의 옵션들 #
Server Component는 서버에서 실행되니, 클라이언트가 못 하는 일도 가능합니다.
DB 직접 쿼리 #
import { db } from '@/lib/db';
type Post = { slug: string; title: string; content: string };
type Props = {
params: Promise<{ slug: string }>;
};
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await db.query<Post>('SELECT * FROM posts WHERE slug = $1', [slug]);
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}API를 별도로 안 만들어도 됩니다. 클라이언트 사이드 리액트에서는 절대 못 하는 일입니다. 브라우저에서 DB 자격증명을 노출하면 보안 사고이기 때문입니다. Server Component에서는 자격증명이 클라이언트에 안 가니 안전합니다.
파일 시스템 읽기 #
import fs from 'node:fs/promises';
import path from 'node:path';
type Props = {
params: Promise<{ slug: string }>;
};
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const filePath = path.join(process.cwd(), 'posts', `${slug}.mdx`);
const content = await fs.readFile(filePath, 'utf-8');
// ... MDX 컴파일 ...
return <article>{/* ... */}</article>;
}fs 같은 Node.js 모듈을 Server Component에서 그대로 import 해 쓸 수 있습니다. 같은 파일에 'use client'가 있었다면 빌드 에러였을 코드가, Server Component에서는 자연스럽게 동작합니다.
Next.js의 fetch 캐싱 #
Next.js는 fetch를 한 번 더 감싸서 자동 캐싱을 제공합니다. 같은 URL을 여러 번 fetch 해도 (같은 요청 내라면) 한 번만 실제 호출이 일어납니다. 그리고 빌드 시점이나 런타임에 캐싱 동작도 제어할 수 있습니다.
기본 동작 — 요청 내 dedup #
같은 페이지 안에서 여러 컴포넌트가 같은 데이터를 fetch 해도 실제로는 한 번만 호출됩니다.
async function getUser(id: number) {
return fetch(`https://api.example.com/users/${id}`).then(r => r.json());
}
export default async function Page() {
const userA = await getUser(1); // 실제 호출
const userB = await getUser(1); // 캐시에서 가져옴 (자동)
// ...
return null;
}getUser를 두 번 호출했지만 실제 HTTP 요청은 한 번만 일어납니다. Next.js가 같은 페이지 렌더링 중의 동일 fetch를 중복 제거 (deduplication) 해 줍니다.
캐시 옵션 — cache와 next.revalidate
#
fetch의 두 번째 인자로 캐싱 동작을 제어할 수 있습니다.
// 1. 영구 캐시 (정적 데이터, 거의 안 바뀜)
fetch(url, { cache: 'force-cache' });
// 2. 캐시 안 함 (매번 새로 요청)
fetch(url, { cache: 'no-store' });
// 3. N초마다 재검증 (자주 바뀌는 데이터)
fetch(url, { next: { revalidate: 60 } });
// 4. 태그 기반 재검증 (수동 무효화 가능)
fetch(url, { next: { tags: ['posts'] } });각각 어떤 상황에 쓰는지.
force-cache— 빌드 타임에 한 번 가져와 영구 캐시. 거의 안 바뀌는 데이터 (정적 페이지 정보, 카테고리 목록 등)no-store— 항상 새로 가져옴. 사용자별로 다른 데이터, 실시간성 중요한 정보revalidate: 60— 60초 동안은 캐시, 그 이후 첫 요청에서 백그라운드 갱신. 블로그 글 목록 같은 “거의 정적이지만 가끔 바뀜”tags— 코드에서revalidateTag('posts')를 호출해 수동으로 무효화. 글이 등록되거나 삭제될 때
기본값은 Next.js 15부터 no-store (즉, 캐시 안 함)로 바뀌었습니다. 이전 버전을 따라 하는 자료에서 force-cache가 디폴트라고 적혀 있을 수 있는데, 최신 기준으로는 명시적으로 cache 옵션을 설정 하는 게 좋습니다.
cache: 'no-store'로 시작해 안전하게 동작시킨 후, 성능이 필요할 때 캐시를 추가해 나가는 접근이 안전합니다.라우트 레벨 옵션 — revalidate, dynamic
#
페이지 단위로 동작을 제어할 수도 있습니다.
export const revalidate = 60; // 이 페이지 전체를 60초마다 재생성
export default async function PostsPage() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return <ul>{/* ... */}</ul>;
}export const dynamic = 'force-dynamic'; // 매 요청마다 새로 렌더 (캐시 안 함)
페이지 전체의 캐시 정책을 한 줄로 표현하는 것입니다. 데이터가 자주 바뀌는 페이지에는 dynamic을, 거의 안 바뀌는 페이지에는 revalidate를 사용합니다.
병렬 페칭 — Promise.all #
여러 데이터를 가져와야 할 때, 순차 await을 쓰면 워터폴이 됩니다.
const user = await getUser(id); // 100ms 소요
const posts = await getPosts(id); // 또 100ms. 총 200ms
데이터들이 서로 의존하지 않는다면 병렬로 가져오는 게 좋습니다.
const [user, posts] = await Promise.all([
getUser(id),
getPosts(id),
]);
// 둘이 동시에 시작 → 100ms (가장 느린 쪽 기준)
Promise.all로 묶으면 두 요청이 동시에 시작됩니다. Server Component에서 흔히 쓰는 패턴입니다.
더 좋은 방법 — 컴포넌트별로 분리 #
각 데이터를 자기를 사용하는 컴포넌트에서 직접 가져오면, Next.js의 자동 dedup과 결합해 매우 자연스러운 병렬 처리가 됩니다.
type Props = {
params: Promise<{ id: string }>;
};
export default async function UserPage({ params }: Props) {
const { id } = await params;
return (
<div>
<UserHeader userId={id} />
<UserPosts userId={id} />
</div>
);
}
async function UserHeader({ userId }: { userId: string }) {
const user = await getUser(userId);
return <h1>{user.name}</h1>;
}
async function UserPosts({ userId }: { userId: string }) {
const posts = await getPosts(userId);
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}Server Component는 자기 자신이 async 함수 일 수 있어서, 위처럼 하위 컴포넌트도 각자 데이터 페칭을 할 수 있습니다. 리액트가 이들을 병렬로 실행해 주고요.
이 패턴의 또 다른 장점은 각 부분이 자기 데이터에만 의존 한다는 점입니다. UserHeader는 UserPosts의 데이터가 늦게 오든 말든 자기 일을 진행할 수 있고, 다음 26장의 Suspense와 결합하면 빠르게 준비된 부분부터 화면에 보여 주는 streaming도 가능해집니다.
에러 처리 — error.tsx #
Server Component에서 페칭이 실패하면 그냥 throw 하면 됩니다. Next.js는 가까운 error.tsx를 찾아 보여 줍니다.
'use client';
type Props = {
error: Error & { digest?: string };
reset: () => void;
};
export default function ErrorBoundary({ error, reset }: Props) {
return (
<div style={{ padding: '24px' }}>
<h2>문제가 발생했습니다.</h2>
<p>{error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}이 파일이 있으면 /posts 또는 그 하위 페이지에서 에러가 나면 이 화면이 보입니다. 'use client'가 필요한 이유는 reset이 클라이언트 사이드 동작 (onClick 이벤트 등)이기 때문입니다.
직접 해보기 — GitHub API로 작은 사이트 #
23장의 사이트를 진짜 데이터를 가져오는 형태로 발전시켜 봅시다. GitHub의 공개 API를 사용합니다 (인증 없이 시간당 제한이 있지만 학습용으로 충분).
src/app/repos/[owner]/[repo]/page.tsx:
type Repo = {
full_name: string;
description: string | null;
stargazers_count: number;
forks_count: number;
watchers_count: number;
language: string | null;
html_url: string;
};
type Props = {
params: Promise<{ owner: string; repo: string }>;
};
export default async function RepoPage({ params }: Props) {
const { owner, repo } = await params;
const data: Repo = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{ next: { revalidate: 300 } } // 5분 캐시
).then(res => {
if (!res.ok) throw new Error('Repo not found');
return res.json();
});
return (
<div style={{ padding: '24px' }}>
<h1>{data.full_name}</h1>
<p>{data.description}</p>
<ul>
<li>⭐ {data.stargazers_count.toLocaleString()}</li>
<li>🍴 {data.forks_count.toLocaleString()}</li>
<li>👁 {data.watchers_count.toLocaleString()}</li>
<li>주 언어: {data.language ?? '미상'}</li>
</ul>
<a href={data.html_url} target="_blank" rel="noopener">GitHub에서 보기</a>
</div>
);
}src/app/repos/error.tsx:
'use client';
type Props = {
error: Error & { digest?: string };
reset: () => void;
};
export default function ErrorBoundary({ error, reset }: Props) {
return (
<div style={{ padding: '24px' }}>
<h2>저장소를 찾을 수 없습니다</h2>
<p style={{ color: '#888', fontSize: '14px' }}>{error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}src/app/page.tsx에 링크들을 추가:
import Link from 'next/link';
export default function HomePage() {
return (
<div style={{ padding: '24px' }}>
<h1>GitHub 저장소 보기</h1>
<ul>
<li><Link href="/repos/facebook/react">facebook/react</Link></li>
<li><Link href="/repos/vercel/next.js">vercel/next.js</Link></li>
<li><Link href="/repos/curtisdev/this-does-not-exist">존재하지 않는 저장소</Link></li>
</ul>
</div>
);
}각 링크를 눌러 보세요.
- 정상 저장소: GitHub에서 가져온 정보가 화면에 표시됨
- 존재하지 않는 저장소:
error.tsx가 가로채서 에러 화면이 표시됨 - 5분 안에 다시 방문: 캐시된 결과가 즉시 표시됨
여기서 일어난 모든 일이 서버에서입니다. 브라우저로 가는 자바스크립트는 거의 없습니다. 페이지를 보는 사용자 입장에선 일반 정적 HTML과 구분이 안 되는 빠른 응답이고, 개발자 입장에선 그냥 await fetch(...) 한 줄로 끝나는 단순한 코드입니다.
연습문제 #
- dedup 직접 확인. 위 RepoPage에서 같은 fetch를 두 번 호출하도록 만들어 보세요.
console.time/console.timeEnd로 시간을 재 보고, 캐시 옵션을cache: 'no-store'로 바꿔서 동일 fetch가 두 번 실제로 발생하는지 비교합니다. dev 서버 콘솔에 찍히는 fetch 횟수도 같이 관찰해 보세요. - revalidate 옵션 실험.
repos/[owner]/[repo]/page.tsx의revalidate: 300을revalidate: 5로 바꾼 뒤, 페이지를 새로고침할 때 GitHub API의X-RateLimit-Remaining헤더가 5초 단위로만 줄어드는지 확인합니다. 그 후 페이지에export const dynamic = 'force-dynamic'을 추가해 매 요청마다 새로 fetch 되도록 바꿔 보세요. - 병렬 페칭 vs 컴포넌트별 분리. 한 페이지에서 두 사용자 정보를 가져오는 코드를 (a)
Promise.all로 묶는 방식과 (b) 두 자식 Server Component가 각자 fetch 하는 방식으로 각각 작성하고, dev tools의 네트워크 탭에서 요청 타이밍을 비교합니다. 둘이 거의 같은 시간을 보이는 이유를 본문 내용 (자동 dedup + 자식 컴포넌트 병렬 실행)으로 설명해 보세요.
한 줄 요약: Server Component의 데이터 페칭은
async function+await fetch(...)한 줄이면 끝이고, 로딩 / 에러 / race condition 보일러플레이트가 사라진다. Next.js의fetch는 요청 내 자동 dedup과 함께cache/next.revalidate/next.tags로 캐시 정책을 세밀하게 제어한다 (Next.js 15부터는 명시적 지정이 안전). 라우트 레벨에는export const revalidate와export const dynamic이 있다. 독립 데이터는Promise.all또는 자식 컴포넌트별로 fetch를 분리해 자연스럽게 병렬 처리하고, 에러는 throw →error.tsx가 잡는다.
다음 챕터 #
다음 26장 Suspense와 use()에서는 본 챕터에서 만든 페이지가 모든 데이터가 다 도착할 때까지 흰 화면 인 문제를 풀어 봅니다. <Suspense>와 loading.tsx로 준비된 부분부터 점진적으로 보여 주는 streaming을 익히고, 리액트 19에서 새로 안정화된 use() 훅으로 Server가 만든 Promise를 Client에서 풀어 쓰는 패턴까지 정리합니다.