Ray Book
Next.js 깊이 보기

데이터 패칭과 캐싱의 진화

getServerSideProps와 getStaticProps에서 fetch 확장, 그리고 Cache Components까지, Next.js가 데이터 처리 모델을 어떻게 바꿔왔는지 정리합니다.

nextjsdata-fetchingcachingcache-components

초창기 Next.js의 데이터 패칭

Next.js의 데이터 패칭 API는 프레임워크가 진화하면서 함께 변해왔습니다. 각 단계에는 분명한 이유가 있었습니다.

getInitialProps 시대 (2016~2019). 초기 Next.js는 getInitialProps라는 정적 메서드를 사용했습니다. 서버와 클라이언트 양쪽에서 실행되는 이 함수는 단순했지만 한 가지 큰 문제가 있었습니다, 같은 코드가 서버와 클라이언트에서 모두 돌아간다 는 것입니다. 서버 전용 라이브러리를 쓸 수 없었고, 환경 변수 접근이 애매했고, 번들 최적화도 어려웠습니다.

getServerSidePropsgetStaticProps 시대 (2020~2022). Next.js 9.3 (2020년 3월) 에서 두 함수가 도입되면서 상황이 명확해졌습니다. 이 함수들은 서버에서만 실행 되는 것이 보장되었고, 이름 그대로 "요청마다 서버 렌더링"과 "빌드 시 정적 생성"을 구분해서 선택할 수 있었습니다.

// pages/posts/[id].js
export async function getStaticProps({ params }) {
  const post = await db.posts.findById(params.id);
  return {
    props: { post },
    revalidate: 60, // ISR: 60초마다 재생성
  };
}

export async function getStaticPaths() {
  return {
    paths: [{ params: { id: "1" } }],
    fallback: "blocking",
  };
}

export default function Post({ post }) {
  return <article>{post.content}</article>;
}

9.3에는 revalidate 옵션과 함께 Incremental Static Regeneration (ISR) 의 베타도 포함되었고, Next.js 9.5 (2020년 7월) 에서 안정화되었습니다. 정적과 동적 사이의 스펙트럼이 넓어졌습니다.

하지만 이 모델에도 한계가 있었습니다.

페이지 단위였습니다. 데이터 패칭은 오직 페이지 컴포넌트에서만 할 수 있었고, 각 페이지는 하나의 getServerSideProps 또는 getStaticProps만 가질 수 있었습니다. 페이지 안의 하위 컴포넌트가 자기만의 데이터를 가져오고 싶다면, 그 데이터까지 페이지 레벨에서 전부 가져와서 props로 내려줘야 했습니다.

"모 아니면 도"였습니다. 한 페이지의 일부는 정적이고 일부는 동적이고 싶어도, 그런 혼합은 불가능했습니다. 페이지 전체가 정적이든 동적이든 둘 중 하나였습니다.

데이터 요청이 중복되기 쉬웠습니다. 같은 API를 여러 컴포넌트에서 쓰려면, 한 곳에서 미리 가져와서 props로 전달하거나, 클라이언트에서 따로 가져와야 했습니다. 자연스러운 중복 제거가 없었습니다.

App Router의 새로운 모델, 컴포넌트 레벨 데이터 패칭

App Router와 Server Components가 등장하면서 데이터 패칭의 모델이 근본적으로 바뀌었습니다. 이제 어떤 Server Component든 async 함수가 될 수 있고, 그 안에서 직접 fetch나 DB 쿼리를 할 수 있습니다.

// app/posts/[id]/page.tsx
export default async function Post({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const post = await db.posts.findById(id);
  return <article>{post.content}</article>;
}

더 이상 getServerSideProps도, getStaticProps도 없습니다. 그냥 컴포넌트 안에서 await하면 됩니다. 그리고 이 패턴은 페이지뿐만 아니라 모든 Server Component 에서 동작합니다.

// app/posts/[id]/page.tsx
export default async function Post({ params }) {
  const { id } = await params;
  const post = await db.posts.findById(id);
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <Comments postId={id} /> {/* 이 컴포넌트도 자기 데이터를 가져옴 */}
    </article>
  );
}

// 자식 컴포넌트
async function Comments({ postId }: { postId: string }) {
  const comments = await db.comments.findByPost(postId);
  return (
    <ul>
      {comments.map((c) => (
        <li key={c.id}>{c.text}</li>
      ))}
    </ul>
  );
}

각 컴포넌트가 자기에게 필요한 데이터를 스스로 가져옵니다. 페이지 레벨에서 모든 데이터를 모아서 내려주는 패턴이 사라졌습니다. 데이터 패칭이 UI 컴포넌트의 관심사 안에 자연스럽게 녹아든 것 입니다.

fetch의 확장, Next.js의 독특한 선택

App Router에서 Next.js는 표준 fetch API를 확장했습니다. 같은 fetch 함수이지만, Next.js 환경에서는 추가 옵션을 받습니다.

// 기본값: 라우트가 정적 prerender 가능하면 빌드 시 한번 fetch, 그 외엔 매 요청마다
const res = await fetch("https://api.example.com/data");

// 명시적으로 캐시 (Data Cache에 저장)
const res = await fetch("https://api.example.com/data", {
  cache: "force-cache",
});

// 매 요청마다 새로 가져옴
const res = await fetch("https://api.example.com/data", {
  cache: "no-store",
});

// 60초마다 재검증 (ISR과 유사)
const res = await fetch("https://api.example.com/data", {
  next: { revalidate: 60 },
});

// 태그 기반 재검증
const res = await fetch("https://api.example.com/data", {
  next: { tags: ["posts"] },
});

Next.js 13~14에서는 fetch의 기본값이 force-cache 였습니다. Next.js 15에서 이 기본값이 바뀌어, 지금은 라우트의 정적 prerendering 가능성과 request-time API 사용 여부에 따라 캐시 여부가 결정됩니다. 예전처럼 기본 캐싱을 원하면 { cache: 'force-cache' } 를 명시해야 합니다.

이 확장은 의도적입니다. Next.js 팀은 별도의 데이터 패칭 함수를 만드는 대신, 개발자가 이미 아는 fetch API를 그대로 확장 하는 길을 택했습니다. 학습 곡선이 낮고, 기존 코드와 자연스럽게 통합됩니다.

Request Memoization, 자동 중복 제거

여러 Server Component가 같은 URL로 fetch를 호출하면, Next.js는 한 번의 요청으로 자동 병합 합니다. 이것을 Request Memoization 이라고 부릅니다.

// app/layout.tsx
export default async function Layout({ children }) {
  const user = await getUser(); // fetch 호출
  return (
    <>
      <Header userName={user.name} />
      {children}
    </>
  );
}

// app/page.tsx
export default async function Page() {
  const user = await getUser(); // 같은 fetch 호출
  return <div>{user.email}</div>;
}

두 컴포넌트가 같은 getUser() 를 호출하지만, Next.js는 이를 하나의 네트워크 요청으로 처리합니다. 렌더링 도중 같은 URL 조합을 감지하면 결과를 재사용합니다.

이 덕분에 개발자는 "데이터 중복 방지"를 의식하지 않아도 됩니다. 각 컴포넌트는 자기가 필요한 데이터를 자유롭게 요청하고, Next.js가 최적화를 알아서 해줍니다.

Data Cache, 요청 사이의 캐싱

Request Memoization이 한 요청 내 의 최적화라면, Data Cache여러 요청에 걸친 캐싱입니다. fetch 응답을 Next.js의 Data Cache에 저장해 두고, 다음 요청 시 동일한 URL + 옵션 조합이면 캐시에서 바로 반환할 수 있습니다.

  • { cache: 'force-cache' } , 캐시가 있으면 사용, 없으면 가져와서 캐시 (13~14에서는 기본값이었음)
  • { cache: 'no-store' } , 캐시 사용 안 함, 매번 새로 가져옴 (15에서 기본값으로 바뀜)
  • { next: { revalidate: N } } , N초 동안 캐시 사용, 이후 재검증

이 시스템이 getStaticPropsrevalidate, getServerSideProps 같은 이전 API들을 하나로 통합합니다. 개발자는 데이터마다 캐싱 전략을 세밀하게 설정할 수 있습니다.

태그 기반 재검증

특정 이벤트가 발생했을 때 캐시를 수동으로 무효화하고 싶을 때가 있습니다. 예를 들어 블로그 글이 업데이트되면 관련 캐시를 즉시 갱신해야 합니다.

// 데이터를 fetch할 때 태그 지정
const posts = await fetch("https://api.example.com/posts", {
  next: { tags: ["posts"] },
});

// 다른 곳에서 수동 재검증
import { revalidateTag } from "next/cache";

async function updatePost() {
  "use server";
  await db.posts.update(...);
  revalidateTag("posts"); // 'posts' 태그가 붙은 모든 캐시 무효화
}

이 메커니즘으로 CMS에서 콘텐츠를 수정했을 때, 관련된 페이지 캐시를 일괄 갱신할 수 있습니다.

Next.js 16, Cache Components로의 진화

Next.js 16 (2025년 10월) 은 캐싱 모델을 한번 더 크게 바꿨습니다. Cache Components 라는 새로운 개념을 도입한 것입니다. 이것은 이전의 "fetch 확장 + 암묵적 캐싱" 모델의 한계에서 나왔습니다.

이전 모델의 문제

13~14 버전의 App Router에서는 fetch 기본값이 캐시 였습니다. fetch로 가져온 데이터는 기본적으로 캐싱되었고, 개발자는 "캐시하지 않을 것"을 명시적으로 표시해야 했습니다 (cache: 'no-store').

이 모델은 직관에 반하는 부분이 있었습니다. 일반적으로 개발자는 "데이터를 가져왔으니 최신 상태일 것" 이라고 기대하는데, Next.js는 몰래 캐시된 값을 돌려줬습니다. 그래서 이런 혼란이 자주 발생했습니다.

  • "왜 내 데이터가 업데이트 안 되지?", 답: 기본 캐싱 때문
  • "어떤 걸 캐시하고 어떤 걸 안 하는지 헷갈린다", 암묵적 규칙이 많음

Next.js 15 (2024년 10월) 는 이 문제를 일부 개선했습니다, fetch와 GET Route Handler의 기본값을 "캐시하지 않음"으로 바꾼 것입니다. 하지만 여전히 세그먼트 레벨의 캐싱 규칙, Full Route Cache, 정적/동적 판정 로직 등이 얽혀 있어 전체 동작은 암묵적이었습니다. Next.js 16은 이 암묵성 자체를 걷어내는 방향으로 갑니다.

Cache Components, 명시적 캐싱

Cache Components는 이 방향을 뒤집었습니다. 기본은 동적이고, 캐시는 명시적으로 선언합니다.

// next.config.ts
const nextConfig = {
  cacheComponents: true,
};

cacheComponents 플래그를 켜면, 모든 페이지와 라우트는 기본적으로 요청 시에 실행됩니다. 캐시를 원하면 "use cache" 지시문으로 명시합니다.

"use cache";

async function getBlogPosts() {
  return await db.posts.findAll();
}

이 함수는 호출되면 결과가 캐시됩니다. 다음에 같은 인자로 호출되면 캐시에서 바로 반환됩니다. "use cache"는 함수, 페이지, 컴포넌트 어디에나 붙일 수 있습니다.

// 페이지 전체를 캐시
"use cache";
export default async function Page() {
  const posts = await db.posts.findAll();
  return <PostList posts={posts} />;
}
// 컴포넌트 단위 캐싱
"use cache";
async function RecentPosts() {
  const posts = await db.posts.recent();
  return <PostList posts={posts} />;
}

Cache Components는 Partial Prerendering (PPR) 의 개념을 완성하는 모델입니다. 한 페이지 안에서 어떤 부분은 정적으로 미리 렌더링하고, 어떤 부분은 요청마다 동적으로 실행하는 혼합 모델이 자연스럽게 표현됩니다.

새로운 재검증 API

Next.js 16에서 revalidateTag()의 시그니처가 바뀌었습니다. 이제 두 번째 인자로 cacheLife 프로필이 필수 입니다. 기존의 단일 인자 형태 (revalidateTag("posts")) 는 deprecated 되었습니다.

import { revalidateTag } from "next/cache";

// stale-while-revalidate 프로필 사용
revalidateTag("posts", "max");

// 커스텀 만료 시간
revalidateTag("products", { expire: 3600 });

// ⚠️ 16부터 deprecated
revalidateTag("posts");

"max" 프로필은 stale-while-revalidate 동작을 활성화합니다, 사용자에게는 캐시된 값을 즉시 주고, 백그라운드에서 새 값을 가져옵니다. 긴 수명의 콘텐츠에 적합합니다.

또한 updateTag() 라는 새 API도 추가되었습니다. 이것은 Server Actions 안에서만 사용 가능하며, read-your-writes 시맨틱을 제공합니다.

"use server";

import { updateTag } from "next/cache";

export async function updateUserProfile(userId: string, profile: Profile) {
  await db.users.update(userId, profile);
  updateTag(`user-${userId}`); // 캐시 만료 + 즉시 새 데이터 읽기
}

updateTag()는 캐시를 무효화하고 즉시 새 데이터를 읽을 수 있게 합니다. 폼 제출 후 사용자가 자기가 방금 한 변경을 바로 보고 싶을 때 적합합니다.

무엇이 바뀌었나

getInitialProps에서 getServerSideProps로, 그리고 fetch 확장에서 Cache Components로, Next.js의 데이터 패칭 모델은 약 10년간 계속 진화해왔습니다. 각 단계의 변화를 정리하면 이렇습니다.

시대모델특징
2016~2019getInitialProps서버/클라이언트 공통. 단순하지만 경계 모호
2020~2022getServerSideProps / getStaticProps서버 전용. SSR/SSG 명확, ISR 도입
2022~2025fetch 확장 + App Router컴포넌트 단위, 자동 중복 제거, 암묵적 캐싱
2025~Cache Components + "use cache"기본 동적, 캐시는 명시적

변화의 방향은 일관됩니다. 더 세밀하게, 더 명시적으로, 더 유연하게. 페이지 단위에서 컴포넌트 단위로, 암묵적 동작에서 명시적 선언으로, 이분법적 선택에서 혼합 모델로, 모두 같은 방향의 진보입니다.

다음 단계

이 시리즈의 다음 글에서는 렌더링 전략 자체에 집중합니다. SSR, SSG, ISR, 그리고 Partial Prerendering (PPR) 이 각각 어떤 문제를 해결하기 위해 등장했는지, 그리고 Cache Components가 어떻게 이들을 통합하는 모델이 되는지를 살펴봅니다.