모던 리액트 + Next.js #4 데이터 페칭과 캐싱

19 분 소요

지난 시간에는 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로 하면 이렇게 됩니다.

src/app/users/[userId]/page.js
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 직접 쿼리

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에서는 자격증명이 클라이언트에 안 가니 안전합니다.

파일 시스템 읽기

MDX 파일 직접 읽기 (이 블로그가 하는 방식)
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해도 실제로는 한 번만 호출됩니다.

동일 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)해줍니다.

캐시 옵션 — cachenext.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

페이지 단위로 동작을 제어할 수도 있습니다.

src/app/posts/page.js
export const revalidate = 60;  // 이 페이지 전체를 60초마다 재생성
 
export default async function PostsPage() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  return /* ... */;
}
dynamic 옵션
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과 결합해 매우 자연스러운 병렬 처리가 됩니다.

src/app/users/[id]/page.js
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가 이들을 병렬로 실행해주고요.

이 패턴의 또 다른 장점은 각 부분이 자기 데이터에만 의존한다는 점입니다. UserHeaderUserPosts의 데이터가 늦게 오든 말든 자기 일을 진행할 수 있고, #5에서 다룰 Suspense와 결합하면 빠르게 준비된 부분부터 화면에 보여주는 streaming도 가능해져요.

에러 처리 — error.js

Server Component에서 페칭이 실패하면 그냥 throw하면 됩니다. Next.js는 가까운 error.js를 찾아 보여줍니다.

src/app/posts/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:

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:

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에 링크들 추가:

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() 훅을 다뤄보겠습니다.