"use cache"로 다시 설계하는 캐시 모델
Next.js에서 가장 많이 오해받던 부분
"이 페이지가 왜 캐시된 거지?"와 "이 페이지가 왜 캐시가 안 됐지?"는 App Router 초기부터 가장 많이 나온 질문 두 가지였습니다. fetch를 쓰면 기본으로 캐시되고, 데이터베이스 클라이언트를 직접 쓰면 캐시가 안 되고, dynamic = "force-dynamic"을 페이지 어딘가에 넣으면 그 라우트 전체가 영향을 받고 — 규칙은 있었지만 코드만 보고 그 규칙을 유추하기는 어려웠습니다. 캐싱이 코드에 드러나 있지 않고 프레임워크의 암묵적 판단에 맡겨져 있었기 때문입니다.
16의 Cache Components는 이 문제를 정면으로 다룹니다. 접근 방식은 정반대로 뒤집혔습니다: 기본값은 이제 "캐시 안 함"이고, 캐시하고 싶은 부분만 명시적으로 표시합니다.
"use cache" — 표시해야 캐시된다
next.config.ts에서 cacheComponents: true를 켜면(16에서 안정화되었지만 여전히 옵트인입니다) 파일, 컴포넌트, 함수 단위로 "use cache"를 붙일 수 있습니다.
// app/products/[id]/page.tsx
async function getProduct(id: string) {
"use cache";
const res = await fetch(`https://api.example.com/products/${id}`);
return res.json();
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
return <h1>{product.name}</h1>;
}"use cache"가 붙은 함수는 인자를 기반으로 컴파일러가 캐시 키를 자동 생성합니다. 붙이지 않은 나머지 코드는 기본적으로 요청 시점에 실행됩니다 — 이전처럼 "캐시가 될지 안 될지 규칙을 추론"할 필요가 없다는 게 핵심입니다. 코드를 읽으면 캐시 여부를 바로 알 수 있습니다.
cacheLife와 revalidateTag, 그리고 updateTag
캐시 수명은 cacheLife 프로파일로 지정합니다.
import { unstable_cacheLife as cacheLife } from "next/cache";
async function getProduct(id: string) {
"use cache";
cacheLife("hours");
// ...
}무효화는 두 가지 API로 나뉩니다. revalidateTag(tag, profile)은 다음 요청부터 새로 계산하도록 표시만 하고, updateTag(tag)는 즉시 갱신을 강제합니다. 재고 수량처럼 "지금 당장 반영돼야 하는" 값은 updateTag, 블로그 글 조회수처럼 "다음에 볼 때만 최신이면 되는" 값은 revalidateTag로 나눠 쓸 수 있습니다. 이 구분이 생기기 전에는 revalidatePath 하나로 뭉뚱그렸는데, 그러면 즉시성이 필요 없는 갱신까지 매번 강제로 다시 렌더하게 되는 낭비가 있었습니다.
마이그레이션에서 가장 위험한 지점
기존 프로젝트에 cacheComponents: true를 켜는 순간, "use cache"가 없는 모든 것은 캐시되지 않습니다. 이전에는 fetch 호출이 암묵적으로 캐시되고 있었는데, 플래그를 켜자마자 그 캐시가 전부 사라지고 매 요청마다 원본으로 나가게 됩니다. 트래픽이 많은 페이지라면 이건 기능 버그가 아니라 인프라 사고입니다 — 백엔드 API나 데이터베이스가 갑자기 몇 배의 요청을 받게 되니까요.
그래서 이 플래그는 한 번에 전체 프로젝트에 켜기보다, 캐시가 중요한 라우트부터 "use cache"를 붙여가며 점진적으로 옮기는 걸 권합니다. 켜기 전에 어떤 페이지가 실제로 캐시에 의존해서 부하를 버티고 있었는지 먼저 파악해야 합니다.
flowchart TD
A["cacheComponents: true 로 전환"] --> B{"이 함수에 use cache 가 있는가?"}
B -->|있음| C["캐시됨, cacheLife 만큼 유지"]
B -->|없음| D["매 요청마다 재실행"]
D --> E{"원래 fetch 캐시에 의존하던 고트래픽 라우트인가?"}
E -->|예| F["원본 백엔드 부하 급증 위험 — 전환 전에 파악해야 함"]솔직히 말하면, 캐시 키가 함수 인자로부터 정확히 어떻게 파생되는지(클로저로 캡처한 외부 변수까지 포함되는지 등) 공식 문서만으로는 완전히 명확하지 않은 부분이 있었습니다. 복잡한 클로저를 쓰는 함수에 "use cache"를 붙일 때는 실제로 캐시가 의도한 대로 키를 구분하는지 로그나 캐시 히트율로 직접 확인해보는 걸 권합니다.