목차
기술을 아는 것과 도입 시점을 아는 것
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을 도입할 이유가 없다.
- 완전 정적 페이지: 블로그 글, 문서, 랜딩 페이지처럼 사용자별 차이가 없는 경우. 기본 SSG로 충분하다.
- 완전 동적 페이지: 대시보드 전체가 로그인한 사용자의 데이터로 구성되어 정적 셸로 분리할 영역이 없는 경우. 이때는 모든 영역이 동적이므로 PPR의 “정적 셸 + 동적 구멍” 구조가 의미 없다.
- 단일 데이터 소스 페이지: 모든 영역의 데이터가 같은 API 한 번 호출로 해결되는 경우. 영역별로 TTL을 달리할 필요가 없다.
실전 구현: 여행 예약 플랫폼
위 패턴들을 모두 포함하는 여행 예약 플랫폼의 호텔 상세 페이지를 구현한다.
| 영역 | 분류 | 렌더링 전략 | 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 바깥)
- 모든 사용자에게 동일한가?
- 빌드 시점에 결정할 수 있는가?
- 동적 데이터(params, cookies, headers)를 읽지 않는가?
세 질문 모두 “예”라면 정적 셸에 배치한다.
캐시 영역 ('use cache' + cacheLife)
- 데이터가 자주 변경되지 않는가?
- 수 초~수 분 지연된 데이터를 보여줘도 비즈니스에 문제가 없는가?
- 동일한 요청이 반복될 가능성이 높은가?
하나라도 “예”라면 캐시 후보다. TTL은 “이 데이터가 몇 분 지연되어도 치명적이지 않은가”를 기준으로 결정한다.
동적 영역 (캐시 없음)
- 매 요청마다 결과가 달라야 하는가?
- 실시간 정확성이 비즈니스 요구사항인가?
하나라도 “예”라면 캐시하지 않는다. Suspense로 격리하여 나머지 영역의 TTFB에 영향을 주지 않도록 한다.
정리
PPR 도입 여부는 페이지의 구성을 분석하면 판단할 수 있다. 하나의 페이지에 서로 다른 갱신 주기와 데이터 소스를 가진 영역이 혼재할 때 PPR은 효과적이다. 모든 영역이 동일한 렌더링 요구사항을 가진다면 불필요한 복잡성일 뿐이다.
구현 과정에서 지켜야 할 규칙은 단순하다.
- 페이지 컴포넌트(정적 셸)에서
await params를 호출하지 않는다. Promise를 그대로 자식에게 전달한다. - 각 영역을 독립된
<Suspense>경계로 감싼다. 스트리밍 단위가 된다. - 캐시 대상 컴포넌트에
'use cache'와 적절한cacheLife를 적용한다. - 빌드 출력에서
◐기호를 확인한다.
이 네 단계를 따르면, 정적 셸이 즉시 응답하고 캐시된 영역이 TTL에 따라 독립적으로 갱신되며 동적 영역만 매 요청마다 스트리밍으로 전달되는 하이브리드 페이지가 완성된다.