목차
Next.js 캐싱 모델의 전환점
Next.js 14까지의 캐싱은 암묵적(implicit)이었다. fetch()를 호출하면 프레임워크가 알아서 결과를 캐시했고, 개발자는 이 동작을 끄기 위해 cache: 'no-store'나 revalidate: 0 같은 옵트아웃 옵션을 달아야 했다. 페이지 전체가 정적이거나 전체가 동적인 양자택일 구조였다.
Next.js 16은 이 모델을 뒤집었다. cacheComponents: true를 설정하면 모든 데이터 페칭은 기본적으로 캐시되지 않는다. 개발자가 'use cache' 지시어로 명시적으로 표시한 영역만 캐시 대상이 된다. 페이지 단위가 아니라 컴포넌트, 함수 단위로 캐시를 제어하는 마이크로 캐싱 모델이다.
| 항목 | Next.js 14 (암묵적 캐싱) | Next.js 16 (명시적 캐싱) |
|---|---|---|
| 기본 동작 | fetch 결과 자동 캐시 | 캐시 없음 (런타임 실행) |
| 캐시 단위 | 페이지(Route) 전체 | 컴포넌트 / 함수 |
| 옵트인/아웃 | cache: 'no-store'로 캐시 해제 | 'use cache'로 캐시 활성화 |
| PPR 지원 | 실험적 플래그 필요 | 기본 동작으로 통합 |
| 설정 플래그 | experimental.dynamicIO | cacheComponents: true |
cacheComponents 활성화
cacheComponents는 Next.js 15의 experimental.dynamicIO와 experimental.ppr을 통합하여 정식 승격한 설정이다.
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
이 한 줄이 프레임워크의 렌더링 전략을 근본적으로 바꾼다. 활성화 이후 모든 페이지는 PPR(Partial Prerendering) 모드로 동작하며, <Suspense> 경계를 기준으로 정적 영역과 동적 영역을 자동 분리한다.
PPR의 작동 원리
PPR(Partial Prerendering)은 하나의 페이지를 세 가지 영역으로 분리한다.
- 정적 셸(Static Shell):
<Suspense>바깥의 마크업. 빌드 시점에 HTML로 고정되어 CDN에서 즉시 응답한다. - 동적 스트리밍 영역:
<Suspense>안쪽이면서'use cache'가 없는 컴포넌트. 매 요청마다 서버에서 실시간 렌더링 후 스트리밍으로 전달한다. - 캐시 영역:
'use cache'지시어가 붙은 컴포넌트. 최초 한 번 실행된 결과가 캐시에 저장되어 이후 요청에 즉시 응답한다.
핵심은 이 세 영역이 하나의 페이지 안에 공존한다는 점이다. 과거에는 페이지 전체를 정적 또는 동적으로 결정해야 했지만, PPR 환경에서는 각 컴포넌트가 독립적인 렌더링 전략을 갖는다.
실전 구현: 온라인 강의 플랫폼
온라인 강의 플랫폼의 강좌 상세 페이지를 구현한다. 이 페이지에는 세 가지 성격이 다른 영역이 존재한다.
| 영역 | 갱신 주기 | 렌더링 전략 |
|---|---|---|
| 페이지 제목, 네비게이션 | 변경 없음 | 정적 셸 (빌드 시 고정) |
| 수강생 실시간 접속 수, 라이브 Q&A 현황 | 매 요청마다 | 동적 스트리밍 |
| 커리큘럼, 강사 소개, 수강 후기 요약 | 거의 불변 | 'use cache' 캐싱 |
페이지 컴포넌트: 정적 셸과 Suspense 경계
// src/app/course/[slug]/page.tsx
import { Suspense } from "react";
import LiveStatus from "./LiveStatus";
import CourseContent from "./CourseContent";
export default function CoursePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
return (
<main>
<nav>
<a href="/courses">전체 강좌</a>
<span> / </span>
<span>강좌 상세</span>
</nav>
<h1>강좌 상세 페이지</h1>
<Suspense fallback={<p>실시간 현황 불러오는 중...</p>}>
<LiveStatus paramsPromise={params} />
</Suspense>
<Suspense fallback={<p>커리큘럼 로딩 중...</p>}>
<CourseContent paramsPromise={params} />
</Suspense>
</main>
);
}
이 컴포넌트에서 주의할 점은 await params를 수행하지 않는다는 것이다. params는 Promise 상태 그대로 자식에게 전달한다. 부모가 비동기 데이터를 직접 소비하면 프레임워크는 정적 셸을 생성할 수 없다고 판단하고 빌드 에러를 발생시킨다. <Suspense> 경계가 “이 안쪽은 동적 영역”이라는 선언이므로, 비동기 데이터 소비는 반드시 그 안쪽에서 이루어져야 한다.
빌드 결과물에서 <nav>와 <h1>은 정적 HTML로 고정된다. 사용자가 페이지에 접속하면 이 셸이 지연 없이 즉시 표시되고, <Suspense> 구멍에는 fallback이 잠시 보인 뒤 서버에서 스트리밍된 콘텐츠로 대체된다.
동적 스트리밍 컴포넌트
// src/app/course/[slug]/LiveStatus.tsx
export default async function LiveStatus({
paramsPromise,
}: {
paramsPromise: Promise<{ slug: string }>;
}) {
const { slug } = await paramsPromise;
// 실제로는 WebSocket이나 DB 쿼리로 실시간 데이터를 가져온다
const activeUsers = Math.floor(Math.random() * 500) + 100;
const serverTime = new Date().toLocaleTimeString("ko-KR");
return (
<section>
<h2>실시간 현황 — {slug}</h2>
<ul>
<li>현재 수강 중: {activeUsers}명</li>
<li>서버 시각: {serverTime}</li>
</ul>
</section>
);
}
이 컴포넌트에는 'use cache' 지시어가 없다. cacheComponents 환경에서 캐시 지시어가 없는 비동기 컴포넌트는 매 요청마다 서버에서 새로 실행된다. 정적 셸이 먼저 전달된 후, 이 컴포넌트의 렌더링이 완료되면 React의 스트리밍 메커니즘을 통해 브라우저의 <Suspense> 자리에 주입된다.
캐시 컴포넌트: use cache 적용
// src/app/course/[slug]/CourseContent.tsx
"use cache";
import { cacheLife } from "next/cache";
async function fetchCurriculum(slug: string) {
// 무거운 DB 쿼리 시뮬레이션
await new Promise((resolve) => setTimeout(resolve, 1500));
return {
title: `${slug} 마스터 클래스`,
instructor: "김아무개",
modules: [
{ name: "기초 이론", duration: "2시간" },
{ name: "실전 프로젝트", duration: "4시간" },
{ name: "심화 패턴", duration: "3시간" },
],
reviewSummary: "수강생 4.8/5.0 평점, 1,240개 리뷰",
};
}
export default async function CourseContent({
paramsPromise,
}: {
paramsPromise: Promise<{ slug: string }>;
}) {
cacheLife("hours");
const { slug } = await paramsPromise;
const course = await fetchCurriculum(slug);
const cachedAt = new Date().toLocaleTimeString("ko-KR");
return (
<section>
<h2>{course.title}</h2>
<p>강사: {course.instructor}</p>
<h3>커리큘럼</h3>
<ul>
{course.modules.map((m) => (
<li key={m.name}>
{m.name} ({m.duration})
</li>
))}
</ul>
<p>{course.reviewSummary}</p>
<p>
<small>캐시 생성 시각: {cachedAt}</small>
</p>
</section>
);
}
파일 최상단의 "use cache"가 이 컴포넌트의 전체 출력을 캐시 대상으로 지정한다. 작동 순서는 다음과 같다.
- 첫 번째 사용자가 접속하면
fetchCurriculum이 실행되어 1.5초가 소요된다. - 렌더링된 HTML 결과물이 프레임워크의 캐시 저장소에 보관된다.
- 이후 접속하는 사용자는 1.5초의 대기 없이 캐시된 HTML을 즉시 받는다.
cacheLife("hours")에 의해 일정 시간 후 캐시가 만료되면 다시 실행된다.
부모 페이지가 매 요청마다 동적으로 렌더링되더라도, 이 컴포넌트는 독립적인 캐시 수명을 가진다. 부모의 렌더링 전략에 종속되지 않는 것이 컴포넌트 단위 캐싱의 핵심이다.
use cache의 적용 범위
'use cache' 지시어는 세 가지 수준에서 사용할 수 있다.
파일 수준
파일 최상단에 선언하면 해당 파일의 모든 내보내기가 캐시 대상이 된다.
"use cache";
// 이 파일의 모든 export가 캐시된다
export async function getPopularCourses() { /* ... */ }
export async function getRecentReviews() { /* ... */ }
함수 수준
특정 함수에만 지시어를 붙일 수 있다.
export async function getStaticData() {
"use cache";
// 이 함수의 반환값만 캐시된다
return db.query("SELECT * FROM categories");
}
export async function getUserProgress(userId: string) {
// 캐시되지 않는다. 매번 실행된다.
return db.query("SELECT * FROM progress WHERE user_id = $1", [userId]);
}
컴포넌트 수준
앞의 CourseContent 예제처럼 서버 컴포넌트에 적용하면, 컴포넌트가 렌더링한 HTML 전체가 캐시 단위가 된다.
cacheLife와 cacheTag: 캐시 수명 제어
'use cache'만으로는 캐시가 언제 갱신되는지 제어할 수 없다. cacheLife와 cacheTag가 이를 보완한다.
cacheLife: 시간 기반 만료
"use cache";
import { cacheLife } from "next/cache";
export async function getCourseCatalog() {
cacheLife("days");
// 하루 단위로 캐시가 갱신된다
return fetchCatalogFromDB();
}
내장 프로필:
| 프로필 | stale | revalidate | expire |
|---|---|---|---|
"seconds" | — | 1초 | 60초 |
"minutes" | 5분 | 1분 | 1시간 |
"hours" | 5분 | 1시간 | 1일 |
"days" | 5분 | 1일 | 1주 |
"weeks" | 5분 | 1주 | 30일 |
"max" | 5분 | 30일 | 무제한 |
cacheTag: 온디맨드 무효화
"use cache";
import { cacheLife, cacheTag } from "next/cache";
export async function getCourseReviews(courseId: string) {
cacheLife("hours");
cacheTag(`reviews-${courseId}`);
return fetchReviewsFromDB(courseId);
}
새 리뷰가 등록되면 서버 액션이나 Route Handler에서 해당 태그를 무효화한다.
import { revalidateTag } from "next/cache";
export async function submitReview(courseId: string, review: FormData) {
await saveReviewToDB(courseId, review);
revalidateTag(`reviews-${courseId}`);
}
시간 기반 만료와 이벤트 기반 무효화를 조합하면, 대부분의 실무 요구사항을 커버할 수 있다.
Suspense 경계가 빌드 에러를 방어하는 메커니즘
cacheComponents 환경에서 흔히 마주치는 빌드 에러와 그 원인을 정리한다.
에러가 발생하는 코드
// 빌드 에러 발생
export default async function CoursePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params; // 정적 셸 영역에서 동적 데이터 소비
return (
<main>
<h1>{slug} 강좌</h1>
</main>
);
}
프레임워크는 이 코드를 분석하고 다음과 같이 판단한다: “이 페이지는 정적 셸을 생성할 수 없다. await params가 로딩 경계 없이 호출되었기 때문에 사용자가 빈 화면을 볼 수 있다.”
해결: Suspense로 경계 설정
export default function CoursePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
return (
<main>
<Suspense fallback={<p>로딩 중...</p>}>
<CourseHeader paramsPromise={params} />
</Suspense>
</main>
);
}
async function CourseHeader({
paramsPromise,
}: {
paramsPromise: Promise<{ slug: string }>;
}) {
const { slug } = await paramsPromise;
return <h1>{slug} 강좌</h1>;
}
<Suspense>는 “이 안쪽은 동적이니 구멍으로 남겨두라”는 선언이다. 프레임워크는 <main> 태그까지만 정적 HTML로 고정하고, <Suspense> 안쪽은 런타임에 스트리밍으로 채운다.
정리
Next.js 16의 cacheComponents와 'use cache'는 캐싱의 기본 단위를 페이지에서 컴포넌트로 바꾼다. 핵심 설계 원칙은 세 가지다.
- 명시적 옵트인: 캐시되지 않는 것이 기본이다.
'use cache'로 선택한 영역만 캐시한다. - 컴포넌트 독립성: 캐시된 컴포넌트는 부모의 렌더링 전략에 종속되지 않는다. 동적 페이지 안에서도 독립적인 캐시 수명을 유지한다.
- Suspense가 경계:
<Suspense>는 정적 셸과 동적 영역의 경계선이자, PPR이 작동하기 위한 필수 구조다.
이 세 원칙을 따르면 하나의 페이지 안에서 즉시 응답하는 정적 셸, 실시간 스트리밍 영역, 캐시된 고비용 연산 결과가 자연스럽게 공존하게 된다.