Skip to content
iik89
Go back

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

1. Suspense를 먼저 다루는 이유

<Suspense>는 원래 스트리밍 처리를 위한 도구다. 그러나 cacheComponents: true 스위치를 활성화하는 순간, 이것은 프레임워크의 빌드 에러를 방어하는 필수 아키텍처 요소로 기능이 확장된다.

cacheComponents가 활성화된 환경에서 프레임워크는 페이지의 정적 껍데기를 빌드 시점에 결빙하려 시도한다. 이때 await params와 같은 동적 데이터를 <Suspense> 경계 없이 직접 읽으면 프레임워크는 다음과 같이 판단하고 빌드를 중단한다.

“로딩 경계 없이 동적 데이터를 직접 참조하면 화면이 멈춘다. 빌드를 허용할 수 없다.”

force-dynamic으로 이 에러를 우회하면 빌드는 통과하지만, 마이크로 캐싱의 핵심인 PPR(부분 사전 렌더링) 최적화 자체가 무력화된다. 따라서 <Suspense>는 선택 사항이 아니라 마이크로 캐싱 아키텍처의 필수 뼈대다.


2. 환경 설정: cacheComponents 활성화

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  reactCompiler: true,
  logging: { fetches: { fullUrl: true } },

  // 마이크로 캐싱 스위치 활성화 (Next.js 16 정식 승격)
  cacheComponents: true,
};

export default nextConfig;

설정 변경의 의미

구분변경 전변경 후
명칭experimental: { dynamicIO: true } (Next.js 15)cacheComponents: true (Next.js 16 정식)
캐싱 단위페이지(Route) 전체컴포넌트 / 함수 단위
기본 렌더링조건 충족 시 자동 정적 결빙기본 동적, 'use cache' 선언 영역만 결빙

이 스위치를 활성화하면 프레임워크는 매크로 캐싱 룰을 파기하고, 개발자가 'use cache'로 명시한 컴포넌트만 선택적으로 캐시 창고에 보관하는 마이크로 통제 모드로 전환된다.


3. 아키텍처 설계: 세 영역의 분리

트래픽이 집중되는 뉴스 기사 상세 페이지를 예시로 삼는다. 화면은 다음 세 영역으로 분리하여 설계한다.

영역특성처리 전략
제목, 레이아웃 구조요청과 무관하게 고정정적 껍데기(Static Shell)로 빌드 시점 결빙
실시간 댓글 수, 조회 수요청마다 변동<Suspense> 내부에서 동적 렌더링
본문 콘텐츠 및 작성자 정보발행 후 거의 변경 없음'use cache'로 마이크로 캐싱 결빙

4. 구현

Step 1 — 부모 페이지: 정적 껍데기와 Suspense 경계 정의

// src/app/article/[id]/page.tsx
import { Suspense } from 'react';
import LiveEngagementStats from './LiveEngagementStats';
import HeavyArticleContent from './HeavyArticleContent';

export default function ArticlePage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  // 아키텍처 규칙: 부모 껍데기에서는 절대 await params를 수행하지 않는다.
  // params를 Promise 형태 그대로 자식에게 전달하여 데이터 해제 책임을 위임한다.

  return (
    <div className="max-w-3xl mx-auto p-10 bg-white shadow-xl mt-10 rounded-2xl">
      {/* 정적 껍데기: 빌드 시점에 0초 로드 HTML로 영구 결빙 */}
      <h1 className="text-4xl font-black text-gray-900 mb-8 tracking-tighter">
        2025 글로벌 AI 트렌드 심층 분석
      </h1>

      {/* 캡슐화 1: 실시간 동적 부품 — Suspense 방어막 적용 */}
      <Suspense
        fallback={
          <p className="text-blue-500 font-bold p-6">
            실시간 통계 로딩 중...
          </p>
        }
      >
        <LiveEngagementStats paramsPromise={params} />
      </Suspense>

      <hr className="my-8 border-gray-200" />

      {/* 캡슐화 2: 마이크로 캐싱 결빙 부품 — Suspense 방어막 적용 */}
      <Suspense
        fallback={
          <p className="text-red-500 font-bold p-8">
            기사 본문 로딩 중...
          </p>
        }
      >
        <HeavyArticleContent paramsPromise={params} />
      </Suspense>
    </div>
  );
}

PPR(부분 사전 렌더링) 동작 원리

빌드 시점에 컴파일러는 <Suspense> 바깥의 영역(제목, 레이아웃 등)을 정적 HTML 껍데기(Static Shell) 로 영구 결빙한다. <Suspense> 내부는 동적 콘텐츠가 채워질 빈 구멍으로 처리된다.

사용자 접속 시 정적 껍데기가 즉시 표시되고, 각 <Suspense> 영역은 내부 컴포넌트의 렌더링이 완료되는 시점에 순차적으로 화면에 합류한다. 이것이 스트리밍(Streaming) 이다.

<Suspense> 경계 없이 동적 데이터를 직접 읽으면, 프레임워크는 정적 껍데기 생성이 불가능하다고 판단하고 빌드를 중단한다.

Step 2 — 동적 부품: 스트리밍 렌더링

// src/app/article/[id]/LiveEngagementStats.tsx

export default async function LiveEngagementStats({
  paramsPromise,
}: {
  paramsPromise: Promise<{ id: string }>;
}) {
  // Suspense 내부의 격리 공간이므로 동적 데이터를 안전하게 해제한다.
  const resolvedParams = await paramsPromise;

  // 캐시 지시어 없음: 요청마다 서버에서 최신 데이터를 조회한다.
  const currentTime = new Date().toLocaleTimeString();

  return (
    <div className="bg-blue-50 border-l-4 border-blue-500 p-6 mb-8 rounded-r-xl">
      <p className="font-bold text-blue-700 text-lg flex items-center gap-2">
        실시간 기사 통계 (ID: {resolvedParams.id})
      </p>
      <p className="text-blue-900 mt-2 font-mono text-xl">
        마지막 조회 시각:{" "}
        <span className="font-black bg-white px-2 py-1 rounded shadow-sm">
          {currentTime}
        </span>
      </p>
    </div>
  );
}

스트리밍 동작 원리

이 컴포넌트에는 'use cache' 지시어가 없다. 페이지 접속 시 정적 껍데기가 먼저 표시된 후, 서버 백그라운드에서 이 컴포넌트를 렌더링하여 완료 즉시 <Suspense> 구멍 안으로 HTML 조각을 스트리밍한다. 요청마다 새로운 데이터가 반영된다.

Step 3 — 마이크로 캐싱 부품: ‘use cache’ 결빙

// src/app/article/[id]/HeavyArticleContent.tsx

// 아키텍트의 정밀 타격: 이 컴포넌트만 독립적으로 결빙한다.
'use cache';

const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));

export default async function HeavyArticleContent({
  paramsPromise,
}: {
  paramsPromise: Promise<{ id: string }>;
}) {
  const resolvedParams = await paramsPromise;

  // 3초짜리 무거운 DB 연산 시뮬레이션 (최초 1회만 실행됨)
  await delay(3000);
  const cachedTime = new Date().toLocaleTimeString();

  return (
    <div className="bg-red-50 border border-red-200 p-8 rounded-xl shadow-inner relative overflow-hidden">
      <h2 className="text-2xl font-black text-gray-900 mb-4">
        기사 본문 (마이크로 캐싱 적용)
      </h2>
      <p className="text-gray-700 mb-6 leading-relaxed">
        이 영역은 최초 1회만 3초의 연산을 수행하고, 이후 요청에서는 캐시에서
        0.001초 만에 반환된다. 기사 ID({resolvedParams.id})에 대한 본문 데이터가
        결빙 상태로 보관된다.
      </p>
      <div className="inline-block bg-white border-2 border-red-500 px-4 py-2 rounded-lg">
        <p className="text-red-600 font-bold flex items-center gap-2">
          결빙 시각:{" "}
          <span className="font-mono text-xl">{cachedTime}</span>
        </p>
      </div>
    </div>
  );
}

’use cache’ 작동 메커니즘

파일 최상단에 선언된 'use cache'는 해당 컴포넌트의 실행 결과 전체(HTML 조각) 를 캐시 창고에 저장하도록 지시한다. 최초 요청자가 접속할 때 단 한 번 연산이 수행되며, 이후 접속하는 모든 사용자는 저장된 HTML 조각을 0.001초 만에 수신한다.

핵심은 독립성이다. 부모 페이지가 스트리밍 환경으로 동작하더라도, 'use cache'가 선언된 자식 컴포넌트는 부모의 렌더링 규칙에 종속되지 않고 독립적인 캐시 창고에서 별도로 처리된다.


5. 프로덕션 검증

npm run build
npm start

http://localhost:3000/article/42에 접속하여 다음 순서로 동작을 검증한다.

1) 최초 접속

2) 새로고침 반복

영역기대 결과
파란색 박스 (동적 부품)새로고침마다 시각이 갱신된다.
빨간색 박스 (캐싱 부품)최초 시각에 고정되며 3초 지연이 발생하지 않는다.

두 영역이 동일한 페이지에서 서로 다른 캐싱 전략으로 독립적으로 동작하는 것이 마이크로 캐싱과 스트리밍의 공존, 즉 PPR(부분 사전 렌더링)의 실체다.


6. 전체 아키텍처 흐름 요약

빌드 시점 (npm run build)
├── <Suspense> 바깥 영역  →  정적 HTML 껍데기로 영구 결빙 (0ms 로드)
├── <Suspense> 내부 [동적]  →  빈 구멍으로 처리 (런타임에 스트리밍)
└── <Suspense> 내부 ['use cache']  →  빈 구멍으로 처리 (최초 1회 연산 후 결빙)

런타임 (사용자 접속)
├── 정적 껍데기 즉시 반환
├── 동적 부품: 서버 렌더링 완료 즉시 스트리밍
└── 캐싱 부품: 캐시 창고에서 0.001초 만에 반환 (최초 이후)

<Suspense>는 스트리밍 도구이자 PPR 빌드 에러 방어막이며, 마이크로 캐싱 아키텍처에서 동적 영역과 정적 영역의 경계를 명확하게 선언하는 구조적 핵심이다. cacheComponents 환경에서 <Suspense> 없이 동적 데이터를 직접 참조하는 것은 빌드 실패를 의미한다.


Share this post on:

Previous Post
Next.js 16의 use cache 지시어와 PPR: 컴포넌트 단위 캐싱 실전 가이드
Next Post
마이크로 캐싱과 컴포넌트 단위 핀셋 통제: 하이브리드 렌더링 아키텍처