CDN 캐시는 그대로, 개인화 UI만 서버에서 — Astro 5 Server Islands와 Content Layer가 콘텐츠 사이트 아키텍처를 바꾸는 방식
Next.js 프로젝트에서 "이 배너는 로그인한 사용자에게만 보여야 해"라는 요구를 받아본 적 있으실 겁니다. 그 순간부터 고민이 시작되죠. 페이지 전체를 SSR로 전환하자니 CDN 캐시를 포기해야 하고, 클라이언트에서 fetch하자니 레이아웃 시프트가 생기고, 미들웨어로 처리하자니 아키텍처가 복잡해집니다. 콘텐츠·마케팅 사이트에서 자주 겪는 이 딜레마를, Astro 5는 "페이지 전체가 아닌 컴포넌트 단위로 서버 렌더링 경계를 긋는" 방식으로 해결합니다.
Astro 5(2024년 12월 출시)에서 정식 도입된 Server Islands는 정적 HTML 페이지 안에 컴포넌트 단위의 서버 렌더링 영역을 삽입하는 렌더링 프리미티브입니다. 페이지 대부분은 CDN에서 캐시되고, server:defer가 붙은 컴포넌트만 요청 시점에 서버에서 렌더링됩니다. Content Layer API는 헤드리스 CMS, REST API, 데이터베이스 같은 외부 소스를 빌드 타임 ETL 파이프라인으로 연결해 타입-세이프한 단일 API로 조회할 수 있게 해줍니다. 이 두 기능이 맞물리면 콘텐츠 사이트가 겪는 "정적이냐 동적이냐" 이분법이 사라집니다.
이 글에서는 Server Islands의 내부 동작 원리부터, Content Layer로 Storyblok·Sanity 같은 외부 CMS를 통합하는 패턴, 실무에서 자주 실수하는 지점까지 실제 코드와 함께 살펴봅니다.
핵심 개념
Islands Architecture — "기본은 정적, 필요한 곳만"
Astro가 처음부터 가지고 있던 Islands Architecture의 핵심은 간단합니다. 페이지 대부분은 JavaScript 없는 정적 HTML로 서브하고, 상호작용이 필요한 컴포넌트만 선택적으로 수화(hydrate)합니다. client:load, client:visible 같은 디렉티브가 그 역할을 해왔죠.
Server Islands는 이 개념을 서버 쪽으로 확장합니다. 클라이언트 수화가 "브라우저에서 JS를 실행"하는 것이라면, 서버 아일랜드는 "요청 시점에 서버에서 HTML을 생성"하는 것입니다. 개인화가 필요한 컴포넌트만 서버를 거치고, 나머지는 CDN에서 처리됩니다.
두 요청은 완전히 독립적으로 동작합니다. 페이지 초기 로드는 CDN에서 정적 HTML을 반환하고, 아일랜드 요청은 브라우저가 CDN을 경유하지 않고 직접 서버(또는 서버리스 함수)로 보냅니다.
server:defer — 아일랜드 선언 방법
사용법은 단순합니다. .astro 컴포넌트에 server:defer 디렉티브를 붙이면 됩니다.
---
// src/components/PersonalizedBanner.astro
const { userId } = Astro.props;
const user = await fetchUser(userId); // 요청 시점에 실행
const promo = await fetchPromo(user.region);
---
<div class="banner">
<p>안녕하세요, {user.name}님 👋</p>
<p>{promo.message}</p>
</div>---
// src/pages/landing.astro
import PersonalizedBanner from '../components/PersonalizedBanner.astro';
import HeroSection from '../components/HeroSection.astro';
import PricingTable from '../components/PricingTable.astro';
const userId = Astro.cookies.get('userId')?.value;
---
<main>
<!-- 아래 두 컴포넌트는 빌드 타임에 정적 HTML로 생성됨 -->
<HeroSection />
<PricingTable />
<!-- 이 아일랜드만 요청 시점에 서버에서 렌더링됨 -->
<PersonalizedBanner server:defer userId={userId}>
<div slot="fallback" class="banner-skeleton">로딩 중…</div>
</PersonalizedBanner>
</main>빌드 시 PersonalizedBanner가 있어야 할 자리에는 폴백 콘텐츠와 클라이언트 fetch 스크립트가 삽입됩니다. 브라우저가 페이지를 로드한 뒤 그 스크립트가 전용 엔드포인트에 요청을 보내 아일랜드 HTML을 받아와 교체하는 방식입니다.
아래 시퀀스 다이어그램을 보시면 좀 더 직관적으로 이해되실 겁니다.
Props 직렬화에 대해:
server:defer컴포넌트에 전달하는 props는 서버 생성 키로 암호화되어 URL 쿼리스트링으로 전달됩니다. props 자체가 URL에서 평문으로 읽히지 않는다는 뜻입니다. 다만 함수나 순환 참조 객체처럼 직렬화 불가능한 값은 전달할 수 없습니다.
Content Layer API — 외부 CMS를 빌드 파이프라인으로 끌어당기기
기존 Astro의 Content Collections는 로컬 Markdown 파일에 최적화되어 있었습니다. Content Layer API는 이를 외부 소스로 확장합니다. defineCollection()에 loader를 지정하면 빌드 타임에 외부 데이터를 가져와 Zod 스키마로 검증한 뒤 로컬 데이터 스토어에 캐싱합니다. 이후 getCollection() / getEntry()로 타입-세이프하게 조회할 수 있습니다.
Config 파일 위치: Astro 5부터 컬렉션 설정 파일 위치가
src/content.config.ts(프로젝트 루트 기준)로 변경되었습니다. 기존의src/content/config.ts경로와 다르니 마이그레이션 시 주의하세요.
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { storyblokLoader } from '@storyblok/astro';
const blog = defineCollection({
loader: storyblokLoader({
accessToken: import.meta.env.STORYBLOK_TOKEN,
version: 'published',
}),
schema: z.object({
title: z.string(),
publishedAt: z.coerce.date(),
slug: z.string(),
excerpt: z.string().optional(),
coverImage: z.string().url().optional(),
}),
});
export const collections = { blog };---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
const sorted = posts.sort(
(a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime()
);
---
<ul>
{sorted.map(post => (
<li>
<a href={`/blog/${post.data.slug}`}>{post.data.title}</a>
<time>{post.data.publishedAt.toLocaleDateString('ko-KR')}</time>
</li>
))}
</ul>"어차피 빌드 타임에 가져오면 CMS를 업데이트했을 때 재빌드해야 하지 않나?"라고 생각하실 수 있는데, Storyblok 공식 로더는 델타 업데이트를 지원합니다. 변경된 항목만 재요청하기 때문에 대규모 사이트에서도 증분 빌드가 빠르게 유지됩니다.
실전 적용
시나리오 1 — 마케팅 랜딩 페이지 + 지역별 프로모션
페이지 본문(헤드라인, 가격표, 기능 소개)은 CDN에서 캐시하고, 로그인 상태와 지역에 따라 다르게 보여야 하는 프로모션 배너만 Server Island로 분리하는 패턴입니다.
---
// src/components/RegionalPromo.astro
import { getEntry } from 'astro:content';
const { region, isLoggedIn } = Astro.props;
const promo = await getEntry('promotions', region);
const message = isLoggedIn
? promo?.data.memberMessage
: promo?.data.guestMessage;
---
{message && (
<div class={`promo promo--${region}`}>
<p>{message}</p>
{isLoggedIn && <a href="/my/coupons">쿠폰 확인하기 →</a>}
</div>
)}---
// src/pages/index.astro
import RegionalPromo from '../components/RegionalPromo.astro';
const region = Astro.cookies.get('region')?.value ?? 'kr';
const isLoggedIn = Boolean(Astro.cookies.get('session')?.value);
---
<main>
<HeroSection />
<FeatureGrid />
<PricingTable />
<RegionalPromo
server:defer
region={region}
isLoggedIn={isLoggedIn}
>
<div slot="fallback" class="promo-skeleton" aria-hidden="true" />
</RegionalPromo>
</main>페이지 본문은 CDN 캐시에서 제공되므로, 캐시 히트 시 TTFB는 수십 ms 수준으로 유지되면서 로그인 사용자에게 맞춤 메시지를 전달할 수 있습니다.
시나리오 2 — 블로그 + 실시간 소셜 데이터
아티클 본문은 Content Layer로 빌드 타임에 가져와 정적 HTML로 만들고, 댓글 수·좋아요 수처럼 자주 변하는 소셜 데이터만 Server Island로 처리합니다.
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { storyblokLoader } from '@storyblok/astro';
const articles = defineCollection({
loader: storyblokLoader({ accessToken: import.meta.env.STORYBLOK_TOKEN }),
schema: z.object({
title: z.string(),
slug: z.string(),
body: z.string(),
publishedAt: z.coerce.date(),
author: z.string(),
}),
});
export const collections = { articles };---
// src/components/SocialStats.astro
const { articleSlug } = Astro.props;
let stats = { comments: 0, likes: 0, views: 0 };
try {
const res = await fetch(
`${import.meta.env.API_BASE}/stats/${articleSlug}`,
{ headers: { Authorization: `Bearer ${import.meta.env.API_TOKEN}` } }
);
if (res.ok) {
stats = await res.json();
}
} catch {
// 네트워크 오류 시 기본값(0)으로 렌더링
}
---
<div class="social-stats">
<span>💬 댓글 {stats.comments}</span>
<span>❤️ 좋아요 {stats.likes}</span>
<span>👁️ 조회 {stats.views.toLocaleString('ko-KR')}</span>
</div>---
// src/pages/articles/[slug].astro
import { getEntry } from 'astro:content';
import SocialStats from '../../components/SocialStats.astro';
const { slug } = Astro.params;
const article = await getEntry('articles', slug!);
if (!article) return Astro.redirect('/404');
---
<article>
<h1>{article.data.title}</h1>
<p>by {article.data.author} · {article.data.publishedAt.toLocaleDateString('ko-KR')}</p>
<!--
set:html은 신뢰할 수 있는 소스의 HTML에만 사용하세요.
외부 사용자 입력이 body에 포함될 수 있다면 서버 사이드 sanitization이 필요합니다.
-->
<div set:html={article.data.body} />
<SocialStats server:defer articleSlug={article.data.slug}>
<span slot="fallback" class="stats-skeleton" aria-hidden="true" />
</SocialStats>
</article>시나리오 3 — 전자상거래 제품 페이지
제품 이미지·설명·스펙은 정적으로, 실시간 재고와 장바구니 버튼 상태만 아일랜드로 처리합니다.
이 시나리오에서 Server Islands의 중요한 특성이 드러납니다. 아일랜드는 서로를 블로킹하지 않습니다. 각 아일랜드는 독립적인 fetch 스크립트를 갖고 있어, 브라우저가 서버에 요청을 병렬로 보냅니다. StockStatus가 느린 재고 API를 기다리는 동안에도 CartButton은 별개의 응답이 오는 대로 먼저 렌더링될 수 있습니다. 전통적인 SSR이라면 두 데이터 소스 중 느린 쪽이 전체 TTFB를 지배하지만, Server Islands에서는 각 조각이 준비되는 대로 독립적으로 교체됩니다.
---
// src/pages/products/[id].astro
import { getEntry } from 'astro:content';
import StockStatus from '../../components/StockStatus.astro';
import CartButton from '../../components/CartButton.astro';
const product = await getEntry('products', Astro.params.id!);
if (!product) return Astro.redirect('/404');
const userId = Astro.cookies.get('userId')?.value;
---
<section>
<!-- 정적 영역 — CDN 캐시 -->
<img src={product.data.imageUrl} alt={product.data.name} />
<h1>{product.data.name}</h1>
<p>{product.data.description}</p>
<SpecTable specs={product.data.specs} />
<!-- 두 아일랜드는 병렬로, 독립적으로 로드됨 -->
<StockStatus server:defer productId={product.id}>
<span slot="fallback">재고 확인 중…</span>
</StockStatus>
<CartButton server:defer productId={product.id} userId={userId}>
<button slot="fallback" disabled>로딩 중…</button>
</CartButton>
</section>배포 시 어댑터 설정
개발 환경(astro dev)은 어댑터 없이도 Server Islands가 동작합니다. 그 때문에 로컬에서 잘 됐다고 안심하고 배포했다가 아일랜드가 전혀 렌더링되지 않는 상황이 생깁니다. 환경 변수도 올바르고 빌드도 성공했는데 아일랜드 자리가 폴백 상태에 고정되어 있다면 어댑터 설정부터 확인해보세요.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel'; // 또는 netlify, node
export default defineConfig({
output: 'static', // 기본값 — 정적 페이지
adapter: vercel(), // Server Islands 지원에 필수
integrations: [],
});프로덕션 배포에는 Netlify, Vercel, Node.js 어댑터 중 하나가 필요합니다.
실무에서 자주 하는 실수
1. React 컴포넌트에 직접 server:defer 붙이기
server:defer는 .astro 파일에만 적용됩니다. React나 Vue 컴포넌트에 직접 붙이면 조용히 무시됩니다. .astro 래퍼로 감싸야 합니다.
<!-- ❌ 이렇게 하면 동작하지 않음 -->
<ReactCart server:defer userId={userId} />---
// src/components/CartIsland.astro — .astro 래퍼
import ReactCart from './ReactCart.tsx';
const { userId } = Astro.props;
const cartData = await fetchCart(userId);
---
<ReactCart client:load initialData={cartData} /><!-- ✅ 페이지에서는 .astro 래퍼에 server:defer 적용 -->
<CartIsland server:defer userId={userId}>
<span slot="fallback">장바구니 로딩 중…</span>
</CartIsland>2. 큰 객체를 Props로 전달하기
Props는 URL 쿼리스트링으로 직렬화됩니다. 객체 전체를 넘기면 URL 길이 제한에 걸릴 수 있고, 이 경우 POST 폴백이 발생해 브라우저 캐시가 적용되지 않습니다. 객체 전체가 아니라 ID만 전달하고, 아일랜드 내부에서 필요한 데이터를 조회하는 패턴이 훨씬 안전합니다.
<!-- ❌ 객체 전체 전달 — URL 길이 초과 위험 -->
<ProductStatus server:defer product={fullProductObject} />
<!-- ✅ ID만 전달, 아일랜드 내부에서 조회 -->
<ProductStatus server:defer productId={product.id} />장단점 분석
어디에 맞고 어디에 맞지 않는지
장점
| 항목 | 내용 |
|---|---|
| 최적 캐싱 전략 | 정적 영역은 CDN에서 공격적으로 캐시, 동적 영역만 온디맨드 렌더링 |
| 독립적 병렬 로딩 | 아일랜드별 병렬 로드 — 느린 아일랜드가 다른 영역을 블로킹하지 않음 |
| 세분화된 캐시 제어 | 각 아일랜드에서 Cache-Control 헤더를 개별 설정 가능 |
| Props 암호화 | 서버 아일랜드 props는 서버 생성 키로 암호화, URL에서 평문으로 읽히지 않음 |
| 타입 안전성 | Content Layer + Zod 스키마로 빌드 타임 타입 검증 |
| JS 번들 최소화 | 기본적으로 JavaScript가 거의 없는 페이지 — 풀스택 프레임워크 대비 압도적으로 가벼움 |
| CMS 생태계 | Storyblok, Sanity, Strapi, Hygraph 등 공식·커뮤니티 로더 풍부 |
단점 및 주의사항
| 항목 | 내용 |
|---|---|
| Astro 컴포넌트 전용 | server:defer는 .astro 파일에만 사용 — React·Vue 컴포넌트에 직접 적용 불가 |
| Props 직렬화 제약 | 함수, 순환 참조 객체 전달 불가 |
| URL 길이 제한 | Props 크기가 클 경우 POST 폴백 발생, 브라우저 캐시 미적용 |
| JS 필수 | 클라이언트 JS 비활성화 환경에서는 폴백 콘텐츠만 표시 |
| 어댑터 필요 | 배포 시 Netlify·Vercel·Node.js 어댑터 설치 필수 |
| 과도한 동적 콘텐츠 | 페이지 대부분이 개인화라면 전체 SSR이 더 적합 |
| Content Layer 학습 곡선 | 기존 Content Collections 마이그레이션 시 loader 개념과 데이터 스토어 구조 파악 필요 |
마치며
Astro 5 Server Islands의 핵심은 "정적이냐 동적이냐"가 아니라 "어느 단위로 경계를 그을 것인가"입니다. 페이지 전체를 SSR로 전환하지 않고도, 개인화가 필요한 컴포넌트만 서버를 거치게 하면서 나머지 영역은 CDN 캐시의 이점을 그대로 누릴 수 있습니다. Content Layer API는 그 위에서 외부 CMS 데이터를 타입-세이프하게 빌드 파이프라인에 통합하는 접착제 역할을 합니다.
새 프로젝트나 신규 페이지를 시작한다면 이 순서를 추천합니다.
- "대부분 정적이지만 일부 개인화가 필요한 페이지"를 하나 골라 Astro로 만들어보세요.
server:defer한 줄의 체감이 예상보다 큽니다. - 헤드리스 CMS를 쓰고 있다면 Content Layer 공식 로더를 붙여 기존
fetch()코드를getCollection()으로 대체해보세요. Zod 스키마 덕분에 빌드 타임에 타입 오류가 잡히기 시작합니다. - Astro DevToolbar를 열어서 아일랜드 경계가 의도한 대로 나뉘어 있는지 확인하세요. 브라우저 안에서 시각적으로 아일랜드를 확인할 수 있어 디버깅이 훨씬 수월합니다.
참고 자료
- Astro 5.0 공식 릴리스 노트
- Server Islands 공식 문서
- Islands Architecture 개념 문서
- Content Layer 심층 분석 공식 블로그
- Content Collections 공식 문서
- Astro Content Loader API 레퍼런스
- Storyblok — Astro Content Layer 로더 발표
- Sanity + Astro 공식 가이드
- Strapi — Astro 커스텀 로더 구현 가이드
- Nick Taylor — Server Islands 동작 원리와 사용 시점
- BCMS — Astro Server Islands 실습
- Astro 5.x 베스트 프랙티스 가이드 (2024-2025)