Skip to content
iik89
Go back

PPR 도입 판단 기준: Suspense 경계와 cacheLife를 적용할 시점

목차

기술을 아는 것과 도입 시점을 아는 것

PPR(Partial Prerendering)과 cacheLife의 작동 원리를 이해하는 것과, 실제 프로젝트에 도입해야 할 시점을 판단하는 것은 다른 문제다. 모든 페이지에 PPR을 적용할 필요는 없다. 단순한 정적 마케팅 페이지에 Suspense 경계를 나누는 것은 불필요한 복잡성만 추가한다.

PPR이 효과를 발휘하는 조건은 명확하다: 하나의 페이지 안에 서로 다른 렌더링 요구사항을 가진 영역이 공존할 때다.


PPR이 효과적인 세 가지 패턴

패턴 1: SEO 필수 콘텐츠 + 개인화 데이터의 공존

검색엔진 크롤러가 즉시 읽을 수 있는 정적 콘텐츠와, 로그인한 사용자에 따라 달라지는 개인화 데이터가 한 페이지에 필요한 경우다.

영역요구사항렌더링 전략
상품/서비스 설명, 가격, 이미지검색엔진이 즉시 크롤링정적 셸 또는 'use cache'
장바구니 수량, 맞춤 추천, 위시리스트사용자별로 다름동적 스트리밍 (<Suspense>)

PPR 없이 이 페이지를 구현하면 두 가지 중 하나를 포기해야 한다. 전체를 정적으로 만들면 개인화 데이터를 클라이언트 사이드에서 별도 API로 가져와야 하고(waterfall 발생), 전체를 동적으로 만들면 SEO에 불리한 느린 TTFB를 감수해야 한다.

패턴 2: 느린 외부 API가 포함된 대시보드

자체 데이터는 빠르게 조회되지만, 외부 서드파티 API 호출이 수 초씩 걸리는 경우다.

영역응답 시간렌더링 전략
레이아웃, 사이드바, 자체 DB 데이터50ms 이내정적 셸 또는 짧은 캐시
외부 결제 API 통계, 서드파티 분석 데이터3~5초동적 스트리밍 (<Suspense>)

느린 API 하나가 전체 페이지의 TTFB를 결정하는 상황에서, Suspense로 해당 영역을 격리하면 나머지 페이지는 즉시 렌더링된다. 사용자는 느린 영역이 준비되는 동안 이미 다른 콘텐츠를 소비할 수 있다.

패턴 3: 트래픽 급증 시 DB 보호가 필요한 UGC 영역

갑작스러운 트래픽 폭주가 예상되는 사용자 생성 콘텐츠(리뷰, 댓글 등) 영역이다.

영역트래픽 특성렌더링 전략
콘텐츠 본문변경 빈도 낮음'use cache' + 긴 TTL
리뷰, 댓글, 평점변경 빈도 높음 + 트래픽 급증 가능'use cache' + cacheLife("minutes")

수만 건의 동시 요청이 매번 DB를 직접 호출하면 서버가 버티지 못한다. cacheLife("minutes")를 적용하면 1분 동안 하나의 캐시를 공유하므로, DB 조회 횟수를 극적으로 줄이면서도 데이터의 신선도를 유지할 수 있다. 1분 정도 지연된 리뷰를 보여주는 것이 비즈니스적으로 허용 가능한지는 사전에 판단해야 한다.


도입이 불필요한 경우

반대로, 다음 상황에서는 PPR을 도입할 이유가 없다.


실전 구현: 여행 예약 플랫폼

위 패턴들을 모두 포함하는 여행 예약 플랫폼의 호텔 상세 페이지를 구현한다.

영역분류렌더링 전략TTL
페이지 레이아웃, 네비게이션정적 셸빌드 시 고정없음 (영구)
호텔 정보 (위치, 설명, 시설)캐시'use cache'"days"
투숙 후기캐시 (트래픽 보호)'use cache'"minutes"
실시간 잔여 객실 + 가격동적스트리밍없음 (매 요청)

설정

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

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

페이지 컴포넌트: 정적 셸

// src/app/hotel/[slug]/page.tsx
import { Suspense } from "react";
import HotelInfo from "./HotelInfo";
import GuestReviews from "./GuestReviews";
import LiveAvailability from "./LiveAvailability";

export default function HotelPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  // params를 await하지 않는다. 정적 셸을 유지하기 위한 필수 규칙이다.

  return (
    <div>
      <header>
        <nav>홈 / 호텔 / 상세</nav>
      </header>

      <main>
        <Suspense fallback={<p>호텔 정보 로딩...</p>}>
          <HotelInfo paramsPromise={params} />
        </Suspense>

        <Suspense fallback={<p>객실 현황 확인 중...</p>}>
          <LiveAvailability paramsPromise={params} />
        </Suspense>

        <Suspense fallback={<p>후기 로딩...</p>}>
          <GuestReviews paramsPromise={params} />
        </Suspense>
      </main>

      <footer>
        <p>고객센터 | 이용약관 | 개인정보처리방침</p>
      </footer>
    </div>
  );
}

<header><footer>는 Suspense 바깥에 위치하므로 빌드 시점에 정적 HTML로 고정된다. 세 개의 Suspense 경계가 각각 독립적인 스트리밍 단위를 형성한다.

캐시 컴포넌트: 호텔 정보 (긴 TTL)

// src/app/hotel/[slug]/HotelInfo.tsx
"use cache";

import { cacheLife } from "next/cache";

export default async function HotelInfo({
  paramsPromise,
}: {
  paramsPromise: Promise<{ slug: string }>;
}) {
  cacheLife("days");

  const { slug } = await paramsPromise;
  const hotel = await fetchHotelDetail(slug);

  return (
    <section>
      <h1>{hotel.name}</h1>
      <p>{hotel.address}</p>
      <p>{hotel.description}</p>
      <ul>
        {hotel.amenities.map((a) => (
          <li key={a}>{a}</li>
        ))}
      </ul>
    </section>
  );
}

async function fetchHotelDetail(slug: string) {
  // 실제로는 DB 또는 CMS 조회
  await new Promise((r) => setTimeout(r, 500));
  return {
    name: `${slug} 그랜드 호텔`,
    address: "서울특별시 중구 명동길 14",
    description: "도심 한복판에 위치한 5성급 호텔",
    amenities: ["무료 Wi-Fi", "피트니스 센터", "루프탑 바", "발렛 파킹"],
  };
}

호텔 기본 정보는 하루에 한 번 정도 변경된다. cacheLife("days")를 적용하면 1일 간격으로 백그라운드 갱신이 발생한다. 최초 요청 시 0.5초의 DB 조회가 필요하지만, 이후 요청은 캐시에서 즉시 응답한다.

캐시 컴포넌트: 투숙 후기 (짧은 TTL, DB 보호)

// src/app/hotel/[slug]/GuestReviews.tsx
"use cache";

import { cacheLife, cacheTag } from "next/cache";

export default async function GuestReviews({
  paramsPromise,
}: {
  paramsPromise: Promise<{ slug: string }>;
}) {
  cacheLife("minutes");

  const { slug } = await paramsPromise;
  cacheTag(`reviews-${slug}`);

  const reviews = await fetchReviews(slug);
  const cachedAt = new Date().toLocaleTimeString("ko-KR");

  return (
    <section>
      <h2>투숙 후기 ({reviews.length}건)</h2>
      <ul>
        {reviews.map((r) => (
          <li key={r.id}>
            <strong>{r.author}</strong>: {r.comment} ({r.rating}/5)
          </li>
        ))}
      </ul>
      <p>
        <small>캐시 갱신: {cachedAt}</small>
      </p>
    </section>
  );
}

async function fetchReviews(slug: string) {
  await new Promise((r) => setTimeout(r, 1000));
  return [
    { id: 1, author: "여행자A", comment: "조식이 훌륭했다", rating: 5 },
    { id: 2, author: "여행자B", comment: "위치가 완벽하다", rating: 4 },
    { id: 3, author: "여행자C", comment: "객실이 넓고 깨끗했다", rating: 5 },
  ];
}

cacheLife("minutes")는 stale 5분, revalidate 1분, expire 1시간이다. 트래픽이 몰려도 1분 간격으로만 DB를 조회하므로 서버를 보호할 수 있다. cacheTag를 설정해 두면, 새 후기가 등록될 때 revalidateTag("reviews-${slug}")를 호출하여 즉시 캐시를 무효화할 수도 있다.

동적 컴포넌트: 실시간 객실 현황 (캐시 없음)

// src/app/hotel/[slug]/LiveAvailability.tsx
export default async function LiveAvailability({
  paramsPromise,
}: {
  paramsPromise: Promise<{ slug: string }>;
}) {
  const { slug } = await paramsPromise;
  const availability = await checkAvailability(slug);

  return (
    <section>
      <h2>실시간 객실 현황</h2>
      <table>
        <thead>
          <tr>
            <th>객실 유형</th>
            <th>잔여</th>
            <th>1박 요금</th>
          </tr>
        </thead>
        <tbody>
          {availability.rooms.map((room) => (
            <tr key={room.type}>
              <td>{room.type}</td>
              <td>{room.remaining}</td>
              <td>{room.price.toLocaleString()}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <p>
        <small>조회 시각: {availability.checkedAt}</small>
      </p>
    </section>
  );
}

async function checkAvailability(slug: string) {
  // 외부 예약 시스템 API 호출 시뮬레이션 (2초 소요)
  await new Promise((r) => setTimeout(r, 2000));
  return {
    checkedAt: new Date().toLocaleTimeString("ko-KR"),
    rooms: [
      { type: "스탠다드", remaining: 3, price: 180000 },
      { type: "디럭스", remaining: 1, price: 280000 },
      { type: "스위트", remaining: 0, price: 520000 },
    ],
  };
}

'use cache' 지시어가 없으므로 매 요청마다 서버에서 실행된다. 객실 잔여 수와 가격은 실시간 정확성이 필수이므로 캐시하지 않는다. 외부 예약 시스템 API가 2초 걸리더라도, 이 지연은 Suspense 경계 안에 격리되어 있으므로 호텔 정보와 네비게이션은 즉시 표시된다.


빌드 출력으로 PPR 적용 확인

npm run build를 실행하면 라우트 목록에서 각 페이지의 렌더링 전략을 확인할 수 있다.

Route (app)                    Size    First Load JS
┌ ○ /                          ...     ...
├ ○ /about                     ...     ...
├ ◐ /hotel/[slug]              ...     ...
└ ƒ /api/webhook               ...     ...

○  (Static)    정적 페이지, 빌드 시 완전히 생성
◐  (Partial)   PPR 적용, 정적 셸 + 동적 구멍
ƒ  (Dynamic)   매 요청마다 서버 렌더링

/hotel/[slug] 옆의 기호가 PPR이 정상 적용되었음을 나타낸다. 이 기호는 정적 셸이 빌드 시 생성되었고, Suspense 경계 안쪽은 런타임에 스트리밍으로 채워진다는 의미다.

만약 대신 ƒ가 표시된다면 정적 셸 생성에 실패한 것이다. 흔한 원인은 페이지 컴포넌트에서 Suspense 경계 없이 await params를 호출한 경우다.


영역 분류 체크리스트

새 페이지를 설계할 때, 각 영역을 다음 질문으로 분류한다.

정적 셸 (Suspense 바깥)

세 질문 모두 “예”라면 정적 셸에 배치한다.

캐시 영역 ('use cache' + cacheLife)

하나라도 “예”라면 캐시 후보다. TTL은 “이 데이터가 몇 분 지연되어도 치명적이지 않은가”를 기준으로 결정한다.

동적 영역 (캐시 없음)

하나라도 “예”라면 캐시하지 않는다. Suspense로 격리하여 나머지 영역의 TTFB에 영향을 주지 않도록 한다.


정리

PPR 도입 여부는 페이지의 구성을 분석하면 판단할 수 있다. 하나의 페이지에 서로 다른 갱신 주기와 데이터 소스를 가진 영역이 혼재할 때 PPR은 효과적이다. 모든 영역이 동일한 렌더링 요구사항을 가진다면 불필요한 복잡성일 뿐이다.

구현 과정에서 지켜야 할 규칙은 단순하다.

  1. 페이지 컴포넌트(정적 셸)에서 await params를 호출하지 않는다. Promise를 그대로 자식에게 전달한다.
  2. 각 영역을 독립된 <Suspense> 경계로 감싼다. 스트리밍 단위가 된다.
  3. 캐시 대상 컴포넌트에 'use cache'와 적절한 cacheLife를 적용한다.
  4. 빌드 출력에서 기호를 확인한다.

이 네 단계를 따르면, 정적 셸이 즉시 응답하고 캐시된 영역이 TTL에 따라 독립적으로 갱신되며 동적 영역만 매 요청마다 스트리밍으로 전달되는 하이브리드 페이지가 완성된다.


Share this post on:

Previous Post
Next.js 16 온디맨드 캐시 무효화: revalidatePath, revalidateTag, updateTag 실전 가이드
Next Post
캐시 TTL과 스트리밍: TTFB 병목 없는 렌더링 설계