Next.js Cache Components 정리

Next.js Docs – Getting Started: Cache Components 내용을 바탕으로, App Router 기준의 Cache Components 개념을 정리한 자료입니다.


1. 개요

  • Cache Components는 옵트인 기능입니다.
    • next.config.(js|ts)cacheComponents 옵션을 true로 설정해야 동작합니다.
  • 하나의 라우트에서 정적(static)·캐시된(cached)·동적(dynamic) 콘텐츠를 섞어서 사용할 수 있도록 하는 기능입니다.
  • 전통적인 서버 렌더링 앱의 문제:
    • 정적 페이지: 빠르지만, 데이터가 금방 낡음(stale).
    • 동적 페이지: 항상 최신이지만, 느리고 서버 부담 큼.
    • 이 일을 클라이언트로 넘기면, 번들이 커지고 최초 렌더가 느려짐.
  • Cache Components는 라우트를 “정적 HTML 쉘(static HTML shell)”로 미리 렌더링한 후, 동적 부분은 준비되는 대로 UI를 업데이트하는 방식으로 이 문제를 해결합니다.

이 렌더링 방식은 **Partial Prerendering (PPR)**이라고 부르며, Cache Components를 활성화하면 기본 동작이 됩니다.


2. Cache Components에서 렌더링이 동작하는 방식

2.1 Prerendering과 Static Shell

  • 빌드 시점에 Next.js가 라우트의 컴포넌트 트리를 렌더링합니다.
  • 다음과 같은 작업만 사용하는 컴포넌트는 자동으로 static shell에 포함됩니다.
    • 동기 I/O (예: 동기 파일 읽기)
    • 모듈 import
    • 순수 계산 (pure computations)
  • 반대로, 다음과 같은 경우는 자동으로 처리되지 않으므로 직접 처리 방법을 선택해야 합니다.
    • 네트워크 요청 (fetch)
    • 일부 시스템 API 사용
    • 요청(request) 객체가 필요한 경우 등

이때 선택지는 두 가지입니다.

  1. <Suspense>로 감싸서 요청 시점(request time)에 렌더링을 미루기
  2. use cache 지시문을 사용해 결과를 캐시하고 static shell에 포함시키기 (요청 데이터가 필요 없는 경우)
  • 이 작업은 요청이 오기 전에 미리 수행되므로 prerendering이라고 부릅니다.

  • 결과로 만들어지는 것은:

    • 최초 진입용 HTML static shell
    • 클라이언트 측 내비게이션에 사용되는 RSC Payload → 사용자가 URL로 직접 들어오든, 페이지 간 이동을 하든 곧바로 렌더된 콘텐츠를 받도록 보장합니다.
  • 만약 prerendering 과정에서 처리할 수 없는 작업을

    • <Suspense>로 감싸지 않거나
    • use cache로 표시하지 않으면
      개발·빌드 시점에 Uncached data was accessed outside of <Suspense> 에러가 발생합니다.

3. 자동으로 프리렌더되는 콘텐츠 (Automatically prerendered content)

다음처럼 동기 I/O + 모듈 import + 순수 계산만 사용하는 컴포넌트는 자동으로 static shell에 포함됩니다.

// page.tsx
import fs from 'node:fs'

export default async function Page() {
  // 동기 파일 읽기
  const content = fs.readFileSync('./config.json', 'utf-8')

  // 모듈 import
  const constants = await import('./constants.json')

  // 순수 계산
  const processed = JSON.parse(content).items.map((item) => item.value * 2)

  return (
    <div>
      <h1>{constants.appName}</h1>
      <ul>
        {processed.map((value, i) => (
          <li key={i}>{value}</li>
        ))}
      </ul>
    </div>
  )
}
  • 레이아웃과 페이지 둘 다 정상적으로 prerender되면, 해당 라우트 전체가 static shell이 됩니다.
  • 빌드 로그 요약이나 브라우저의 페이지 소스보기로, 어떤 내용이 static shell에 포함됐는지 확인할 수 있습니다.

4. 요청 시점으로 렌더링 미루기 (Defer rendering to request time)

빌드 시점에 완료할 수 없는 작업(네트워크 요청, 런타임 데이터 등)은 명시적으로 처리해야 합니다.

  • 부모 컴포넌트가 <Suspense> boundary를 제공해야 합니다.
    • fallback UI는 static shell에 포함되고,
    • 실제 콘텐츠는 요청 시점에 스트리밍됩니다.
  • Suspense boundary는 가능한 해당 컴포넌트에 가깝게 배치하는 것이 좋습니다.
    → static shell에 최대한 많은 내용을 포함시키기 위해서입니다.

<Suspense>를 통해 여러 동적 영역을 병렬로 렌더링할 수 있어 전체 로딩 시간이 줄어듭니다.

4.1 Dynamic content

  • 외부 시스템에서 가져오는 데이터는
    • 응답 시간이 예측 불가능하고,
    • 실패할 수도 있습니다.
  • 따라서 prerendering에서는 자동으로 실행되지 않습니다.

최신 데이터(실시간 피드, 개인화된 콘텐츠 등)가 필요하다면, <Suspense>를 사용하여 fallback UI를 static shell에 포함시키고, 실제 콘텐츠는 요청 시점에 스트리밍하는 방식으로 처리합니다.

// DynamicContent: 자동 프리렌더 대상이 아닌 작업들
async function DynamicContent() {
  // 네트워크 요청
  const data = await fetch('https://api.example.com/data')

  // DB 쿼리
  const users = await db.query('SELECT * FROM users')

  // 비동기 파일 읽기
  const file = await fs.readFile('..', 'utf-8')

  // 외부 시스템 지연 시뮬레이션
  await setTimeout(100)

  return <div>Not in the static shell</div>
}

// Page: Suspense로 감싸기
export default async function Page() {
  return (
    <>
      <h1>Part of the static shell</h1>
      {/* 아래 fallback은 static shell에 포함 */}
      <Suspense fallback={<p>Loading..</p>}>
        <DynamicContent />
        <div>Sibling excluded from static shell</div>
      </Suspense>
    </>
  )
}
  • prerendering은 fetch에서 멈추며, 요청도 시작되지 않습니다.
  • <p>Loading..</p>는 static shell에 포함되고, DynamicContent 결과는 요청 시점에 스트리밍됩니다.

자주 변하지 않는 동적 데이터라면, <Suspense> 대신 use cache를 사용해 static shell에 포함할 수도 있습니다.

4.2 Runtime data

런타임 데이터는 요청 컨텍스트가 있어야만 접근 가능한 데이터입니다.

예:

  • cookies() – 사용자의 쿠키
  • headers() – 요청 헤더
  • searchParams – URL 쿼리 파라미터
  • params – 동적 라우트 파라미터
    (단, generateStaticParams로 샘플을 제공한 경우 일부는 정적으로 가능)

이런 데이터를 사용하는 컴포넌트는 반드시 <Suspense>로 감싸야 합니다.

중요: Runtime data는 use cache와 같은 스코프에서 함께 사용할 수 없으며,
무조건 <Suspense>로 감싼 동적 영역에서만 사용해야 합니다.

4.3 비결정적 연산 (Non-deterministic operations)

  • Math.random(), Date.now(), crypto.randomUUID(), crypto.getRandomValues() 등은 실행할 때마다 값이 달라집니다.
  • Cache Components에서는 이러한 연산이 요청 시점에 실행되도록 명시적으로 표현해야 합니다.
    • 이를 위해 connection() 또는 runtime data를 먼저 호출하여, “지금은 request time 컨텍스트다”라고 표시합니다.
import { connection } from 'next/server'

async function UniqueContent() {
  // 요청 시점으로 명시적 defer
  await connection()

  const random = Math.random()
  const now = Date.now()
  const date = new Date()
  const uuid = crypto.randomUUID()
  const bytes = crypto.getRandomValues(new Uint8Array(16))

  return (
    <div>
      <p>{random}</p>
      <p>{now}</p>
      <p>{date.getTime()}</p>
      <p>{uuid}</p>
      <p>{bytes}</p>
    </div>
  )
}
  • 이 컴포넌트 역시 <Suspense>로 감싸야 하며,
    각 요청마다 다른 난수·시간·UUID를 보게 됩니다.

5. use cache 지시문 사용 (Using use cache)

use cache 지시문은 비동기 함수와 컴포넌트의 반환값을 캐시합니다.

  • 적용 위치:
    • 함수 스코프
    • 컴포넌트 스코프
    • 파일 전체 스코프
  • 캐시 키:
    • **함수 인자(arguments)**와
    • 상위 스코프에서 클로저로 잡힌 값들이 자동으로 cache key에 포함됩니다.
      → 입력 값에 따라 서로 다른 캐시 엔트리가 생성되므로, 개인화 또는 파라미터 기반 캐시가 가능합니다.

동적 데이터가 매 요청마다 최신일 필요가 없다면:

  • use cache를 사용하여
    • prerendering 시 static shell에 포함하거나
    • 런타임 여러 요청에서 같은 결과를 재사용할 수 있습니다.

캐시된 내용은 두 가지 방식으로 리밸리데이트할 수 있습니다.

  1. cacheLife로 정의한 **수명(lifetime)**에 따라 자동으로
  2. cacheTag + revalidateTag / updateTag를 사용한 태그 기반 온디맨드 리밸리데이트

자세한 제약 사항 및 직렬화 제한은 use cache API 문서를 참고해야 합니다.


5.1 Prerendering 시 use cache 사용 (During prerendering)

  • 상품 목록, 블로그 본문, 과거 날짜의 분석 리포트 등은
    자주 변하지 않는 경우가 많습니다.
  • 런타임 데이터에 의존하지 않는다면, use cache로 감싸서 static shell에 포함할 수 있습니다.
// app/page.tsx
import { cacheLife } from 'next/cache'

export default async function Page() {
  'use cache'
  cacheLife('hours') // 기본 프로필 사용

  const users = await db.query('SELECT * FROM users')

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}
  • cacheLife'hours', 'days', 'weeks' 같은 프로필 이름 또는
    stale, revalidate, expire 값을 가진 설정 객체를 받을 수 있습니다.
import { cacheLife } from 'next/cache'

export default async function Page() {
  'use cache'
  cacheLife({
    stale: 3600,     // 1시간 후 stale
    revalidate: 7200, // 2시간 후 백그라운드 리밸리데이트
    expire: 86400,    // 1일 후 캐시 만료
  })

  const users = await db.query('SELECT * FROM users')

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}
  • 리밸리데이트 시 static shell이 새로운 데이터로 갱신됩니다.

5.2 Runtime data와 함께 사용 (With runtime data)

  • Runtime data와 use cache는 같은 스코프에서 함께 사용할 수 없습니다.
  • 대신, 다음 패턴을 사용합니다.
    1. runtime API를 읽는 컴포넌트는 use cache 없이 실행
    2. 읽어온 값을 인자로 받아 use cache가 선언된 함수/컴포넌트에 전달
// app/profile/page.tsx
import { cookies } from 'next/headers'
import { Suspense } from 'react'

export default function Page() {
  // Page가 동적 boundary 역할
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ProfileContent />
    </Suspense>
  )
}

// runtime data 읽기 (캐시 X)
async function ProfileContent() {
  const session = (await cookies()).get('session')?.value

  return <CachedContent sessionId={session} />
}

// 캐시되는 컴포넌트/함수
async function CachedContent({ sessionId }: { sessionId: string }) {
  'use cache'
  // sessionId는 cache key의 일부가 됨
  const data = await fetchUserData(sessionId)
  return <div>{data}</div>
}
  • 요청 시점에 적절한 캐시 엔트리가 없으면 CachedContent가 실행되고, 결과가 향후 요청을 위해 저장됩니다.

5.3 비결정적 연산과 함께 사용 (With non-deterministic operations)

  • use cache 스코프 내부에서 비결정적 연산을 실행하면,
    prerendering 시 한 번만 실행되어 그 결과가 캐시됩니다.
export default async function Page() {
  'use cache'
  // 한 번만 실행되고, 이후 요청에는 동일한 결과 사용
  const random = Math.random()
  const random2 = Math.random()
  const now = Date.now()
  const date = new Date()
  const uuid = crypto.randomUUID()
  const bytes = crypto.getRandomValues(new Uint8Array(16))

  return (
    <div>
      <p>
        {random} and {random2}
      </p>
      <p>{now}</p>
      <p>{date.getTime()}</p>
      <p>{uuid}</p>
      <p>{bytes}</p>
    </div>
  )
}
  • 캐시가 리밸리데이트되기 전까지 모든 요청은 같은 랜덤 값과 시간, UUID를 보게 됩니다.

6. 태깅과 리밸리데이트 (Tagging and revalidating)

cacheTag로 캐시된 데이터를 태그하고, 이후 Server Action에서 updateTag 또는 revalidateTag로 리밸리데이트할 수 있습니다.

6.1 updateTag 사용 (즉시 갱신)

  • 같은 요청 안에서 캐시를 만료하고 즉시 새 데이터로 갱신해야 할 때 사용합니다.
// app/actions.ts
import { cacheTag, updateTag } from 'next/cache'

export async function getCart() {
  'use cache'
  cacheTag('cart')
  // 장바구니 데이터 fetch
}

export async function updateCart(itemId: string) {
  'use server'
  // itemId를 사용하여 데이터 갱신
  updateTag('cart') // cart 태그가 달린 캐시를 즉시 갱신
}

6.2 revalidateTag 사용 (stale-while-revalidate)

  • 태그가 달린 캐시 엔트리만 골라서 stale-while-revalidate 방식으로 무효화할 때 사용합니다.
  • 일정 정도의 지연(최종 일관성)을 허용하는 정적 콘텐츠에 적합합니다.
// app/actions.ts
import { cacheTag, revalidateTag } from 'next/cache'

export async function getPosts() {
  'use cache'
  cacheTag('posts')
  // 게시물 목록 fetch
}

export async function createPost(post: FormData) {
  'use server'
  // 새 게시글 작성
  revalidateTag('posts', 'max')
}

7. 무엇을 캐시할 것인가? (What should I cache?)

  • 캐시 전략은 원하는 UI 로딩 상태와 직결됩니다.
  • 다음 조건을 만족한다면 use cache + cacheLife를 사용하는 것이 좋습니다.
    • 런타임 데이터에 의존하지 않고,
    • 일정 기간 동안 여러 요청에 같은 데이터를 제공해도 괜찮을 때
  • CMS 같은 시스템에서는:
    • 캐시 기간을 넉넉하게 두고,
    • 콘텐츠가 실제로 변경될 때 revalidateTag를 사용해 리밸리데이트하는 패턴이 좋습니다.
      → 미리 캐시를 자주 버리는 대신, 변경 시점에만 갱신하여 빠른 응답 + 신선한 데이터를 동시에 만족시킬 수 있습니다.

8. 전체 예시 (Putting it all together)

정적 콘텐츠 + 캐시된 동적 콘텐츠 + 스트리밍 동적 콘텐츠를 하나의 페이지에 함께 사용하는 예시 구조입니다.

// app/blog/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife } from 'next/cache'
import Link from 'next/link'

export default function BlogPage() {
  return (
    <>
      {/* 정적 콘텐츠 - 자동 프리렌더 */}
      <header>
        <h1>Our Blog</h1>
        <nav>
          <Link href="/">Home</Link> | <Link href="/about">About</Link>
        </nav>
      </header>

      {/* 캐시된 동적 콘텐츠 - static shell에 포함 */}
      <BlogPosts />

      {/* 런타임 동적 콘텐츠 - 요청 시 스트리밍 */}
      <Suspense fallback={<p>Loading your preferences...</p>}>
        <UserPreferences />
      </Suspense>
    </>
  )
}

// 모든 사용자가 공유하는 블로그 글 목록
async function BlogPosts() {
  'use cache'
  cacheLife('hours')

  const res = await fetch('https://api.vercel.app/blog')
  const posts = await res.json()

  return (
    <section>
      <h2>Latest Posts</h2>
      <ul>
        {posts.slice(0, 5).map((post: any) => (
          <li key={post.id}>
            <h3>{post.title}</h3>
            <p>
              By {post.author} on {post.date}
            </p>
          </li>
        ))}
      </ul>
    </section>
  )
}

// 사용자별 개인화 (쿠키 기반)
async function UserPreferences() {
  const theme = (await cookies()).get('theme')?.value || 'light'
  const favoriteCategory = (await cookies()).get('category')?.value

  return (
    <aside>
      <p>Your theme: {theme}</p>
      {favoriteCategory && <p>Favorite category: {favoriteCategory}</p>}
    </aside>
  )
}

렌더링 흐름 요약:

  • prerendering 시:
    • 헤더(정적), BlogPosts(캐시된 동적 데이터), UserPreferences의 fallback이 static shell에 포함됩니다.
  • 요청 시:
    • 사용자는 즉시 헤더 + 블로그 글 목록을 확인할 수 있습니다.
    • 쿠키에 의존하는 개인화된 UserPreferences는 요청 시점에 스트리밍됩니다.

9. Cache Components 활성화 (Enabling Cache Components)

next.config.js 또는 next.config.ts에서 cacheComponents 옵션을 켜면 됩니다.

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig
  • Cache Components를 활성화하면, GET Route Handlers도 페이지와 동일한 prerendering 모델을 따릅니다.

10. Navigation과 Activity

cacheComponents를 켜면, Next.js는 클라이언트 내비게이션 시 React의 <Activity> 컴포넌트를 사용하여 상태를 보존합니다.

  • 새 라우트로 이동할 때 이전 라우트를 바로 언마운트하지 않고, Activity 모드를 "hidden"으로 설정합니다.
    • 라우트 상태(폼 입력값, 펼쳐진 아코디언 등)가 유지됩니다.
    • 뒤로 가기를 하면 이전 상태 그대로 다시 나타납니다.
    • 숨겨져 있는 동안 효과(effects)는 정리되고, 다시 표시되면 재생성됩니다.
  • Next.js는 최근 방문한 몇 개의 라우트만 "hidden" 상태로 유지하고,
    오래된 라우트는 DOM에서 제거하여 메모리 사용이 과도하게 증가하는 것을 방지합니다.

11. 기존 Route Segment 설정 마이그레이션

Cache Components를 활성화하면, 기존 App Router의 일부 route segment 설정이 더 이상 필요 없거나 지원되지 않습니다.

11.1 dynamic = "force-dynamic"

  • 더 이상 필요 없습니다. 모든 페이지는 기본적으로 **동적(dynamic)**입니다.
// Before
export const dynamic = 'force-dynamic'

export default function Page() {
  return <div>...</div>
}

// After
export default function Page() {
  return <div>...</div>
}

11.2 dynamic = "force-static"

  • 우선 제거하는 것이 권장됩니다.
  • 개발/빌드 시점에 동적 데이터 또는 runtime data 접근이 감지되면 에러가 발생하며,
    이 에러를 바탕으로 다음처럼 조정합니다.
  1. 동적 데이터(fetch 등)
    • 데이터 접근 가까이에 use cache를 추가하고,
      cacheLife('max') 같은 긴 캐시 기간을 부여해 기존의 static behavior를 유지합니다.
  2. Runtime data (cookies(), headers() 등)
    • Suspense로 감싸라는 에러 안내에 따라 <Suspense>를 추가해야 합니다.
    • 원래 force-static을 사용하고 있었다면, 요청 시점 작업을 제거해야 완전히 정적이 됩니다.

11.3 revalidate

  • cacheLife로 교체합니다.
    기존 route segment 설정 대신, 컴포넌트 내부에서 캐시 수명을 정의해야 합니다.
// Before
export const revalidate = 3600 // 1 hour

export default async function Page() {
  return <div>...</div>
}

// After
import { cacheLife } from 'next/cache'

export default async function Page() {
  'use cache'
  cacheLife('hours')
  return <div>...</div>
}

11.4 fetchCache

  • 필요 없습니다.
    use cache 스코프 안에 있는 모든 fetch는 자동으로 캐시됩니다.
// Before
export const fetchCache = 'force-cache'

// After
export default async function Page() {
  'use cache'
  // 여기서 하는 fetch는 자동 캐시
  return <div>...</div>
}

11.5 runtime = 'edge'

  • 지원되지 않습니다.
    Cache Components는 Node.js 런타임이 필요하며, Edge Runtime과 함께 사용할 수 없습니다.

12. Next Steps

더 깊이 학습하려면 다음 API 문서를 함께 보는 것이 좋습니다.

  • cacheComponents – Next.js에서 Cache Components 플래그 활성화 방법
  • use cache – 데이터/컴포넌트를 캐시하는 방법
  • cacheLife – 캐시 수명 설정
  • cacheTag – 캐시 무효화(tagging) 관리
  • revalidateTag / updateTag – 태그 기반 온디맨드 리밸리데이트

이 정리본을 기준으로 스터디에서:

  1. cacheComponents: true를 켜고
  2. 간단한 페이지에
    • 정적 섹션
    • use cache + cacheLife 섹션
    • <Suspense> + runtime data 섹션

을 하나씩 추가해 보면, static shell에 들어가는 것 / 요청 시점에 스트리밍되는 것이 어떻게 나뉘는지 감각을 빠르게 익힐 수 있습니다.