Astro Islands Architecture: 성능 중심 웹 개발의 새로운 패러다임
들어가며
Next.js의 강력한 대안으로 주목받고 있는 Astro와 그 핵심 철학인 Islands Architecture에 대해 이야기해보려 합니다.
우리는 지금까지 Next.js를 통해 훌륭한 웹 애플리케이션들을 구축해왔습니다. App Router, RSC(React Server Components)까지, Next.js는 분명 강력한 도구죠. 하지만 최근 팀 내에서 몇 가지 고민이 제기되었습니다:
- Vercel 벤더 락인에 대한 우려
- 과도한 JavaScript 번들 크기
- 복잡한 클라이언트-서버 경계 관리
- “정말 모든 프로젝트에 Next.js가 필요한가?”
이러한 고민의 답을 찾기 위해 개인 블로그 프로젝트를 Astro로 구축해봤습니다. 그 과정에서 발견한 Astro의 혁신적인 접근 방식과 Islands Architecture의 실제 구현 사례를 공유해보겠습니다.
이 글을 다 읽고 나면 Astro의 기본적인 사용법과 아키텍처, 그리고 Next.js의 문제점과 현재 Vercel이 해결하고 있는 방향에 대해 학습하실 수 있습니다.
시작하기 전에: 현재 블로그의 아키텍처
저는 이번 블로그를 Astro로 개발하면서 빌드 시간과 성능에서 높은 만족도를 얻었습니다.
Notion을 CMS로 쓰고 있어서 블로그 글이 늘어날수록 빌드 시간이 길 우려가 있었는데, Astro의 증분 빌드를 이용하면서 빌드 시간이 평균적으로 10초 이내로 줄었습니다.

- Framework: Astro 5.9
- Language: TypeScript
- Styling: Tailwind 4
- CMS: Notion API
- Deployment: GitHub Pages
Astro의 설계 철학: Zero JavaScript by Default
웹 개발의 근본적 패러독스
현대 웹 개발의 가장 큰 모순은 이것입니다: 텍스트와 이미지를 보여주기 위해 수백 KB의 JavaScript를 다운로드한다는 점입니다.
Next.js로 간단한 “About” 페이지를 만들어도 기본적으로 포함되는 것들을 보면:
// 사용자가 다운로드해야 하는 필수 JavaScript
- React Runtime: ~42KB (gzipped)
- React DOM: ~130KB (gzipped)
- Next.js Runtime: ~85KB (gzipped)
- 하이드레이션용 컴포넌트 코드: ~50KB
- 라우팅 및 기타 로직: ~30KB
// 총합: ~337KB// 실제 페이지 내용
<h1>About</h1>
<p>아주 간단한 컴포넌트입니다!</p>아이러니: 50바이트의 실제 콘텐츠를 위해 337KB의 JavaScript를 로드합니다.
Astro의 근본적 해결책

Astro는 이 문제를 “HTML First” 철학으로 해결합니다:
---
// 이 코드는 빌드 시점에만 실행됩니다
const companyInfo = {
name: "우리 회사",
mission: "단순함을 추구하는 회사입니다."
};
---
<html>
<head>
<title>About Us</title>
</head>
<body>
<h1>About {companyInfo.name}</h1>
<p>{companyInfo.mission}</p>
</body>
</html>결과물: 순수 HTML, JavaScript 0KB

이는 단순한 기술적 트릭이 아니라 “웹의 본질로 돌아가자”는 철학적 전환입니다.
하지만 현실적인 질문이 남습니다: “정적 HTML만으로는 한계가 있지 않을까요?”
검색 기능, 댓글 시스템, 다크 모드 토글 같은 상호작용은 어떻게 구현할까요? 바로 여기서 Astro의 가장 혁신적인 아이디어인 Islands Architecture가 등장합니다.
Islands Architecture: 선택적 상호작용의 우아함

기존 접근 방식의 구조적 한계
전통적 SPA 방식 (React/Vue):

SSR 방식 (Next.js):

Islands Architecture의 패러다임 전환
Astro Islands 방식:

핵심 원리: 정적 HTML의 바다 위에 떠 있는 상호작용 가능한 섬들
각 섬(Island)은:
- 독립적으로 하이드레이션됨
- 서로 간의 의존성이 없음
- 필요할 때만 JavaScript를 로드
- 다른 프레임워크(React, Vue, Svelte) 혼용 가능
실제 구현 예시
---
// 정적 컴포넌트들 (JavaScript 0KB)
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import BlogCard from '../components/BlogCard.astro';
// 동적 컴포넌트들 (필요시에만 JavaScript 로드)
import DarkModeToggle from '../components/DarkModeToggle.jsx';
import SearchBox from '../components/SearchBox.svelte';
import CommentSection from '../components/CommentSection.vue';
---
<Header />
<main>
<h1>기술 블로그</h1>
<!-- 정적 콘텐츠: HTML로만 렌더링 -->
{posts.map(post => <BlogCard post={post} />)}
<!-- Island 1: 즉시 상호작용 필요 -->
<SearchBox client:load />
<!-- Island 2: 스크롤 시 로드 -->
<DarkModeToggle client:visible />
<!-- Island 3: 브라우저 유휴 시 로드 -->
<CommentSection client:idle />
</main>
<Footer />결과:
- 정적 콘텐츠: 즉시 표시 (0KB JavaScript)
- 검색 기능: 페이지 로드 시 활성화 (~15KB)
- 다크 모드: 사용자가 스크롤할 때 활성화 (~8KB)
- 댓글: 브라우저가 유휴 상태일 때 활성화 (~25KB)
총 JavaScript: 48KB (기존 337KB 대비 86% 감소)
Islands Architecture의 개념이 인상적이지만, 실제로 각 Island가 언제, 어떻게 활성화되는지가 핵심입니다. 모든 Island를 즉시 로드한다면 기존 방식과 차이가 없을 테니까요.
Astro는 이 문제를 Client Directives라는 정교한 시스템으로 해결합니다. 마치 지휘자가 오케스트라의 각 악기에게 정확한 타이밍을 지시하듯이 말이죠.
Client Directives: 정밀한 하이드레이션 제어
Astro의 가장 강력한 기능은 언제, 어떻게 JavaScript를 로드할지 세밀하게 제어할 수 있다는 점입니다.
1. client:load - 즉시 하이드레이션
<!-- 페이지 로드 직후 즉시 활성화 -->
<PurchaseButton client:load />
<CriticalForm client:load />사용 시나리오:
- 구매 버튼, 중요한 폼
- 사용자가 즉시 상호작용할 것으로 예상되는 UI
- 비즈니스 크리티컬한 기능
성능 특성:
- 우선순위: 높음
- 로딩 시점: 페이지 로드 즉시
2. client:idle - 유휴 시간 활용
<!-- 브라우저가 바쁘지 않을 때 로드 -->
<NewsletterSignup client:idle />
<SocialShareButtons client:idle />
<!-- 타임아웃 설정 (500ms 후 강제 로드) -->
<AnalyticsWidget client:idle={{ timeout: 500 }} />사용 시나리오:
- 뉴스레터 구독, 소셜 공유 버튼
- 분석 도구, 추천 시스템
- 보조적 기능들
3. client:visible - 뷰포트 기반 지연 로딩
<!-- 화면에 보일 때 로드 -->
<HeavyChart client:visible />
<ImageGallery client:visible />
<!-- 미리 로딩 (뷰포트 200px 전에 로드) -->
<VideoPlayer client:visible={{ rootMargin: "200px" }} />사용 시나리오:
- 스크롤해야 보이는 차트, 갤러리
- 페이지 하단의 추가 콘텐츠
- 무거운 미디어 컴포넌트
4. client:media - 반응형 조건부 로딩
<!-- 모바일에서만 로드 -->
<MobileNavigation client:media="(max-width: 768px)" />
<!-- 태블릿/데스크톱에서만 로드 -->
<DesktopSidebar client:media="(min-width: 769px)" />
<!-- 고해상도 화면에서만 로드 -->
<HighResolutionChart client:media="(min-resolution: 2dppx)" />실제 비즈니스 사례:
<!-- 모바일: 햄버거 메뉴 -->
<HamburgerMenu client:media="(max-width: 768px)" />
<!-- 데스크톱: 드롭다운 메뉴 -->
<DropdownMenu client:media="(min-width: 769px)" />
<!-- 결과: 각 디바이스에서 불필요한 JavaScript 제거 -->5. client:only - 클라이언트 전용 렌더링
<!-- 브라우저 API 의존 컴포넌트 -->
<GeolocationWidget client:only="react" />
<WebRTCChat client:only="vue" />
<!-- 폴백 콘텐츠 제공 -->
<BrowserSpecificComponent client:only="react">
<div slot="fallback">
<p>브라우저에서 로딩 중...</p>
</div>
</BrowserSpecificComponent>사용 시나리오:
- 지오로케이션, 카메라 API 사용
- 브라우저별 호환성 문제가 있는 기능
- 서버에서 렌더링할 수 없는 컴포넌트
Content Collections: 타입 안전한 콘텐츠 관리와 증분 빌드
Client Directives만으로도 상당한 성능 개선을 달성할 수 있지만, 진정한 게임 체인저는 따로 있었습니다.
Notion을 CMS로 사용하는 블로그를 운영하면서 마주친 가장 큰 고통은 “포스트 하나만 수정해도 전체 사이트를 다시 빌드해야 한다”는 점이었습니다. 50개 포스트가 있을 때 오타 하나 고치려면 5분을 기다려야 했죠.
Astro의 Content Collections는 이 문제를 근본적으로 해결했습니다. 타입 안전성은 덤이고, 증분 빌드라는 혁신적 기능이 진짜 핵심이었습니다.
기존 콘텐츠 관리 방식의 한계
전통적인 Static Site Generator 문제점:
- 타입 안전성 부족: 런타임에서야 오류 발견
- 전체 재빌드: 콘텐츠 하나만 변경되어도 모든 페이지 재생성
- 데이터 소스 제약: 파일 시스템에만 의존
Next.js의 한계:

// Next.js에서 블로그 포스트 가져오기
export async function getStaticProps() {
// 매번 모든 포스트를 다시 페치
const posts = await getAllPosts();
return { props: { posts } };
}
export async function getStaticPaths() {
// 매번 모든 경로를 다시 생성
const posts = await getAllPosts();
return {
paths: posts.map(post => ({ params: { slug: post.slug } })),
fallback: false
};
}문제점:
- 포스트 1개만 수정되어도 전체 사이트 재빌드
- Notion 같은 외부 CMS 사용 시 API 호출 횟수 급증
- 타입 안전성 부족으로 런타임 오류 발생 가능
Astro Content Collections의 혁신

타입 안전한 스키마 정의:
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blogSchema = z.object({
title: z.string(),
description: z.string().optional(),
publishDate: z.coerce.date(),
tags: z.array(z.string()),
featured: z.boolean().default(false),
// Notion에서 가져온 구조화된 데이터notionId: z.string(),
lastModified: z.string(),
images: z.array(z.object({
url: z.string(),
alt: z.string(),
width: z.number(),
height: z.number(),
blurDataURL: z.string().optional(),
})).optional(),
});
const blog = defineCollection({
loader: notionLoader,// 커스텀 로더schema: blogSchema,
});
export const collections = { blog };증분 빌드: 게임 체인저

핵심 아이디어: 변경된 콘텐츠만 다시 빌드
// src/lib/content/loader.ts
export const notionLoader = async (): Promise<LoaderEntry[]> => {
const notion = new Client({ auth: process.env.NOTION_TOKEN });
// 1단계: 요약 정보만 먼저 가져오기 (빠른 API 호출)
const summaries = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID!,
filter: {
property: 'Status',
select: { equals: 'Published' }
},
sorts: [{ property: 'Last Modified', direction: 'descending' }]
});
const entries: LoaderEntry[] = [];
for (const page of summaries.results) {
const title = extractTitle(page);
const lastModified = extractLastModified(page);
const slug = extractSlug(page);
// 2단계: 변경 감지를 위한 digest 생성
const digest = generateDigest({ title, lastModified });
// 3단계: 기존 엔트리와 비교
const existingEntry = await getExistingEntry(slug);
if (existingEntry?.digest === digest) {
// 변경 없음: 기존 데이터 재사용
console.log(`📄 Skipping unchanged post: ${title}`);
entries.push(existingEntry);
} else {
// 변경됨: 전체 콘텐츠 가져오기
console.log(`🔄 Fetching updated post: ${title}`);
const fullContent = await getFullPostContent(page.id);
entries.push({
id: slug,
data: {
...fullContent,
digest,// 다음 빌드를 위해 digest 저장
}
});
}
}
return entries;
};
function generateDigest(data: { title: string; lastModified: string }): string {
return crypto
.createHash('md5')
.update(`${data.title}-${data.lastModified}`)
.digest('hex');
}실제 성능 개선 사례
이전 (Next.js) vs 이후 (Astro) 비교:

| 메트릭 | Next.js | Astro | 개선율 |
|---|---|---|---|
| 포스트 20개 전체 재빌드 | 5분 30초 | 1초 | 98% 감소 |
| 포스트 1개 수정 후 빌드 | 5분 30초 | 8초 | 97% 감소 |
결과적으로 달성한 것:
- 개발 경험 개선: 콘텐츠 수정 후 즉시 확인 가능
- 운영 비용 절감: API 호출 98% 감소로 Notion API 비용 절약
- CI/CD 효율성: 빌드 시간 단축으로 배포 속도 향상
- 타입 안전성: 빌드 시점에 모든 콘텐츠 검증
Server Islands: 서버 사이드 지연 렌더링
Content Collections로 빌드 타임 문제는 해결했지만, 런타임에서 여전히 남은 도전이 있었습니다.
개인화된 추천 콘텐츠, 사용자별 맞춤 설정, 실시간 데이터 등은 빌드 시점에 미리 생성할 수 없습니다. 그렇다고 전체 페이지를 SSR로 처리하면 빠른 콘텐츠 표시라는 Astro의 핵심 장점을 포기해야 하죠.
“정적 콘텐츠는 즉시 보여주면서, 동적 콘텐츠는 별도로 처리할 수는 없을까?”
Astro 4.12에서 도입된 Server Islands가 바로 이 딜레마의 해답입니다.
기존 서버 렌더링의 한계
전통적인 SSR 문제:
- 모든 서버 컴포넌트가 동시에 렌더링되길 기다려야 함
- 느린 API 호출 하나가 전체 페이지를 지연시킴
- 개인화된 콘텐츠를 위해 전체 페이지를 동적으로 렌더링
Next.js App Router의 한계:
// app/dashboard/page.tsx
export default async function Dashboard() {
// 이 두 API 호출이 모두 완료되어야 페이지 렌더링 시작
const user = await getUser();// 100ms
const recommendations = await getRecommendations();// 2000ms
return (
<div>
<UserProfile user={user} />
<RecommendationsPanel data={recommendations} />
</div>
);
}
// 총 로딩 시간: 2100ms (더 느린 것에 의해 결정됨)Server Islands의 해결책
---
// 빠른 데이터는 즉시 로드
const user = await getUser(); // 100ms
---
<div>
<UserProfile user={user} />
<!-- 느린 데이터는 별도로 지연 렌더링 -->
<RecommendationsPanel server:defer>
<div slot="fallback">
<SkeletonLoader />
<p>맞춤 추천을 불러오는 중...</p>
</div>
</RecommendationsPanel>
</div>
<!--
결과:
1. 사용자 정보는 100ms 후 즉시 표시
2. 추천 패널은 별도로 2초 후 로드되어 교체
3. 전체 페이지 로딩 시간: 100ms
-->Next.js 블로그 버전과 성능 측정 비교
Next.js 버전 성능 측정
Astro로 만든 블로그와 유사하게 컨텍스트 용량이 큰 Gemini 2.5 Pro를 이용하여 Next.js 버전을 빠르게 만들어서 비교를 했습니다.
페이지 구성이 간단한 만큼 원활한 비교를 위해 CPU 쓰로틀링은 4배, 네트워크 쓰로틀링은 느린 4G로 설정하여 비교했습니다.
테스트 환경
공통 조건:
- 동일한 블로그 콘텐츠와 디자인
- Tailwind v4 사용
- SSG (빌드 타임에 Notion에서 블로그 글 생성)
- CPU 쓰로틀링 4배, 네트워크 쓰로틀링 느린 4G
Next.js 버전:
- Next.js 15
- React 19

성능 측정 결과
TTFB: 36ms

많은 js 파일


LCP 이후에도 React의 Hydration이 수행되는 것을 볼 수 있습니다.
Astro 버전 성능 측정
- Astro 5.9
- Astro Collections API를 사용하여 빌드 타임에 블로그 글 생성
- Tailwind v4

TTFB: 4ms

심플한 네트워크 구조

성능 차이의 원인
Next.js의 경우:
- React 기반이므로 하이드레이션이 필수
- 정적 컴포넌트도 Virtual DOM 필요
- LCP 이후에도 스크립트 실행 시간 소요
Astro의 경우:
- 정적 컴포넌트는 순수 HTML로 빌드
- 하이드레이션이 필요한 부분만 선택적 처리
- LCP 이후 즉시 상호작용 가능
특히 CPU 제약이 있는 환경에서 두 프레임워크의 성능 차이가 더욱 극명하게 드러났습니다.
예상했던대로 Next.js는 리액트 기반인 만큼, Hydration이 필수이기 때문에 Astro 버전보다 스크립트 실행 시간이 6배 가량 높은 것을 볼 수 있습니다.
LCP는 모두 약 1.5초로 큰 차이가 없었지만 Next.js 버전의 TTI는 2초 가량 걸렸고, Astro 버전은 LCP 이후 즉시 인터랙션이 가능했습니다.
인상적인 성능 개선 수치들이지만, “왜 이런 차이가 발생하는 걸까요?”라는 질문이 자연스럽게 떠오릅니다.
단순히 JavaScript를 덜 쓴다고 해서 이 정도 성과가 나올까요? 그 배후에는 근본적으로 다른 아키텍처 철학이 있습니다. Next.js와 Astro가 어떻게 다른 접근 방식을 취하는지 구조적으로 분석해보겠습니다.
아키텍처 심화 비교: Next.js vs Astro
렌더링 전략 비교

Next.js App Router (React Server Components):
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
// 서버 컴포넌트: 서버에서 렌더링
const post = await getPost(params.slug);
return (
<div>
<PostHeader post={post} />
<PostContent content={post.content} />
{/* 클라이언트 컴포넌트: 하이드레이션 필요 */}
<LikeButton postId={post.id} />
<CommentSection postId={post.id} />
</div>
);
}Astro Islands:
---
// src/pages/blog/[slug].astro
const { slug } = Astro.params;
const post = await getPost(slug);
---
<div>
<!-- 정적 렌더링: JavaScript 0KB -->
<PostHeader post={post} />
<PostContent content={post.content} />
<!-- 선택적 하이드레이션 -->
<LikeButton postId={post.id} client:visible />
<CommentSection postId={post.id} client:idle />
</div>핵심 차이점:
- Next.js: React 기반이므로 정적 컴포넌트도 Virtual DOM을 가지며, LCP 이후 하이드레이션 과정 필요
- Astro: 정적 컴포넌트는 빌드 타임에 순수 HTML로 변환되고, 사용자가 원하는 부분만 선택적 하이드레이션
Next.js의 새로운 기능: PPR(Partial Prerendering)
Partial Prerendering (PPR)은 Next.js 14에서 도입된 실험적 렌더링 기법으로, 정적 생성과 서버 사이드 렌더링의 장점을 하나의 페이지에서 동시에 활용할 수 있게 하는 혁신적인 접근법입니다. 이는 웹 성능 최적화의 새로운 패러다임을 제시하며, 특히 복합적인 데이터 요구사항을 가진 현대적 웹 애플리케이션에서 significant한 성능 향상을 기대할 수 있습니다.
https://nextjs.org/docs/app/getting-started/partial-prerendering
PPR의 등장 배경
Next.js 15에서 도입된 PPR(Partial Prerendering)은 흥미롭게도 Astro의 Islands Architecture와 매우 유사한 철학을 가집니다. 정적 부분은 빌드 타임에 미리 생성하고, 동적 부분만 런타임에 스트리밍으로 처리하는 방식이죠.

기존 렌더링 전략의 한계
SSG의 제약:
// SSG의 딜레마 - 전자상거래 제품 페이지 예시
export async function getStaticProps({ params }: { params: { id: string } }) {
const product = await getProductInfo(params.id)// 정적 데이터
const stock = await getRealTimeStock(params.id)// 실시간 데이터
const userPrice = await getUserSpecificPrice(params.id)// 사용자별 데이터
const reviews = await getLatestReviews(params.id)// 자주 변경되는 데이터
return {
props: { product, stock, userPrice, reviews },
revalidate: 60// 매분 재생성하면 비효율적, 안 하면 데이터 부정확
}
}SSR의 워터폴 문제:
// SSR의 워터폴 문제 - 대시보드 페이지 예시
export async function getServerSideProps() {
// 순차적으로 실행되어 총 대기시간이 누적됨
const userProfile = await fetchUserProfile()// 2초
const notifications = await fetchNotifications()// 1.5초
const analytics = await fetchAnalytics()// 3초
const recommendations = await fetchRecommendations()// 2초// 총 8.5초 후에야 페이지가 사용자에게 전송됨
return {
props: {
userProfile,
notifications,
analytics,
recommendations
}
}
}
App Router와 React Server Components의 등장
이러한 한계를 해결하기 위해 Next.js 13에서 App Router와 RSC(React Server Components)가 도입되었습니다. RSC는 혁신적인 접근법으로, 컴포넌트 단위로 서버/클라이언트 렌더링을 결정할 수 있게 했습니다.
// App Router에서의 RSC - 컴포넌트 단위 렌더링 전략
// app/product/[id]/page.tsx
// 서버 컴포넌트 - 서버에서 렌더링
async function ProductInfo({ productId }: { productId: string }) {
const product = await getProduct(productId)// 서버에서 실행
return <div>{product.name}</div>
}
// 클라이언트 컴포넌트 - 브라우저에서 실행
'use client'
function AddToCartButton({ productId }: { productId: string }) {
const [isLoading, setIsLoading] = useState(false)
// 상호작용 로직...
return <button>장바구니 추가</button>
}
// 페이지 컴포넌트 - 하이브리드 구성
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
<ProductInfo productId={params.id} /> {/* 서버 렌더링 */}
<AddToCartButton productId={params.id} /> {/* 클라이언트 렌더링 */}
</div>
)
}RSC는 서버 컴포넌트와 클라이언트 컴포넌트를 분리하는 등 많은 문제를 해결했지만, 여전히 한계가 있었습니다:
RSC의 한계점
- 여전한 서버 의존성: 서버 컴포넌트는 매 요청마다 서버에서 렌더링되어야 함
- 부분적 캐싱의 어려움: 페이지 내 일부만 정적으로 캐시하기 어려움
- 초기 로딩 지연: 서버 컴포넌트 렌더링 완료까지 사용자는 대기해야 함
- 복잡한 로딩 상태 관리: 서버/클라이언트 경계에서의 로딩 처리가 복잡
// RSC의 한계 - 여전히 서버 렌더링 필요
async function UserDashboard() {
// 매 요청마다 서버에서 실행됨
const user = await getCurrentUser() // 동적 데이터
const staticContent = await getStaticContent() // 정적 데이터도 매번 서버에서 처리
return (
<div>
<Header /> {/* 정적이지만 매번 서버 렌더링 */}
<UserProfile user={user} />
<StaticContent content={staticContent} />
</div>
)
}정리하면 App Router 방식도 여전히 전체 페이지를 하나의 전략으로만 렌더링하게 된다는 것입니다. (모든 것을 빌드타임or모든 것을 런타임)
PPR의 가장 혁신적인 측면은 렌더링 전략을 페이지 단위에서 컴포넌트 단위로 세분화했다는 점입니다. 이는 단순한 기술적 개선이 아닌, 웹 애플리케이션 아키텍처에 대한 근본적인 사고방식의 변화를 의미합니다.
기존 스트리밍 SSR vs PPR의 근본적 차이
PPR을 제대로 이해하려면 React 18의 기존 스트리밍 SSR과의 차이점을 명확히 해야 합니다. 두 접근법 모두 Suspense를 활용하지만, 작동 방식과 성능 특성이 근본적으로 다릅니다.

기존 스트리밍 SSR의 한계
// 기존 스트리밍 SSR - 모든 것이 서버에서 렌더링됨
export default function Dashboard() {
return (
<div>
<Header /> {/* 서버에서 렌더링 */}
<Suspense fallback={<UserSkeleton />}>
<UserProfile /> {/* 서버에서 스트리밍 */}
</Suspense>
<Suspense fallback={<DataSkeleton />}>
<Analytics /> {/* 서버에서 스트리밍 */}
</Suspense>
</div>
)
}
// 문제점:
// 1. 매 요청마다 Header도 서버에서 렌더링
// 2. CDN 캐싱 불가능 (전체가 동적)
// 3. 서버 리소스 지속적 소모
// 4. 지역별 성능 편차 존재PPR의 혁신적 접근
// PPR - 정적 셸 + 동적 스트리밍의 하이브리드
export const experimental_ppr = true
export default function Dashboard() {
return (
<div>
<Header /> {/* 빌드 타임에 정적 생성, CDN에서 즉시 제공 */}
<Navigation /> {/* 빌드 타임에 정적 생성 */}
<Suspense fallback={<UserSkeleton />}>
<UserProfile /> {/* 런타임에 서버에서 스트리밍 */}
</Suspense>
<Suspense fallback={<DataSkeleton />}>
<Analytics /> {/* 런타임에 서버에서 스트리밍 */}
</Suspense>
<Footer /> {/* 빌드 타임에 정적 생성 */}
</div>
)
}성능 및 사용자 경험의 차이

PPR 빌드 결과물 구조
.next/
├── static/
│ └── chunks/ # 정적 컴포넌트들의 번들
├── server/
│ └── app/ # 서버 컴포넌트들
│ ├── page.html # 동적 부분을 위한 서버 함수
│ └── page.prefetch.rsc # 프리페치용 데이터
├── prerender-manifest.json # PPR 메타데이터
└── routes-manifest.json # 라우팅 정보page.html 확인

PPR을 활성화한 페이지에서 빌드 시 html로 정적 생성된 것을 볼 수 있다.
prerender-manifest.json 구조 분석
실제 프로젝트에서 생성된 prerender-manifest.json을 살펴보면 PPR의 동작 방식을 이해할 수 있습니다:
{
"version": 4,
"routes": {
"/": {
"renderingMode": "PARTIALLY_STATIC",
"experimentalPPR": true,
"experimentalBypassFor": [
{
"type": "header",
"key": "Next-Action"
},
{
"type": "header",
"key": "user-agent",
"value": "Mediapartners-Google|Chrome-Lighthouse|..."
}
],
"srcRoute": "/",
"dataRoute": "/index.rsc",
"prefetchDataRoute": "/index.prefetch.rsc"
},
"/posts/[slug]": {
"renderingMode": "PARTIALLY_STATIC",
"experimentalPPR": true,
"dataRoute": "/posts/[slug].rsc",
"prefetchDataRoute": "/posts/[slug].prefetch.rsc"
}
},
"dynamicRoutes": {
"/posts/[slug]": {
"experimentalPPR": true,
"renderingMode": "PARTIALLY_STATIC",
"routeRegex": "^/posts/([^/]+?)(?:/)?$",
"dataRoute": "/posts/[slug].rsc",
"prefetchDataRoute": "/posts/[slug].prefetch.rsc"
}
}
}4.4.3 주요 필드 해석
1. renderingMode: “PARTIALLY_STATIC”
- 해당 라우트가 PPR을 사용함을 나타냄
- 정적 셸과 동적 스트리밍을 조합
2. experimentalPPR: true
- PPR 기능이 활성화되었음을 확인
- 개발자가
experimental_ppr = true로 설정한 결과
3. dataRoute와 prefetchDataRoute
dataRoute: 동적 데이터를 스트리밍하는 엔드포인트prefetchDataRoute: 프리페칭을 위한 최적화된 엔드포인트
4. experimentalBypassFor
- 특정 조건에서 PPR을 우회하고 전체 SSR로 fallback
- 봇 트래픽이나 특수한 헤더에 대한 처리
RSC Wire Format: .prefetch.rsc 파일 구조 분석
PPR에서 생성되는 .prefetch.rsc 파일은 React Server Components의 직렬화된 형태로, RSC Wire Format이라고 불리는 구조를 가집니다. 실제 파일을 분석해보면 PPR의 동작 방식을 더 깊이 이해할 수 있습니다.
export default async function HomePage({ searchParams }: HomePageProps) {
return (
<div className="container mx-auto px-4 py-8 sm:px-6 lg:px-8">
{/* 정적으로 렌더링되는 부분 */}
<StaticPostsList />
{/* 동적으로 렌더링되는 부분 */}
<Suspense fallback={<PaginationSkeleton />}>
<DynamicPagination searchParams={searchParams} />
</Suspense>
</div>
);
}// .next/server/app/index.prefetch.rsc 파일 구조 분석
// 1. React Component 참조 정의
1:"$Sreact.fragment"// React Fragment 참조
2:I[6767,["608","static/chunks/..."]]// Import 지시자 (청크 파일들)
// 2. HTML Head Links (정적 리소스)
:HL["/_next/static/media/e4af272ccee01ff0-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
:HL["/_next/static/css/c2248972195f422b.css","style"]
// 3. 페이지 구조 정보 (JSON 직렬화된 React Element Tree)
0:{"P":null,"b":"Vyki5-x7o4kWpAUgG2I2r","p":"","c":["",""],"i":false,"f":[[
["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],
// React element structure in JSON format
["$","html",null,{
"lang":"ko",
"className":"scroll-smooth",
"children":[
["$","head",null,{"children":["$","meta",null,{"name":"theme-color","content":"#ffffff"}]}],
["$","body",null,{
"className":"__className_e8ce0c flex min-h-screen flex-col bg-white text-gray-900",
"children":[
["$","$L2",null,{}],// 컴포넌트 참조
["$","main",null,{
"className":"container mx-auto flex-1 px-4 py-8",
"children":["$","$L3",null,{"parallelRouterKey":"children",...}]
}]
]
}]
]
}]
]]}
// 4. 컴포넌트별 데이터 (정적 부분)
8:["$","div",null,{"className":"container mx-auto px-4 py-8","children":["$L13",[
"$","$10",null,{
"fallback":["$","div",null,{"className":"flex justify-center items-center space-x-2 mt-8"}],
"children":"$L14"// Suspense로 감싸진 동적 부분에 대한 참조
}]
]}]
// 5. 메타데이터 (SEO, OG 태그 등)
c:{"metadata":[
["$","title","0",{"children":"hyunBell.dev"}],
["$","meta","1",{"name":"description","content":"개발 관련 인사이트 공유"}],
["$","meta","2",{"property":"og:title","content":"hyunBell.dev"}],
// ... 더 많은 메타데이터
]}
// 6. 정적 콘텐츠 (블로그 포스트 목록 등)
13:[["$","h1",null,{"className":"text-4xl font-bold mb-8","children":"All Posts"}],
["$","div",null,{"className":"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6","children":[
// 정적으로 생성된 블로그 포스트 카드들
["$","div","21b46def-b3e2-80e6-812f-f9f5f9009529",{
"className":"rounded-lg border border-gray-200 bg-white p-6",
"children":[
["$","$L5",null,{"href":"/posts/21b46defb3e280e6812ff9f5f9009529"}],
// ... 포스트 내용
]
}],
// ... 더 많은 포스트들
]}]]
// 7. 동적 부분에 대한 플레이스홀더
14:P// Promise placeholder - 동적 데이터를 위한 자리표시자RSC Wire Format의 주요 특징
1. 컴포넌트 참조 시스템
$L2,$L3등은 다른 컴포넌트에 대한 참조- 서버에서 렌더링된 컴포넌트들을 효율적으로 참조
2. JSON 직렬화된 React Elements
["$","div",null,{props}]형태로 React elements 표현- 첫 번째 요소:
"$"(React element 식별자) - 두 번째 요소: 태그명 또는 컴포넌트 참조
- 세 번째 요소: key (대부분 null)
- 네 번째 요소: props 객체
3. 지연 로딩 및 스트리밍 지원
P(Promise placeholder): 동적 데이터에 대한 자리표시자- Suspense 경계에서 사용되어 스트리밍 가능
4. 정적 리소스 최적화
:HL[]구문으로 CSS, 폰트 등 정적 리소스 사전 로딩- 정적 부분은 즉시 사용 가능한 형태로 직렬화
TTFB: 5ms로 기존 36ms보다 훨씬 개선되었고, Astro와 유사한 값을 달성하게 됨.

스크립트 실행 시간도 여전히 Astro에 비해 길지만, 기존보다 훨씬 개선된 것을 볼 수 있었음.
PPR이 아직 베타 상태인 이유
하지만 PPR이 아직 베타 상태인 이유를 보면, 이러한 접근법이 React 생태계에서 얼마나 복잡한 문제인지 알 수 있습니다:
- 자동 분석의 복잡성: Next.js가 복잡한 React 컴포넌트 트리를 분석해서 어떤 부분이 정적이고 어떤 부분이 동적인지 자동으로 판단해야 합니다.
- 하이드레이션 불일치 위험: 서버와 클라이언트 렌더링 결과가 다를 경우 예상치 못한 오류가 발생할 수 있습니다.
- 생태계 호환성: 기존 React 라이브러리들과의 호환성 검증이 필요합니다.
결국 PPR은 “React의 복잡성을 유지하면서도 Astro와 같은 성능을 내려는” 시도이지만, 그 과정에서 상당한 기술적 복잡성을 감수해야 합니다.
명시적 제어 vs 자동 분석
Astro가 PPR과 비슷한 개념을 사용하면서도 훨씬 안정적인 이유는 “명시적 제어”에 있습니다:
Astro의 접근법:
<!-- 개발자가 명확히 제어 -->
<StaticComponent />
<!-- 항상 정적 -->
<InteractiveComponent client:load />
<!-- 명시적으로 동적 -->PPR의 접근법:
// Next.js가 자동으로 분석해야 함
function Component() {
const data = await fetchData(); // 정적? 동적?
return <div>{data}</div>;
}Astro는 개발자가 client:load, client:visible, client:idle 등의 지시어로 정확히 언제 어떤 컴포넌트가 하이드레이션될지 제어합니다. 이는 예측 가능하고 디버깅하기 쉬운 환경을 만들어줍니다.
반면 PPR은 복잡한 휴리스틱을 통해 자동으로 판단하기 때문에, 예상치 못한 동작이나 성능 이슈가 발생할 가능성이 있습니다.
미래 전망과 생태계 동향
Islands Architecture의 확산
유사한 접근법을 채택한 프레임워크들:
- Fresh (Deno): Deno 런타임 기반 Islands Architecture
- Qwik: “Resumability” 개념으로 하이드레이션 최적화
- Marko: eBay에서 개발한 컴포넌트 기반 Islands
- Elder.js: Svelte 기반 정적 사이트 생성기
- Next.js: Partial Prerendering 실험
- React: Server Components로 유사한 선택적 렌더링 도입
.
대부분의 프론트엔드 프레임워크에서 Islands Architecture 개념을 차용하고 있습니다. 기존의 자바스크립트 100%에서 html 중심으로 바뀌고 개발자가 지정하는 부분에만 자바스크립트를 사용하는 개념이 도입되고 있습니다.
결론: 맥락에 맞는 도구를 선택하는 것이 시니어의 역량이다
Next.js는 여전히 제가 가장 신뢰하고 애용하는 프레임워크 중 하나입니다. 복잡한 비즈니스 로직, 사용자 인증, 동적인 데이터 상호작용이 필수적인 대규모 웹 애플리케이션을 구축해야 한다면 주저 없이 Next.js를 선택할 것입니다.
하지만 제 기술 블로그는 그런 프로젝트가 아니었습니다. 이 프로젝트의 핵심은 빠르게 로딩되는 콘텐츠와 지속 가능한 개발 경험이었습니다. 이러한 맥락에서 Astro는 Next.js가 제공하는 강력한 기능들의 “복잡성 비용” 없이, 프로젝트의 본질에 정확히 부합하는 가치를 제공했습니다.
결국 시니어 엔지니어의 역량은 특정 기술에 대한 숙련도뿐만 아니라, 프로젝트의 요구사항과 맥락을 정확히 파악하고, 그에 가장 적합한 도구를 선택할 수 있는 능력에 있다고 믿습니다. 저의 이번 Astro 블로그 구축 경험은 그 믿음을 다시 한번 확인시켜 준 소중한 여정이었습니다.
혹시 당신도 콘텐츠 중심의 웹사이트를 구상하고 있다면, 잠시 멈춰서 Astro라는 선택지를 진지하게 검토해 보시길 권합니다.