모던 리액트 + Next.js #4 데이터 페칭과 캐싱
지난 시간에는 Server / Client 컴포넌트의 차이와 경계를 다뤘습니다. 이번에는 Server Component의 가장 강력한 능력 — 데이터 페칭이 단순해진다는 점을 본격적으로 살펴봅니다.
클라이언트 사이드 페칭의 복잡함
기억나시나요? #10에서 useEffect로 데이터를 가져올 때의 패턴.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(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>;
return <p>{user.name}</p>;
}이게 표준 패턴이었죠. 3개의 state, useEffect, race condition 처리, 로딩/에러 분기까지 — 같은 작업의 보일러플레이트가 매번 반복됩니다.
Server Component에서는
같은 일을 Server Component로 하면 이렇게 됩니다.
export default async function UserProfile({ params }) {
const { userId } = await params;
const 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.js가 잡음
이 단순함이 Server Component가 풀어내는 핵심 가치 중 하나입니다.
직접 fetch 외의 옵션들
Server Component는 서버에서 실행되니, 클라이언트가 못 하는 일도 가능합니다.
DB 직접 쿼리
import { db } from '@/lib/db';
export default async function PostPage({ params }) {
const { slug } = await params;
const post = await db.query('SELECT * FROM posts WHERE slug = $1', [slug]);
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}API를 별도로 안 만들어도 됩니다. 클라이언트 사이드 React에서는 절대 못 하는 일이에요 — 브라우저에서 DB 자격증명을 노출하면 보안 사고죠. Server Component에서는 자격증명이 클라이언트에 안 가니 안전합니다.
파일 시스템 읽기
import fs from 'fs';
import path from 'path';
export default async function PostPage({ params }) {
const { slug } = await params;
const filePath = path.join(process.cwd(), 'posts', `${slug}.mdx`);
const content = fs.readFileSync(filePath, 'utf-8');
// ... MDX 컴파일 ...
}이 블로그(schoolofweb.net)도 정확히 이 방식으로 동작합니다. posts/ 폴더의 MDX 파일을 Server Component에서 직접 읽어 컴파일해 화면에 그립니다.
Next.js의 fetch 캐싱
Next.js는 fetch를 한 번 더 감싸서 자동 캐싱을 제공합니다. 같은 URL을 여러 번 fetch해도 (같은 요청 내라면) 한 번만 실제 호출이 일어납니다. 그리고 빌드 시점이나 런타임에 캐싱 동작도 제어할 수 있어요.
기본 동작 — 요청 내 dedup
같은 페이지 안에서 여러 컴포넌트가 같은 데이터를 fetch해도 실제로는 한 번만 호출됩니다.
async function getUser(id) {
return fetch(`/api/users/${id}`).then(r => r.json());
}
export default async function Page() {
const userA = await getUser(1); // 실제 호출
const userB = await getUser(1); // 캐시에서 가져옴 (자동)
// ...
}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 옵션을 설정하는 게 좋습니다.
캐싱은 Next.js의 가장 강력한 기능 중 하나이자 가장 헷갈리는 부분이기도 합니다. 이 글에서는 기본만 다루고, 본격적인 캐시 전략은 실제 프로젝트의 요구사항에 맞춰 결정하세요. 처음에는 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 /* ... */;
}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과 결합해 매우 자연스러운 병렬 처리가 됩니다.
export default async function UserPage({ params }) {
const { id } = await params;
return (
<div>
<UserHeader userId={id} />
<UserPosts userId={id} />
</div>
);
}
async function UserHeader({ userId }) {
const user = await getUser(userId);
return <h1>{user.name}</h1>;
}
async function UserPosts({ userId }) {
const posts = await getPosts(userId);
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}Server Component는 자기 자신이 async 함수일 수 있어서, 위처럼 하위 컴포넌트도 각자 데이터 페칭을 할 수 있습니다. React가 이들을 병렬로 실행해주고요.
이 패턴의 또 다른 장점은 각 부분이 자기 데이터에만 의존한다는 점입니다. UserHeader는 UserPosts의 데이터가 늦게 오든 말든 자기 일을 진행할 수 있고, #5에서 다룰 Suspense와 결합하면 빠르게 준비된 부분부터 화면에 보여주는 streaming도 가능해져요.
에러 처리 — error.js
Server Component에서 페칭이 실패하면 그냥 throw하면 됩니다. Next.js는 가까운 error.js를 찾아 보여줍니다.
'use client';
export default function ErrorBoundary({ error, reset }) {
return (
<div style={{ padding: '24px' }}>
<h2>문제가 발생했습니다.</h2>
<p>{error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}이 파일이 있으면 /posts 또는 그 하위 페이지에서 에러가 나면 이 화면이 보입니다. 'use client'가 필요한 이유는 reset이 클라이언트 사이드 동작이기 때문이에요.
동작 확인 — GitHub API로 작은 사이트
지난 글의 사이트를 진짜 데이터를 가져오는 형태로 발전시켜봅시다. GitHub의 공개 API를 사용합니다 (인증 없이 시간당 제한이 있지만 학습용으로 충분).
src/app/repos/[owner]/[repo]/page.js:
export default async function RepoPage({ params }) {
const { owner, repo } = await params;
const data = 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.js:
'use client';
export default function ErrorBoundary({ error, reset }) {
return (
<div style={{ padding: '24px' }}>
<h2>저장소를 찾을 수 없습니다</h2>
<p style={{ color: '#888', fontSize: '14px' }}>{error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}src/app/page.js에 링크들 추가:
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.js가 가로채서 에러 화면이 표시됨 - 5분 안에 다시 방문: 캐시된 결과가 즉시 표시됨
여기서 일어난 모든 일이 서버에서입니다. 브라우저로 가는 자바스크립트는 거의 없어요. 페이지를 보는 사용자 입장에선 일반 정적 HTML과 구분이 안 되는 빠른 응답이고, 개발자 입장에선 그냥 await fetch(...) 한 줄로 끝나는 단순한 코드입니다.
마무리
이번 글에서는 Server Component의 데이터 페칭을 다뤘습니다.
async function컴포넌트 +await fetch(...)로 클라이언트 페칭의 보일러플레이트 사라짐- DB / 파일 시스템 / 환경변수 등 서버 자원에 직접 접근 가능
- Next.js의
fetch는 자동 dedup + 캐시 옵션 (cache,next.revalidate,tags) - 라우트 레벨 옵션 (
export const revalidate,export const dynamic) - 독립 데이터는
Promise.all또는 컴포넌트별 분리로 병렬 처리 - 에러는 throw →
error.js가 잡음
지금까지 우리가 만든 페이지는 모든 데이터가 다 도착해야 비로소 화면이 보였습니다. 한쪽 데이터가 느리면 빠른 쪽까지 같이 기다려야 했죠. 다음 글인 "모던 리액트 + Next.js #5 Suspense와 use()로 로딩 처리"에서는 준비된 부분부터 점진적으로 보여주는 streaming, Suspense, loading.js, 그리고 새로 등장한 use() 훅을 다뤄보겠습니다.