LabHub

블로그

Astro 5 심층 해부: Server Islands · Content Layer · View Transitions로 다시 그리는 '콘텐츠 사이트의 표준'

한국어English日本語

"정적이라는 단어를 다시 정의하자. 캐시할 수 있는 모든 것은 캐시하고, 진짜로 사람마다 달라야 하는 부분만 서버에서 늦게 그린다." — Astro 5 Server Islands의 디자인 한 줄 요약

프롤로그 — '단순한 정적 사이트 생성기'의 시대는 끝났다

2022년의 Astro는 마크다운 블로그용 정적 사이트 생성기였습니다. 2024년 12월 3일에 공개된 Astro 5는, 같은 이름을 달고 있지만 다른 물건입니다. 콘텐츠 중심 사이트의 사실상 표준 이라고 불러도 과하지 않습니다.

이번 글에서 답하고 싶은 질문은 단순합니다.

이 글은 광고가 아닙니다. Astro가 잘하는 일과 못하는 일을 같은 무게로 적습니다. 결정은 마지막 표 한 장에서 하시면 됩니다.


1. 아일랜드 아키텍처 복습 — Astro가 처음부터 다르게 했던 한 가지

먼저 짧게 복습합니다. 다른 프레임워크가 SPA에서 SSR로 갔다가 다시 RSC로 우회하는 동안, Astro는 처음부터 다른 길을 갔습니다.

1.1 "모든 페이지는 HTML이다"

기본 출력물은 HTML입니다. JavaScript는 필요한 컴포넌트에만 명시적으로 붙입니다. 이 정책의 이름이 바로 아일랜드 아키텍처(Islands Architecture) 입니다.

페이지 전체가 하나의 React 트리가 아니라, 정적인 HTML 바다 위에 인터랙티브한 "섬"이 듬성듬성 떠 있는 그림입니다. 섬마다 자기 번들과 자기 하이드레이션 시점을 가집니다. 그 결과 평균적인 콘텐츠 페이지에서 클라이언트로 내려가는 JS가 압도적으로 적습니다.

1.2 클라이언트 디렉티브

섬을 만드는 방법은 컴포넌트 옆에 디렉티브 한 줄을 붙이는 것입니다.

---
import Counter from '../components/Counter.tsx'
import LikeButton from '../components/LikeButton.svelte'
import SearchBox from '../components/SearchBox.vue'
---

<h1>섬 없는 텍스트는 정적 HTML</h1>

<Counter client:load />
<LikeButton client:visible />
<SearchBox client:idle />

이 한 줄의 차이로 "이 페이지의 4킬로바이트 JS"와 "이 페이지의 300킬로바이트 JS"가 갈립니다.

1.3 프레임워크 어그노스틱

같은 페이지 안에 React, Svelte, Vue, Solid, Preact, Lit을 섞을 수 있습니다. 실무에서 이걸 적극적으로 활용하는 팀은 드물지만, 레거시 위젯을 점진적으로 흡수할 수 있다는 의미는 큽니다. 디자인 시스템은 Svelte, 인터랙티브 데모는 React 같은 분할이 자연스럽습니다.


2. Astro 5가 가져온 큰 그림 — 6가지 헤드라인 변화

Astro 5의 핵심을 한 화면에 담으면 다음과 같습니다.

영역Astro 4Astro 5
콘텐츠 모델Content Collections (파일 기반)Content Layer API (어떤 소스든)
동적 페이지전체 페이지 SSR 또는 정적Server Islands (정적 위에 서버 섬)
번들러Vite 5Vite 6, 이후 6.x 라인에서 Vite 7 합류
폼/뮤테이션직접 핸들러Astro Actions (타입 안전)
라우팅정적 + 부분 SSRprerender per-route 정교화
i18n실험적안정화 + fallback/도메인 라우팅

이걸 한 줄로 줄이면 — "정적 우선을 유지한 채로, 풀스택의 일부를 받아들였다" 입니다. Next.js와 반대 방향에서 같은 지점에 도달하는 중입니다. Next.js는 클라이언트 React에서 출발해 RSC로 정적/서버 경계를 다시 그렸고, Astro는 정적 HTML에서 출발해 Server Islands로 서버를 다시 받아들였습니다.


3. Server Islands — '정적 우선'을 깨지 않고 개인화를 끼워 넣는 법

Server Islands는 Astro 5의 얼굴입니다. 한 문장으로:

"페이지를 CDN에서 정적으로 캐시하고, 사람마다 달라야 하는 작은 조각만 서버에서 늦게 그려서 클라이언트가 뒤따라 끼워 넣는다."

3.1 문제 — "캐시할 수 있는 99%와 캐시할 수 없는 1%"

전형적인 콘텐츠 사이트의 페이지를 떠올려 보세요. 헤더, 본문, 사이드바, 푸터 — 거의 모든 픽셀이 모든 방문자에게 동일합니다. 다만 우측 상단의 "로그인됨/안 됨", "장바구니 (3)", "내 추천 글" 같은 작은 조각이 사람마다 다릅니다.

전통적 선택지:

  1. 전체 페이지 SSR — 1%를 위해 99%를 매번 다시 그린다. 캐시가 무용지물.
  2. 전체 정적 + 클라이언트 fetch — JS 로드 → fetch → 렌더링 → 레이아웃 시프트. 코어 웹 바이탈 손상.
  3. ESI / Edge Side Includes — 이론적으론 좋지만 CDN 의존, 디버깅 지옥.

Server Islands는 4번째 길입니다.

3.2 사용법 — server:defer 한 줄

---
// src/pages/index.astro
import Layout from '../layouts/Layout.astro'
import Avatar from '../components/Avatar.astro'
import AvatarFallback from '../components/AvatarFallback.astro'
---

<Layout>
  <h1>오늘의 추천 글</h1>
  <p>이 본문은 빌드 타임에 만들어져 CDN에 캐시됩니다.</p>

  <Avatar server:defer>
    <AvatarFallback slot="fallback" />
  </Avatar>
</Layout>
---
// src/components/Avatar.astro
import { getUserFromCookie } from '../lib/auth'
const user = await getUserFromCookie(Astro.request)
---

{user ? (
  <a href="/me"><img src={user.avatar} alt={user.name} /></a>
) : (
  <a href="/login">로그인</a>
)}

server:defer가 붙은 컴포넌트는:

  1. 빌드 타임에는 렌더되지 않습니다. 대신 slot="fallback"이 자리를 차지합니다.
  2. 페이지는 정적 HTML로 CDN에 캐시됩니다. 0.5kB 안팎의 스크립트와 placeholder만 들어 있습니다.
  3. 브라우저는 그 자리에 들어갈 HTML을 별도 엔드포인트(/_server-islands/Avatar)에서 가져옵니다.
  4. 응답이 도착하면 placeholder가 실제 HTML로 교체됩니다.

핵심은 — 본문은 캐시되고, 섬만 사람마다 다르다는 점입니다. CDN을 100% 활용하면서 개인화가 됩니다.

3.3 GA에서 추가된 디테일

베타 동안 다음이 더해지면서 5.0 GA에 올라왔습니다.

3.4 언제 쓰고, 언제 쓰지 말아야 하는가

쓸 때:

쓰지 말아야 할 때:


4. Content Layer API — '콘텐츠 컬렉션'을 '어떤 소스든' 으로 일반화

두 번째 큰 변화는 Content Layer입니다. Astro 4의 Content Collections는 파일 시스템의 마크다운/MDX 가 1급 시민이었습니다. Astro 5는 그것을 한 단계 추상화해서, 로더가 곧 컬렉션이 되도록 만들었습니다.

4.1 모델 — Loader + Schema

// src/content.config.ts
import { defineCollection, z } from 'astro:content'
import { glob } from 'astro/loaders'

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    pubDate: z.coerce.date(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false),
  }),
})

export const collections = { blog }

glob 외에 공식 file 로더가 있고, 그 너머는 자유입니다.

4.2 커스텀 로더 — HTTP, DB, CMS 모두

가장 흥미로운 부분입니다. 임의의 데이터 소스를 컬렉션으로 만들 수 있습니다.

// src/loaders/notion.ts
import type { Loader, LoaderContext } from 'astro/loaders'

export function notionLoader(opts: { databaseId: string }): Loader {
  return {
    name: 'notion-loader',
    async load({ store, parseData, meta, generateDigest }: LoaderContext) {
      const lastSynced = meta.get('last-synced')
      const pages = await fetchNotionPages(opts.databaseId, { since: lastSynced })

      for (const p of pages) {
        const data = await parseData({
          id: p.id,
          data: {
            title: p.properties.Title.title[0].plain_text,
            slug: p.properties.Slug.rich_text[0].plain_text,
            body: p.body,
            updatedAt: p.last_edited_time,
          },
        })
        const digest = generateDigest(data)
        store.set({ id: p.id, data, digest })
      }
      meta.set('last-synced', new Date().toISOString())
    },
  }
}
// src/content.config.ts
import { defineCollection, z } from 'astro:content'
import { notionLoader } from './loaders/notion'

const articles = defineCollection({
  loader: notionLoader({ databaseId: process.env.NOTION_DB_ID! }),
  schema: z.object({
    title: z.string(),
    slug: z.string(),
    body: z.string(),
    updatedAt: z.coerce.date(),
  }),
})

export const collections = { articles }

이제 페이지에서는 똑같이 씁니다.

---
import { getCollection } from 'astro:content'
const articles = await getCollection('articles')
---
<ul>
  {articles.map(a => <li><a href={`/articles/${a.data.slug}`}>{a.data.title}</a></li>)}
</ul>

데이터가 마크다운 파일이든, Notion이든, Sanity든, 자체 PostgreSQL이든 — 페이지 코드는 모릅니다. 이게 Content Layer의 본질입니다.

4.3 빌드 캐시와 인크리멘털

Content Layer는 SQLite 기반 캐시를 깔고 있어, 변경된 엔트리만 다시 처리합니다. 1만 개 콘텐츠 사이트의 두 번째 빌드가 첫 빌드보다 압도적으로 빠른 이유입니다. 로더는 meta.get/set으로 자기 마지막 동기화 시점을 기억할 수 있고, generateDigest로 콘텐츠 해시를 만들어 변경 여부를 판단합니다.

4.4 외부 로더 생태계

5.0 발표 이후 단기간에 공식·서드파티 로더가 우르르 등장했습니다 — Storyblok, Hygraph, WordPress, Ghost, Strapi, Sanity 등. 즉 "Astro = 헤드리스 CMS의 보편적 프런트엔드" 라는 포지셔닝이 실제로 작동하기 시작했습니다.

4.5 Live Loaders — 런타임 콘텐츠

GA 이후 추가된 흐름이 Live Content Loaders 입니다. 빌드 타임이 아니라 요청 시점에 데이터를 가져오는 로더로, "정적 80% + 라이브 20%" 시나리오(가격, 재고, 환율)에 적합합니다. 같은 getCollection API를 유지하면서 라이브로 갈 수 있다는 점이 인상적입니다.


5. Astro Actions — 폼과 뮤테이션을 타입 안전하게

세 번째 큰 변화는 Astro Actions입니다. 한마디로 "타입 안전한 서버 함수, Zod 입력 스키마와 함께".

5.1 정의

// src/actions/index.ts
import { defineAction } from 'astro:actions'
import { z } from 'astro:schema'

export const server = {
  subscribe: defineAction({
    accept: 'form',
    input: z.object({
      email: z.string().email(),
      utm: z.string().optional(),
    }),
    handler: async (input, ctx) => {
      const ip = ctx.request.headers.get('x-forwarded-for')
      await saveSubscriber({ email: input.email, utm: input.utm, ip })
      return { ok: true }
    },
  }),
}

5.2 호출 — 서버에서 그대로

---
import { actions } from 'astro:actions'
const result = Astro.getActionResult(actions.subscribe)
---
<form method="POST" action={actions.subscribe}>
  <input type="email" name="email" required />
  <input type="hidden" name="utm" value="blog-banner" />
  <button type="submit">구독</button>
  {result?.data?.ok && <p>고맙습니다!</p>}
  {result?.error && <p class="err">에러: 다시 시도해 주세요.</p>}
</form>

JS가 꺼져 있어도 폼은 작동합니다. JS가 켜져 있으면 점진적으로 강화된 UX가 됩니다. 클라이언트 JS에서도 actions.subscribe(input) 으로 호출 가능합니다.

5.3 무엇이 좋은가

Next.js의 Server Actions와 컨셉상 형제지만, Astro는 "정적 페이지 위에 폼 한 개"라는 경량 시나리오에 더 가볍게 맞습니다.


6. Vite 6 / Vite 7 통합 — 빌드의 속살

Astro 5는 Vite 6 위에서 출시됐고, 이후 5.x/6.x 라인에서 Vite 7 합류 작업이 진행됐습니다. 개발자 입장에서 느끼는 변화:

여기서 중요한 시사점은 — Astro 팀이 Vite 코어 개발자와 거의 한 팀처럼 움직인다는 점입니다. Vite의 변화가 Astro에 빠르게 흘러들어옵니다.


7. View Transitions와 prefetching — SPA처럼 매끄럽게, MPA처럼 가볍게

콘텐츠 사이트의 약점 중 하나는 페이지 이동마다 흰 화면이 깜빡인다는 점이었습니다. View Transitions API와 핵심 prefetching이 이걸 풀어냅니다.

7.1 View Transitions

---
import { ClientRouter } from 'astro:transitions'
---
<head>
  <ClientRouter />
</head>

이 한 줄을 레이아웃에 넣으면, 같은 사이트 안 내비게이션이 MPA 그대로 동작하면서 화면 전환에 브라우저 네이티브 View Transitions가 입혀집니다. 헤더는 유지되고 본문만 페이드되거나, 썸네일이 다음 페이지의 히어로 이미지로 자연스럽게 모핑되는 식입니다.

요소별로 이름을 주면 페이지 간 동일 요소가 트래킹됩니다.

<img src={post.cover} transition:name={`cover-${post.slug}`} />

7.2 prefetching — 코어로 흡수

prefetch는 Astro 3.5에서 코어로 들어왔고, 5에서는 기본 ON이 좀 더 합리적인 휴리스틱으로 다듬어졌습니다.

// astro.config.mjs
export default defineConfig({
  prefetch: {
    prefetchAll: true,
    defaultStrategy: 'viewport',
  },
})

전략은 tap, hover, viewport, load. 링크별로 data-astro-prefetch="hover" 같이 오버라이드 가능. 결과는 — 사용자가 클릭하기 전에 다음 페이지가 이미 와 있다.

7.3 i18n 개선

i18n은 4.x에서 안정화에 들어갔고 5.x에서 다듬어졌습니다.

콘텐츠 사이트는 거의 모두 다국어를 결국 만나기 때문에, 이 부분이 1급으로 들어왔다는 것이 큰 의미입니다.


8. Astro vs Next.js vs SvelteKit — 결정 매트릭스

이제 본론입니다. 어디서 무엇을 쓸지.

8.1 철학 한 줄

8.2 항목별 비교

항목Astro 5Next.js (RSC)SvelteKit
기본 JS 페이로드가장 적음 (0이 기본)중간~높음적음
풀스택 깊이얕음~중간깊음중간
콘텐츠 모델Content Layer (1급)App Router + MDX (애드온)직접 구성
폼/뮤테이션ActionsServer Actionsform actions
동적 개인화Server IslandsRSC + Suspensestreaming + load
다국어코어 i18n별도 라이브러리별도 라이브러리
호스팅Static + 어디든 어댑터Vercel 최적어댑터 다양
학습 곡선낮음중간~높음낮음~중간
컴포넌트 호환성React/Svelte/Vue/Solid 등React 전용Svelte 전용

8.3 시나리오별 추천

한 줄로 — 콘텐츠가 1순위면 Astro, 애플리케이션이 1순위면 Next.js/SvelteKit.


9. 실제 적용 사례 — 콘텐츠 헤비 사이트에서의 모양

가상의 미디어 회사를 가정합니다. 월 1억 PV의 뉴스 사이트. 페이지 구성을 Astro 5 기준으로 어떻게 짤지 살펴봅니다.

9.1 페이지 단위 결정

CDN 캐시 적중률이 95% 이상이 됩니다. 99/1 페이지에 99/1의 비용 구조가 따라옵니다.

9.2 데이터 흐름

9.3 빌드 시간

같은 규모의 Next.js 정적 빌드보다 빠를 가능성이 높습니다. 단, Next.js의 ISR(Incremental Static Regeneration) + 온디맨드 재검증 모델은 또 다른 매력이 있어, 절대 우위는 아닙니다.


10. Astro 4 → 5 마이그레이션 — 솔직한 이야기

가장 현실적인 질문 — 4에서 5로 옮기는 게 얼마나 아픈가.

10.1 좋은 소식

10.2 손이 가는 부분

  1. Content Collections → Content Layer

    • src/content/config.tsloader: glob({...}) 추가
    • getEntry/getCollection의 일부 시그니처 변경
    • 빌드 타임 캐시가 새로 생기므로 .astro/ 같은 캐시 디렉터리 CI에 마운트
  2. Astro.glob 제거

    • 위 Content Layer로 흡수됨. 마크다운 페이지 수집을 직접 하던 코드가 있다면 getCollection으로 옮김
  3. Image 처리 정책 변화

    • astro:assets의 옵션 일부 정리. 외부 이미지 도메인 화이트리스트는 더 엄격해짐
  4. Adapter 업데이트

    • Vercel, Netlify, Cloudflare, Node 어댑터 모두 5.x 버전 필요
  5. Vite 6 환경 호환

    • 커스텀 Vite 플러그인을 끼고 있다면 5/6 호환성 확인

10.3 마이그레이션 절차

  1. Astro 4 최신 패치로 올린 뒤 deprecation 경고를 0으로
  2. pnpm dlx @astrojs/upgrade로 일괄 메이저 점프
  3. astro check 통과시키기
  4. 한 페이지에 Server Island 시험 도입
  5. 한 컬렉션부터 Content Layer로 이주
  6. 점진 확장

큰 사이트라도 보통 며칠 단위로 끝납니다. Next.js의 Pages → App Router 마이그레이션보다 훨씬 가볍습니다.


11. 솔직한 한계 — Astro가 여전히 지지 않는 곳, 지는 곳

광고를 빼고 적습니다.

11.1 여전히 강한 곳

11.2 약한 곳

11.3 결정 한 줄

"당신의 사이트가 책이라면 Astro. 당신의 사이트가 도구라면 Next.js. 그 중간이면 SvelteKit도 같이 평가하라."


에필로그 — 체크리스트와 다음 글 예고

12.1 Astro 5를 도입할 때 체크리스트

12.2 안티패턴

12.3 다음 글 예고

이 시리즈의 한 줄 약속 — "콘텐츠 사이트의 표준은 다시 바뀌었고, 그 이름은 Astro 5다."


참고 / References

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다