Skip to content
iik89
Go back

Next.js 16의 use cache 지시어와 PPR: 컴포넌트 단위 캐싱 실전 가이드

목차

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.dynamicIOcacheComponents: true

cacheComponents 활성화

cacheComponents는 Next.js 15의 experimental.dynamicIOexperimental.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)은 하나의 페이지를 세 가지 영역으로 분리한다.

  1. 정적 셸(Static Shell): <Suspense> 바깥의 마크업. 빌드 시점에 HTML로 고정되어 CDN에서 즉시 응답한다.
  2. 동적 스트리밍 영역: <Suspense> 안쪽이면서 'use cache'가 없는 컴포넌트. 매 요청마다 서버에서 실시간 렌더링 후 스트리밍으로 전달한다.
  3. 캐시 영역: '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"가 이 컴포넌트의 전체 출력을 캐시 대상으로 지정한다. 작동 순서는 다음과 같다.

  1. 첫 번째 사용자가 접속하면 fetchCurriculum이 실행되어 1.5초가 소요된다.
  2. 렌더링된 HTML 결과물이 프레임워크의 캐시 저장소에 보관된다.
  3. 이후 접속하는 사용자는 1.5초의 대기 없이 캐시된 HTML을 즉시 받는다.
  4. 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'만으로는 캐시가 언제 갱신되는지 제어할 수 없다. cacheLifecacheTag가 이를 보완한다.

cacheLife: 시간 기반 만료

"use cache";

import { cacheLife } from "next/cache";

export async function getCourseCatalog() {
  cacheLife("days");
  // 하루 단위로 캐시가 갱신된다
  return fetchCatalogFromDB();
}

내장 프로필:

프로필stalerevalidateexpire
"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'는 캐싱의 기본 단위를 페이지에서 컴포넌트로 바꾼다. 핵심 설계 원칙은 세 가지다.

  1. 명시적 옵트인: 캐시되지 않는 것이 기본이다. 'use cache'로 선택한 영역만 캐시한다.
  2. 컴포넌트 독립성: 캐시된 컴포넌트는 부모의 렌더링 전략에 종속되지 않는다. 동적 페이지 안에서도 독립적인 캐시 수명을 유지한다.
  3. Suspense가 경계: <Suspense>는 정적 셸과 동적 영역의 경계선이자, PPR이 작동하기 위한 필수 구조다.

이 세 원칙을 따르면 하나의 페이지 안에서 즉시 응답하는 정적 셸, 실시간 스트리밍 영역, 캐시된 고비용 연산 결과가 자연스럽게 공존하게 된다.


Share this post on:

Next Post
PPR과 마이크로 캐싱: cacheComponents와 Suspense 기반 하이브리드 렌더링