Astro Server Islands vs Next.js Partial Prerendering(PPR): 내부 동작과 캐싱 전략 비교
이런 분께 권장합니다: Next.js App Router 또는 Astro를 실무에서 써본 프론트엔드 개발자.
React Suspense와Server Components개념에 익숙하다면 훨씬 읽기 편합니다.
처음 이 두 기술을 나란히 놓고 봤을 때 솔직히 "그냥 비슷한 거 아닌가?" 싶었습니다. 둘 다 "빠른 정적 HTML을 먼저 보내고, 느린 동적 데이터는 나중에 채운다"는 아이디어를 공유하거든요. 그런데 동적 콘텐츠를 실제로 어떻게 전달하는지 파고들면 설계 철학이 완전히 다릅니다.
Astro Server Islands는 정적 셸과 동적 island를 완전히 분리된 요청으로 나눠 각각을 독립적으로 캐시할 수 있게 합니다. Next.js PPR은 반대로 단일 HTTP 스트림 안에서 정적 부분과 동적 부분을 연속적으로 흘려보냅니다. 이 차이가 캐싱 전략, 인프라 요구사항, 실무에서 체감하는 트레이드오프를 전부 결정합니다.
이 글에서는 두 기술의 내부 동작을 props 직렬화, island 엔드포인트, postponedState 관리까지 뜯어보고, "우리 서비스에는 어느 쪽이 맞는가"를 판단할 수 있는 구체적인 기준을 잡아보겠습니다. 이 글은 Next.js 16 / Astro 5.x 기준으로 작성됐습니다.
핵심 개념
Astro Server Islands — 섬처럼 독립된 동적 컴포넌트
Astro는 원래 정적 HTML 바다 위에 인터랙티브한 UI 컴포넌트를 "섬처럼" 배치하는 Islands Architecture로 유명했습니다. 핵심 아이디어는 "필요한 부분에만 JavaScript를 올린다"는 것인데, 여기서 인터랙션은 클라이언트 사이드입니다. Server Islands는 이 개념을 서버 렌더링 영역으로 확장한 것입니다. 즉, 브라우저가 아니라 서버에서 동적으로 렌더링하는 컴포넌트를 섬으로 분리합니다.
컴포넌트에 server:defer를 붙이면, 빌드 시점에 해당 자리를 슬롯(placeholder)으로 남겨두고, 브라우저가 페이지를 로드한 후 /_server_island/ 내부 라우트로 별도의 GET 요청을 보내 동적 컴포넌트를 가져와 채워 넣습니다.
---
// ProductPage.astro
import UserAvatar from '../components/UserAvatar.astro';
import ProductContent from '../components/ProductContent.astro';
---
<html>
<body>
<!-- 빌드 타임에 렌더링 → CDN에 캐시 -->
<ProductContent />
<!-- 요청 시마다 /_server_island/ 경로로 별도 GET 요청 -->
<UserAvatar server:defer>
<div slot="fallback">로딩 중...</div>
</UserAvatar>
</body>
</html>여기서 흥미로운 구현 디테일이 있습니다. island에 전달하는 props는 AES 암호화된 문자열로 URL 쿼리 파라미터에 담겨 전송됩니다. props가 너무 크면(URL 길이 초과 시) 자동으로 POST 요청으로 전환되는데, POST는 브라우저에서 캐시되지 않으니 CDN 캐싱 이점이 사라집니다. props를 최소화해야 하는 이유가 바로 여기 있습니다.
AES 암호화 in props 직렬화 — Astro는 props를 그대로 URL에 노출하지 않기 위해 Web Crypto API를 사용해 암호화합니다.
/_server_island/엔드포인트가 어느 URL로 요청을 보내는지 알아야 CDN 캐시 규칙 설정이나 디버깅이 가능합니다. 롤링 배포나 멀티리전 환경에서는 암호화 키 불일치 문제가 발생할 수 있으니 키 관리 전략을 미리 세워두는 것을 권장합니다.
Next.js PPR(Partial Prerendering) — 하나의 스트림으로 정적과 동적을 함께
Next.js PPR은 접근법이 근본적으로 다릅니다. <Suspense> 경계로 정적/동적 영역을 구분하고, 단일 HTTP 스트림 안에서 정적 셸과 동적 콘텐츠를 순서대로 흘려보냅니다.
// app/product/[id]/page.tsx
import { Suspense } from 'react';
import ProductInfo from './ProductInfo';
import DynamicPricing from './DynamicPricing';
export default function ProductPage() {
return (
<main>
{/* 빌드 타임에 렌더링된 정적 셸 */}
<ProductInfo />
{/* 요청 시 스트리밍으로 전달 */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPricing />
</Suspense>
</main>
);
}내부적으로 React의 prerender() + postpone 내부 API가 핵심입니다. 빌드 시 Next.js는 <Suspense> 경계를 만나면 렌더링을 "중단(postpone)"하고 postponedState라는 직렬화된 블롭(blob)을 생성합니다.
postponedState — 어디서 렌더링을 멈췄는지에 대한 메타데이터 덩어리입니다. CDN에는 정적 셸 HTML이 올라가고,
postponedState는 Next.js 내부 캐시 레이어에서 추상화됩니다. Vercel 환경에서는 자동으로 처리되지만, 셀프호스팅 환경에서는 어댑터에 따라 별도 스토리지(KV, Blob 등)가 필요할 수 있습니다. 요청이 들어오면 CDN이 정적 셸을 즉시 반환하고, origin 서버가postponedState를 기반으로 동적 부분을 이어서 스트리밍합니다.
Next.js 16의 변화 — 'use cache'와 완전 opt-in 캐싱
버전 참고: Next.js 15까지 PPR은
experimental.ppr: true로 활성화했습니다. Next.js 16에서는 Cache Components 형태로 안정화 단계에 진입하면서 활성화 방식이experimental.cacheComponents: true로 변경됐습니다. 이 글의 코드 예시는 Next.js 16 기준입니다.
이전 App Router에서 암묵적으로 동작하던 fetch 자동 캐시가 전부 제거되고, 'use cache' 디렉티브로 명시적으로 캐싱을 선언하는 완전 opt-in 모델로 바뀌었습니다.
// next.config.js
module.exports = {
experimental: {
cacheComponents: true,
},
};// 컴포넌트 수준에서 캐싱 선언
'use cache';
export async function ProductReviews({ id }: { id: string }) {
const reviews = await fetchReviews(id);
return <ReviewList reviews={reviews} />;
}처음엔 "왜 자동 캐싱을 없애지?"라는 생각이 들 수 있는데, 실무에서 "왜 이 데이터가 stale하지?" 디버깅하다가 암묵적 캐싱 때문에 한 번이라도 고생해봤다면 이 결정이 반갑게 느껴질 겁니다.
그렇다면 실제 프로젝트에서는 두 기술이 어떻게 다를까요?
실전 적용
예시 1: 이커머스 상품 상세 페이지 (Astro Server Islands)
상품 이름, 이미지, 설명은 수 주~수 개월 TTL로 CDN에 캐시해두고, 사용자별 아바타나 실시간 재고/가격 정보는 island로 분리하는 패턴입니다. 가장 큰 장점은 느린 island가 빠른 island를 블로킹하지 않는다는 점입니다.
---
// app/pages/product/[id].astro
export const prerender = true; // SSG 모드: 빌드 타임에 정적 HTML 생성
// server:defer island를 쓰려면 반드시 선언
import ProductHero from '../components/ProductHero.astro';
import UserAvatar from '../components/UserAvatar.astro';
import StockBadge from '../components/StockBadge.astro';
import PriceTag from '../components/PriceTag.astro';
const { id } = Astro.params;
const product = await getProduct(id); // 빌드 타임에 가져와 정적 HTML에 포함
---
<html>
<body>
<!-- 정적: CDN 캐시, 빌드 타임 렌더링 -->
<ProductHero product={product} />
<!-- 동적 islands: 각자 /_server_island/ 경로로 독립 로드 -->
<UserAvatar server:defer>
<div slot="fallback" class="avatar-skeleton" />
</UserAvatar>
<!-- ID 문자열만 넘기므로 URL 크기 문제 없음 -->
<StockBadge productId={id} server:defer>
<div slot="fallback" class="badge-skeleton" />
</StockBadge>
<PriceTag productId={id} server:defer>
<span slot="fallback">--</span>
</PriceTag>
</body>
</html>| 컴포넌트 | 렌더링 시점 | 캐싱 전략 | 비고 |
|---|---|---|---|
ProductHero |
빌드 타임 | CDN, 장기 TTL | on-demand invalidation 또는 재배포로 갱신 가능 |
UserAvatar |
요청 시 (GET) | 사용자별 no-cache | 개인화 데이터 |
StockBadge |
요청 시 (GET) | 단기 TTL (30s) | 재고는 자주 변하지 않음 |
PriceTag |
요청 시 (GET) | no-cache | 실시간 가격 |
예시 2: 이커머스 상품 상세 페이지 (Next.js PPR)
같은 시나리오를 PPR로 구현하면 추가 HTTP 요청 없이 단일 스트림으로 처리됩니다. TTFB가 API 레이턴시와 무관하게 CDN 엣지 기준으로 고정된다는 게 핵심입니다.
// app/product/[id]/page.tsx
import { Suspense } from 'react';
import ProductHero from './components/ProductHero';
import UserAvatar from './components/UserAvatar';
import StockBadge from './components/StockBadge';
import PriceTag from './components/PriceTag';
// generateStaticParams는 PPR과 별개로 동작합니다.
// 해당 경로를 빌드 타임 SSG로 사전 생성할 때 사용하며,
// PPR 자체는 이 함수 없이도 동작합니다.
export async function generateStaticParams() {
const products = await getTopProducts();
return products.map(p => ({ id: p.id }));
}
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<main>
{/* 정적 셸: 빌드 타임 렌더링 */}
<ProductHero id={params.id} />
{/* 동적 영역들: 단일 스트림으로 전달 */}
<Suspense fallback={<AvatarSkeleton />}>
<UserAvatar />
</Suspense>
<div className="product-meta">
<Suspense fallback={<StockSkeleton />}>
<StockBadge productId={params.id} />
</Suspense>
<Suspense fallback={<PriceSkeleton />}>
<PriceTag productId={params.id} />
</Suspense>
</div>
</main>
);
}// app/product/[id]/components/PriceTag.tsx
'use cache';
import { cacheTag, cacheLife } from 'next/cache';
export async function PriceTag({ productId }: { productId: string }) {
cacheTag(`price-${productId}`);
cacheLife('seconds'); // 단기 TTL — no-cache가 아니라 수 초 단위 캐시
const price = await fetchCurrentPrice(productId);
return <span className="price">{price.formatted}</span>;
}| 특성 | Astro Server Islands | Next.js PPR |
|---|---|---|
| HTTP 요청 수 | 1(셸) + N(islands) | 1(스트리밍) |
| TTFB | CDN 기준 즉시 | CDN 기준 즉시 |
| island 독립성 | 완전 독립 | Suspense 경계 단위 |
| 캐싱 선언 방식 | Cache-Control 헤더 |
'use cache' 디렉티브 |
| 인프라 종속성 | 낮음 | Vercel 또는 전용 어댑터 |
장단점 분석
장점
| 항목 | Astro Server Islands | Next.js PPR |
|---|---|---|
| CDN 캐시 가능성 | 정적 셸 100% 캐시 가능 | 스트리밍 응답이라 제한적 |
| 추가 RTT | island마다 독립 요청이지만 병렬 처리 | 없음, 단일 스트림 |
| 인프라 이식성 | Node.js, Docker, Cloudflare Workers 등 어디서나 | Vercel 또는 PPR 어댑터 필요 |
| 생태계 통합 | 프레임워크 무관 | React Suspense, Server Components와 긴밀하게 통합 |
| 장애 격리 | 한 island 오류가 다른 island에 영향 없음 | Suspense 경계 설계에 따라 다름 |
스트리밍 vs 분리 요청 — PPR의 단일 스트림은 CDN이 스트리밍 청크를 바이트 단위로 캐싱하기 어렵습니다. Astro의 분리 요청은 island 응답마다 독립적인
Cache-Control헤더를 붙일 수 있어 island 단위 캐싱 제어가 훨씬 유연합니다.
RTT(Round Trip Time) — 클라이언트와 서버 사이 요청-응답 한 왕복에 걸리는 시간입니다. island마다 추가 RTT가 발생하는 Astro와 달리, PPR은 첫 응답 스트림에 모든 게 실려 오므로 RTT 추가가 없습니다.
단점 및 주의사항
경험 기반으로 걸러낸 지뢰들입니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| [Islands] waterfall 지연 | JS 로드 후 island 요청 시작 → 추가 지연 발생 가능 | 중요 island는 <link rel="preload">로 미리 힌트 |
| [Islands] props 크기 제한 | URL 길이 초과 시 POST 전환 → CDN 캐싱 불가 | props를 ID만 넘기고 데이터는 island 내부에서 fetch |
| [Islands] 암호화 키 불일치 | 롤링 배포·멀티리전 환경에서 키 버전 불일치 발생 가능 | 배포 시 키 로테이션 전략 명확히 수립 |
| [PPR] postponedState 관리 | 셀프호스팅 시 어댑터에 따라 별도 스토리지 필요 | Vercel이나 공식 어댑터 사용으로 추상화 |
| [PPR] Suspense 경계 설계 | 잘못 배치하면 전체가 SSR로 폴백 | cookies(), headers(), searchParams 사용 여부 기준으로 경계 최소화 |
| [PPR] 플랫폼 종속성 | 최적 동작은 Vercel 또는 전용 어댑터에서만 | 셀프호스팅 시 PPR 어댑터 구현 필요 여부 사전 확인 |
실무에서 가장 흔한 실수
-
Astro에서 props에 너무 많은 데이터를 담는 것 — props가 URL 길이 한계를 넘으면 GET이 POST로 바뀌어 CDN 캐시 효과가 사라집니다. 저도 처음에 객체 통째로 넘겼다가 캐시가 전혀 안 되는 걸 나중에야 알아챘습니다. island에는 최소한의 ID만 넘기고, 데이터는 island 서버 컴포넌트 내부에서 fetch하는 패턴이 안전합니다.
-
PPR에서
<Suspense>경계를 너무 넓게 잡는 것 — 동적 컴포넌트를 제대로 감싸지 않으면 해당 경계 전체가 동적으로 처리됩니다. "이 컴포넌트 안에서cookies(),headers(),searchParams를 쓰는가?"를 기준으로 경계를 최소 단위로 잡으면 정적 셸을 최대화할 수 있습니다. -
소셜 피드처럼 전체가 동적인 페이지에 적용하는 것 — 두 기술 모두 "정적으로 캐시 가능한 메인 콘텐츠가 있어야 효과가 있다"고 공식 문서에서 명시합니다. 사용자 타임라인처럼 셸 자체도 개인화된 경우엔 두 기술 모두 의미 있는 이점을 주지 못합니다.
마치며
"island 단위 독립적인 캐시 제어와 인프라 이식성이 우선이면 Astro Server Islands, 단일 요청 성능과 React 생태계 통합이 우선이면 Next.js PPR" — 이 한 문장이 선택의 출발점이 될 수 있습니다.
직접 써보니 예상보다 까다로웠던 부분은 양쪽 모두 "언제 어디에 경계를 긋는가"였습니다. Astro에서는 props 크기 한계, PPR에서는 Suspense 경계 배치가 핵심이었는데, 이 감각은 문서만 읽어서는 잘 생기지 않더라고요. 직접 코드를 뜯어보고 빌드 출력을 확인하는 과정이 필요했습니다.
지금 바로 시작해볼 수 있는 3단계:
-
기존 SSR 페이지에서 정적/동적 비율을 파악해보기 — Chrome DevTools Network 탭에서 페이지 요청을 보면서 "어떤 데이터가 모든 사용자에게 동일한가?"를 분리해보시면 좋습니다. 이 비율이 높을수록 두 기술 모두 큰 효과를 기대할 수 있습니다.
-
Astro를 써보신다면 —
npx create astro@latest --template minimal로 프로젝트를 만든 뒤, 페이지 상단에export const prerender = true를 선언하고 느린 API를 호출하는 컴포넌트 하나에server:defer를 붙여보시면 됩니다. server-islands.com에서 실제 island 요청이 어떻게 오가는지 먼저 확인해보시는 것도 좋습니다. -
Next.js PPR을 써보신다면 — Next.js 16 기준으로
next.config.js에experimental: { cacheComponents: true }를 추가하고, 느린fetch가 있는 컴포넌트를<Suspense>로 감싸보시면 됩니다. 빌드 결과에서 해당 라우트가◐(PPR) 심볼로 표시되는지 확인해보시면 바로 체감이 됩니다.
참고 자료
- Server Islands | Astro Docs
- The Future of Astro: Server Islands | Astro Blog
- Astro 4.12 릴리즈 노트 — Server Islands 도입 | Astro Blog
- How Astro's server islands deliver progressive rendering | Netlify Developers
- Partial Prerendering (Next.js 15) | Next.js Docs
- Cache Components (Next.js 16) | Next.js Docs
- How Partial Prerendering Works Under the Hood | Wyatt Johnson
- Partial prerendering: Building towards a new default rendering model | Vercel Blog
- Next.js 15 Partial Prerendering: Real-World Patterns and Tradeoffs | wolf-tech.io
- Next.js PPR と比較して理解する Astro Server Islands | Zenn
- Next.js PPR Deep Dive | pockit.tools