ywc.life

Next.js 16 Cache Components - Vercel이 공개한 PPR·use cache 가이드 (전문 번역)

· nextjs, react, vercel, best-practices

목차

참고: 이 문서는 Vercel Labs의 next-cache-components 스킬 전문을 번역한 것입니다. 이전에 번역한 Next.js Best Practices가 Next.js 전반의 정확성 규칙을 다뤘다면, 이 문서는 Next.js 16에서 도입된 Cache Components 한 가지 주제 — 정적·캐시·동적 콘텐츠를 한 라우트에 섞는 Partial Prerendering(PPR)과 use cache 지시어 — 를 집중적으로 다룹니다.


Cache Components는 Partial Prerendering(PPR)을 가능하게 합니다 — 정적, 캐시된, 동적 콘텐츠를 하나의 라우트에 섞을 수 있어요.

Cache Components 활성화 (Enable Cache Components)

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

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

이 설정이 기존의 experimental.ppr 플래그를 대체합니다.


세 가지 콘텐츠 유형 (Three Content Types)

Cache Components를 활성화하면 콘텐츠는 세 가지 범주로 나뉩니다:

1. 정적 (Static) - 자동 프리렌더링

동기 코드, import, 순수 연산 — 빌드 타임에 프리렌더링됩니다:

export default function Page() {
  return (
    <header>
      <h1>Our Blog</h1>  {/* 정적 - 즉시 표시 */}
      <nav>...</nav>
    </header>
  )
}

2. 캐시됨 (use cache)

매 요청마다 새로 가져올 필요가 없는 비동기 데이터:

async function BlogPosts() {
  'use cache'
  cacheLife('hours')

  const posts = await db.posts.findMany()
  return <PostList posts={posts} />
}

3. 동적 (Suspense)

항상 최신이어야 하는 런타임 데이터 — Suspense로 감쌉니다:

import { Suspense } from 'react'

export default function Page() {
  return (
    <>
      <BlogPosts />  {/* 캐시됨 */}

      <Suspense fallback={<p>Loading...</p>}>
        <UserPreferences />  {/* 동적 - 스트리밍으로 도착 */}
      </Suspense>
    </>
  )
}

async function UserPreferences() {
  const theme = (await cookies()).get('theme')?.value
  return <p>Theme: {theme}</p>
}

use cache 지시어 (use cache Directive)

파일 레벨

'use cache'

export default async function Page() {
  // 페이지 전체가 캐시됨
  const data = await fetchData()
  return <div>{data}</div>
}

컴포넌트 레벨

export async function CachedComponent() {
  'use cache'
  const data = await fetchData()
  return <div>{data}</div>
}

함수 레벨

export async function getData() {
  'use cache'
  return db.query('SELECT * FROM posts')
}

캐시 프로필 (Cache Profiles)

내장 프로필

'use cache'                    // 기본값: stale 5분, revalidate 15분
'use cache: remote'           // 플랫폼이 제공하는 캐시 (Redis, KV)
'use cache: private'          // 컴플라이언스용, 런타임 API 허용

cacheLife() - 커스텀 수명

import { cacheLife } from 'next/cache'

async function getData() {
  'use cache'
  cacheLife('hours')  // 내장 프로필
  return fetch('/api/data')
}

내장 프로필: 'default', 'minutes', 'hours', 'days', 'weeks', 'max'

인라인 설정

async function getData() {
  'use cache'
  cacheLife({
    stale: 3600,      // 1시간 - 재검증하는 동안 stale 응답 제공
    revalidate: 7200, // 2시간 - 백그라운드 재검증 주기
    expire: 86400,    // 1일 - 완전 만료
  })
  return fetch('/api/data')
}

캐시 무효화 (Cache Invalidation)

cacheTag() - 캐시된 콘텐츠에 태그 달기

import { cacheTag } from 'next/cache'

async function getProducts() {
  'use cache'
  cacheTag('products')
  return db.products.findMany()
}

async function getProduct(id: string) {
  'use cache'
  cacheTag('products', `product-${id}`)
  return db.products.findUnique({ where: { id } })
}

updateTag() - 즉시 무효화

같은 요청 안에서 캐시가 갱신되어야 할 때 사용합니다:

'use server'

import { updateTag } from 'next/cache'

export async function updateProduct(id: string, data: FormData) {
  await db.products.update({ where: { id }, data })
  updateTag(`product-${id}`)  // 즉시 - 같은 요청에서 최신 데이터를 봄
}

revalidateTag() - 백그라운드 재검증

stale-while-revalidate 동작이 필요할 때 사용합니다:

'use server'

import { revalidateTag } from 'next/cache'

export async function createPost(data: FormData) {
  await db.posts.create({ data })
  revalidateTag('posts')  // 백그라운드 - 다음 요청에서 최신 데이터를 봄
}

런타임 데이터 제약 (Runtime Data Constraint)

use cache 안에서는 cookies(), headers(), searchParams에 접근할 수 없습니다.

해결책: 인자로 전달하기

// 잘못된 예 - use cache 안에서 런타임 API 사용
async function CachedProfile() {
  'use cache'
  const session = (await cookies()).get('session')?.value  // 에러!
  return <div>{session}</div>
}

// 올바른 예 - 바깥에서 추출해 인자로 전달
async function ProfilePage() {
  const session = (await cookies()).get('session')?.value
  return <CachedProfile sessionId={session} />
}

async function CachedProfile({ sessionId }: { sessionId: string }) {
  'use cache'
  // sessionId는 자동으로 캐시 키의 일부가 됨
  const data = await fetchUserData(sessionId)
  return <div>{data.name}</div>
}

예외: use cache: private

리팩터링이 불가능한 컴플라이언스 요구사항이 있을 때:

async function getData() {
  'use cache: private'
  const session = (await cookies()).get('session')?.value  // 허용됨
  return fetchData(session)
}

캐시 키 생성 (Cache Key Generation)

캐시 키는 다음 요소를 기반으로 자동 생성됩니다:

  • 빌드 ID - 배포할 때마다 모든 캐시를 무효화
  • 함수 ID - 함수 위치의 해시
  • 직렬화 가능한 인자 - props가 키의 일부가 됨
  • 클로저 변수 - 바깥 스코프의 값도 포함됨
async function Component({ userId }: { userId: string }) {
  const getData = async (filter: string) => {
    'use cache'
    // 캐시 키 = userId (클로저) + filter (인자)
    return fetch(`/api/users/${userId}?filter=${filter}`)
  }
  return getData('active')
}

전체 예제 (Complete Example)

import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife, cacheTag } from 'next/cache'

export default function DashboardPage() {
  return (
    <>
      {/* 정적 셸 - CDN에서 즉시 제공 */}
      <header><h1>Dashboard</h1></header>
      <nav>...</nav>

      {/* 캐시됨 - 빠르고, 매시간 재검증 */}
      <Stats />

      {/* 동적 - 최신 데이터를 스트리밍으로 */}
      <Suspense fallback={<NotificationsSkeleton />}>
        <Notifications />
      </Suspense>
    </>
  )
}

async function Stats() {
  'use cache'
  cacheLife('hours')
  cacheTag('dashboard-stats')

  const stats = await db.stats.aggregate()
  return <StatsDisplay stats={stats} />
}

async function Notifications() {
  const userId = (await cookies()).get('userId')?.value
  const notifications = await db.notifications.findMany({
    where: { userId, read: false }
  })
  return <NotificationList items={notifications} />
}

이전 버전에서 마이그레이션 (Migration from Previous Versions)

기존 설정대체
experimental.pprcacheComponents: true
dynamic = 'force-dynamic'제거 (기본 동작)
dynamic = 'force-static''use cache' + cacheLife('max')
revalidate = NcacheLife({ revalidate: N })
unstable_cache()'use cache' 지시어

unstable_cacheuse cache로 마이그레이션

Next.js 16에서 unstable_cacheuse cache 지시어로 대체되었습니다. cacheComponents가 활성화되어 있다면 unstable_cache 호출을 use cache 함수로 변환하세요:

변경 전 (unstable_cache):

import { unstable_cache } from 'next/cache'

const getCachedUser = unstable_cache(
  async (id) => getUser(id),
  ['my-app-user'],
  {
    tags: ['users'],
    revalidate: 60,
  }
)

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const user = await getCachedUser(id)
  return <div>{user.name}</div>
}

변경 후 (use cache):

import { cacheLife, cacheTag } from 'next/cache'

async function getCachedUser(id: string) {
  'use cache'
  cacheTag('users')
  cacheLife({ revalidate: 60 })
  return getUser(id)
}

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const user = await getCachedUser(id)
  return <div>{user.name}</div>
}

핵심 차이점:

  • 수동 캐시 키 불필요 - use cache는 함수 인자와 클로저에서 키를 자동 생성합니다. unstable_cachekeyParts 배열은 더 이상 필요 없습니다.
  • 태그 - options.tags를 함수 안의 cacheTag() 호출로 대체하세요.
  • 재검증 - options.revalidatecacheLife({ revalidate: N }) 또는 cacheLife('minutes') 같은 내장 프로필로 대체하세요.
  • 동적 데이터 - unstable_cache는 콜백 안에서 cookies()headers()를 지원하지 않았습니다. 같은 제약이 use cache에도 적용되지만, 필요하다면 'use cache: private'을 쓸 수 있습니다.

제한 사항 (Limitations)

  • Edge 런타임 미지원 - Node.js가 필요합니다
  • 정적 export 미지원 - 서버가 필요합니다
  • 비결정적 값 (Math.random(), Date.now())은 use cache 안에서 빌드 타임에 한 번만 실행됩니다

캐시 바깥에서 요청 시점의 랜덤 값이 필요하다면:

import { connection } from 'next/server'

async function DynamicContent() {
  await connection()  // 요청 시점으로 지연
  const id = crypto.randomUUID()  // 요청마다 다른 값
  return <div>{id}</div>
}

참고 자료 (Sources)

관련 글

댓글