콘텐츠로 이동

6. 캐싱

Next.js에서 가장 오해가 많았던 부분이 다시 설계됐다

Next.js 13~14의 캐싱은 악명이 높았다. “왜 데이터가 안 바뀌지”가 가장 흔한 질문이었고, 원인은 캐시가 암묵적으로 켜져 있었고 계층이 네 개나 됐다는 것이다.

Next.js 16은 이걸 Cache Components라는 모델로 다시 설계했다. 핵심 변화는 한 줄로 요약된다 — 기본은 캐시 안 함. 캐시하려면 명시한다.

next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
  • 이걸 켜면 부분 프리렌더링(PPR)이 기본 동작이 된다
  • 캐시되지 않은 데이터 접근은 반드시 Suspense 안에 있어야 한다 (아니면 개발 중 경고)
  • 강제성이 있는 대신, 모든 라우트가 즉시 뜨는 정적 껍데기를 갖게 된다
import { cacheLife } from 'next/cache'
export async function getUsers() {
'use cache'
cacheLife('hours')
return db.query('SELECT * FROM users')
}

여러 컴포넌트가 같은 데이터를 쓸 때. 조회 결과만 캐시한다.

인자와 클로저로 잡힌 값이 자동으로 캐시 키가 된다. getUser(1)getUser(2)는 별개 엔트리다.

async function BlogPosts() {
'use cache'
cacheLife('hours') // 프로파일 이름으로 지정
// ...
}
프로파일 대략의 성격
seconds 거의 실시간에 가까운 데이터
minutes 자주 바뀌는 목록
hours 블로그 글, 상품 정보
days 카테고리, 설정값
max 사실상 안 바뀌는 것

cacheTag + revalidateTag — 사건 기반 무효화

섹션 제목: “cacheTag + revalidateTag — 사건 기반 무효화”

시간이 아니라 사건을 기준으로 갱신한다.

// 읽는 쪽: 태그를 붙인다
import { cacheLife, cacheTag } from 'next/cache'
async function BlogPosts() {
'use cache'
cacheLife('hours')
cacheTag('posts')
const res = await fetch('https://api.example.com/posts')
return <List items={await res.json()} />
}
// 쓰는 쪽: 글을 발행하면 태그를 무효화한다
'use server'
import { revalidateTag } from 'next/cache'
export async function publishPost(data: FormData) {
await db.post.create({ /* ... */ })
revalidateTag('posts') // 이 태그를 쓰는 모든 캐시가 갱신 대상
}

CMS 웹훅을 Route Handler로 받아 revalidateTag를 호출하는 것이 전형적인 패턴이다.

매 요청 새로 읽어야 하는 데이터에는 use cache를 쓰지 않는다. 대신 Suspense로 감싼다.

import { Suspense } from 'react'
async function LatestPosts() {
const data = await fetch('https://api.example.com/posts')
return <List items={await data.json()} />
}
export default function Page() {
return (
<>
<h1>My Blog</h1>
<Suspense fallback={<p>불러오는 중…</p>}>
<LatestPosts />
</Suspense>
</>
)
}

fallback이 정적 껍데기에 포함되어 즉시 전송되고, 실제 내용은 요청 시점에 흘러온다.

cookies(), headers(), searchParams, params — 요청이 있어야 알 수 있는 것들.

import { cookies } from 'next/headers'
import { Suspense } from 'react'
async function UserGreeting() {
const cookieStore = await cookies()
const theme = cookieStore.get('theme')?.value || 'light'
return <p>테마: {theme}</p>
}
export default function Page() {
return (
<>
<h1>대시보드</h1>
<Suspense fallback={<p>불러오는 중…</p>}>
<UserGreeting />
</Suspense>
</>
)
}

세 가지가 한 페이지에 공존한다

섹션 제목: “세 가지가 한 페이지에 공존한다”
flowchart TB
    subgraph shell["정적 껍데기 — CDN 에서 즉시"]
        A["헤더 · 내비게이션<br/>순수 계산"]
        B["블로그 목록<br/>'use cache'"]
        C["사용자 설정 자리<br/>Suspense fallback"]
    end
    C -.->|"요청 시 스트리밍"| D["cookies() 읽어<br/>실제 설정 표시"]

    classDef ok   fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef key  fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
    class A ok
    class B key
    class C,D warn
  • 초록 — 빌드 타임에 확정. 순수 계산, 모듈 import
  • 파랑use cache. 모두에게 같은 내용, 캐시에서
  • 노랑 — 요청 시. 사용자마다 다른 내용, 스트리밍
// ❌ 레이아웃 최상단에서 params를 await — 껍데기를 만들 수 없다
export default async function Layout({ children, params }: LayoutProps<'/shop/[slug]'>) {
const { slug } = await params
return <div><Sidebar /><h1>{slug}</h1>{children}</div>
}
// ✅ await를 경계 안쪽으로 밀어 넣는다
export default function Layout({ children, params }: LayoutProps<'/shop/[slug]'>) {
return (
<div>
<Sidebar />
<Suspense fallback={<h1>불러오는 중…</h1>}>
{params.then(({ slug }) => <SlugHeading slug={slug} />)}
</Suspense>
{children}
</div>
)
}

비동기 작업이 트리 깊은 곳에 있을수록 프리렌더할 수 있는 영역이 넓어진다. 3장에서 params가 Promise가 된 이유가 정확히 이것이다.

기본 use cache 말고도 두 가지 변종이 있다.

use cache: private — 사용자별 캐시

섹션 제목: “use cache: private — 사용자별 캐시”

쿠키·헤더를 직접 읽으면서도 수명을 가질 수 있다.

async function UserSidebar() {
'use cache: private'
cacheLife('minutes')
const session = (await cookies()).get('session')?.value
return <Nav items={await getNavFor(session)} />
}
  • 결과가 브라우저에만 저장된다. 서버 공유 캐시에 들어가지 않는다
  • 프리페치에 포함될 수 있어 클릭 시 이미 준비돼 있다
  • 사용자별로 다른 내용을 캐시해야 할 때의 정답

use cache: remote — 인스턴스 간 공유

섹션 제목: “use cache: remote — 인스턴스 간 공유”
async function ExpensiveReport(params: { month: string }) {
'use cache: remote'
cacheLife('hours')
return renderReport(await runHeavyQuery(params.month))
}
  • 기본 use cache의 런타임 저장소는 인스턴스 메모리다. 서버리스에서는 요청마다 날아갈 수 있다
  • remotecache handler를 통해 durable 스토리지에 저장한다 — 인스턴스가 바뀌어도 유지된다
  • 대신 네트워크 왕복 비용이 든다. 문서 표현대로 적중률이 높을 때만 이득이다

빌드 ID가 캐시 키에 포함되므로 새로 배포하면 remote 캐시도 초기화된다.

저장소 무엇이 들어가나 수명 제어
프리렌더 HTML 정적 껍데기, ISR로 승격된 페이지 revalidate / expire
인스턴스 메모리 기본 use cache 런타임 결과 프로세스 수명
remote 스토어 use cache: remote cache handler 설정
브라우저 프리페치된 RSC 페이로드, use cache: private stale

전부 배포 단위로 스코프된다. 새 배포는 새 캐시에서 시작한다.

캐시 모델에서 Math.random(), Date.now()는 애매한 존재다. Next.js는 명시하라고 요구한다.

import { connection } from 'next/server'
async function UniqueContent() {
await connection() // 요청 시점으로 미룬다
const uuid = crypto.randomUUID()
return <p>요청 ID: {uuid}</p>
}
// + Suspense로 감싼다

외우지 않아도 된다. 개발 중 오버레이가 blocking-prerender-random 같은 진단을 띄우고 고치는 방법을 알려준다.

봇과 크롤러는 다르게 처리된다

섹션 제목: “봇과 크롤러는 다르게 처리된다”
  • 사람의 브라우저 → 정적 껍데기를 즉시 받고 나머지는 스트리밍
  • 봇·크롤러 → 껍데기를 건너뛰고 전체를 요청 시점에 렌더해서 완성된 HTML을 준다

use cache가 만든 결과는 링크를 프리페치할 때 미리 준비될 수 있다. 클릭 시 기다릴 것이 없어지지만, 비용은 프리페치 대상 링크마다 서버 호출 한 번이다. partialPrefetching 설정과 <Link prefetch>로 조절한다.

하려던 일 예전 (13~15) 지금 (Cache Components)
페이지 정적 생성 기본값 (암묵적) use cache 명시
60초마다 갱신 export const revalidate = 60 cacheLife('minutes')
fetch 결과 캐시 fetch(url, { next: { revalidate: 60 } }) 함수에 use cache + cacheLife
캐시 안 함 cache: 'no-store' 아무것도 안 붙임 (기본)
동적 렌더 강제 export const dynamic = 'force-dynamic' Suspense + 런타임 API
태그 무효화 revalidateTag revalidateTag (동일)
부분 프리렌더 experimental.ppr 기본 동작
  • Cache Components는 **“기본은 캐시 안 함, 캐시는 명시”**로 뒤집은 모델이다
  • use cache데이터 함수와 컴포넌트 양쪽에 붙일 수 있다
  • cacheLife를 항상 짝지어 쓴다. cacheTag + revalidateTag로 사건 기반 무효화
  • 캐시 안 하는 것·런타임 API는 Suspense로 감싼다 → 나머지는 정적 껍데기로 남는다
  • private는 사용자별(브라우저), remote는 인스턴스 공유(durable)
  • await를 트리 아래로 밀수록 껍데기가 커진다