Astro 5 가이드 — Content Layer·Server Islands·Sessions 새 기능과 실전
이 글의 핵심
Astro 5는 2024년 말 GA된 메이저 릴리스로 Content Layer·Server Islands·Sessions·새 Actions API를 핵심 축으로 가집니다. 정적 사이트에서 동적 개인화까지 하나의 프레임워크로 커버하면서 여전히 JS 번들은 0에 가까운 수준으로 유지합니다. 이 글은 Astro 5의 주요 새 기능과 실전 활용·마이그레이션 전략을 다룹니다.
옛날엔 랜딩이랑 문서를 Next App Router에 얹어 놨는데, 글 수만 늘어도 page 트리가 덩어리 JS처럼 느껴지고, “이 페이지 정적이잖아?”라는 죄책감이 드는 날이 왔다. 그때 콘텐츠 사이트면 Astro라는 말이 제일 잘 먹혔다. 말하자면 문서·블로그·마케팅·카탈로그까지는 Astro가 압도적으로 싸고 빠르고, 대시보드·실시간 협업·앱 느낌 풀스택이면 Next를 가져가는 식. 나는 그 뒤에 Astro 4 쓰다가 5로 올리면서 Content Layer랑 Server Islands 쪽이 “아 이제 진짜 정적+동적 딜레마를 끊는다”는 인상이었다.
4에서 5로 넘어가면 콘텐츠 쪽이 확 바뀐다. content/만 긁던 시절이 Content Layer로 가면서 “어떤 소스든 로더로 붙이고 같은 getCollection API” 쪽으로 통일됐고, “CDN에 캐시해놓고도 로그인 뱃지만 살아움직이고 싶다”는 욕이 Server Islands로 떨어졌다. 폼은 Actions API로 한 번에 묶이고, 세션은 (실험적이지만) 프레임워크 안에 들어온다. i18n은 라우팅·번역·미들웨어 쪽이 다듬어졌고, 이미지는 AVIF·Picture 쪽이 더 실전형으로 정리됐다. 이걸 표로 늘어 놓지는 않을게. 숫자보다 “내가 마이그레이션할 때 뭐가 감각이 달라졌는지”가 중요하니까.
Content Layer 쪽, 4때는 대충 이런 느낌이었지. defineCollection에 type: "content" 박고 마크다운만 먹는 구조. CMS나 REST는 빌드 스크립트 따로 짜는 날이 많았다.
// src/content/config.ts
import { defineCollection, z } from "astro:content"
const blog = defineCollection({
type: "content",
schema: z.object({ title: z.string(), date: z.date() }),
})
export const collections = { blog }
5는 content.config.ts에 glob/file 로더를 올리고, 비동기로 Notion·Stripe·뭐든 fetch해서 store에 넣는 커스텀 Loader도 같은 계층에 둔다. 아래는 한 번에 “옛날 blog + 외부 API + JSON 파일”을 같이 쓰는 뼈다.
// src/content.config.ts
import { defineCollection, z } from "astro:content"
import { glob, file } from "astro/loaders"
const blog = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/blog" }),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()).optional(),
}),
})
// 외부 JSON API
const notionPosts = defineCollection({
loader: async () => {
const res = await fetch("https://api.notion.com/v1/databases/...",
{ headers: { Authorization: `Bearer ${import.meta.env.NOTION_TOKEN}` } })
const data = await res.json()
return data.results.map((item: any) => ({
id: item.id,
title: item.properties.Name.title[0].text.content,
slug: item.properties.Slug.rich_text[0].text.content,
body: item.properties.Body.rich_text[0].text.content,
}))
},
schema: z.object({ title: z.string(), slug: z.string(), body: z.string() }),
})
// 로컬 JSON 파일
const products = defineCollection({
loader: file("./data/products.json"),
schema: z.object({ id: z.string(), name: z.string(), price: z.number() }),
})
export const collections = { blog, notionPosts, products }
쓰는 쪽 API는 익숙하다. getCollection / getEntry 그대로이고, Zod·빌드 캐시도 한 줄기로 따라온다.
---
import { getCollection, getEntry } from "astro:content"
const posts = await getCollection("blog", ({ data }) => !data.draft)
const featured = await getEntry("notionPosts", "featured-slug")
---
팀이 Stripe·샵 같은 걸 쓰면 이런 식으로 Loader를 찍는다. CI에서 매번 가져와서 store에 꽂고, 증분 빌드랑 맞출 수 있다.
import type { Loader } from "astro/loaders"
function stripeProducts(): Loader {
return {
name: "stripe-products",
load: async ({ store, logger }) => {
const res = await fetch("https://api.stripe.com/v1/products", {
headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET}` },
})
const { data } = await res.json()
store.clear()
for (const p of data) {
store.set({ id: p.id, data: p })
}
logger.info(`Loaded ${data.length} products`)
},
}
}
Server Islands는 “페이지 뼈는 정적으로 두고, 딱 그 조각만 서버가 요청마다” 그 그림. Partial Hydration이 브라우저에서 섬에 물 올리는 거라면, 이건 “HTML 껍데기는 캐시, 서버는 그 구멍만 뚫어서”에 가깝다. server:defer + fallback이 핵심.
---
// src/pages/index.astro
import Layout from "../layouts/Base.astro"
import UserBadge from "../components/UserBadge.astro"
import LatestPosts from "../components/LatestPosts.astro"
---
<Layout>
<h1>Welcome</h1>
<!-- 서버 아일랜드: 요청 시점에 서버가 렌더링 -->
<UserBadge server:defer>
<p slot="fallback">Loading user…</p>
</UserBadge>
<!-- 정적: 빌드 시 결정, CDN에 캐시됨 -->
<LatestPosts />
</Layout>
---
// src/components/UserBadge.astro
const session = Astro.cookies.get("session")
const user = session ? await getUser(session.value) : null
---
{user ? <span>Hi, {user.name}</span> : <a href="/login">Log in</a>}
전체는 Cache-Control: s-maxage=31536000, immutable 같은 느낌으로 묶고, UserBadge만 사용자별로 튀는 구조. 그래서 “CDN hit는 높이고, 로그인·장바구니·뱃지” 같은 건 안 내려놓는다. 이 패턴 쓰다 보면 Next에서 온 애들이 가장 먼저 “아 예전엔 이거 하려고 페이지를 통째로 dynamic으로 만들었는데”라고 말한다. 그게 Server Islands 쓸 때의 리얼한 마이그레이션 감정이다.
Actions는 그 서버 쪽 뇌를 폼·mutation 쪽에 붙이는 API. defineAction + Zod, 서버에만 있어도 클라이언트에서 타입 따라오고, JS 없이도 form 돌리는 progressive enhancement까지 한 번에.
// src/actions/index.ts
import { defineAction } from "astro:actions"
import { z } from "astro:schema"
import { db } from "../lib/db"
export const server = {
createComment: defineAction({
accept: "form",
input: z.object({
postId: z.string(),
body: z.string().min(1).max(500),
}),
handler: async ({ postId, body }, context) => {
const session = context.cookies.get("session")
if (!session) throw new Error("unauthorized")
const [row] = await db.insert(comments).values({ postId, body, userId: session.value }).returning()
return row
},
}),
}
---
// src/pages/posts/[id].astro
import { actions } from "astro:actions"
const result = Astro.getActionResult(actions.createComment)
---
<form method="POST" action={actions.createComment}>
<input type="hidden" name="postId" value={post.id} />
<textarea name="body" required></textarea>
<button>Submit</button>
</form>
{result?.data && <p>Saved!</p>}
{result?.error && <p class="error">{result.error.message}</p>}
자바스크립트로 때리고 싶으면 script에서 actions를 그냥 부르면 된다. Next Server Actions랑 쓰는 맛이 비슷한데, 전제가 “콘텐츠 페이지 + 가끔 액션”이라 DX가 가볍게 느껴진다.
<script>
import { actions } from "astro:actions"
document.getElementById("btn")?.addEventListener("click", async () => {
const { data, error } = await actions.createComment({ postId: "1", body: "Hello" })
if (error) console.error(error)
else console.log("created", data)
})
</script>
Sessions는 아직 실험적이지만, “그냥 쿠키+파일+Redis+KV”를 한 줄기로 묶는 느낌이라 취향이 맞는 팀이 많다. Node standalone 예시.
// astro.config.mjs
import { defineConfig } from "astro/config"
import node from "@astrojs/node"
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
experimental: {
session: {
driver: "fs", // fs | redis | cloudflare-kv | vercel-kv | upstash
options: { base: "./.sessions" },
},
},
})
---
const sess = Astro.session
const count = (await sess.get<number>("count")) ?? 0
await sess.set("count", count + 1)
---
<p>Visits: {count + 1}</p>
i18n은 설정만 박으면 ko 기본, en 프리픽스, fallback 같은 걸 한 파일에서 잡는다. Accept-Language 미들웨어는 팀 취향에 맞게 얹고.
// astro.config.mjs
export default defineConfig({
i18n: {
defaultLocale: "ko",
locales: ["ko", "en"],
routing: { prefixDefaultLocale: false },
fallback: { en: "ko" },
},
})
src/pages/
index.astro # ko (기본)
about.astro
en/
index.astro # en
about.astro
---
import { getRelativeLocaleUrl, getLocaleByPath } from "astro:i18n"
const locale = getLocaleByPath(Astro.url.pathname)
---
<a href={getRelativeLocaleUrl("en", "/about")}>English</a>
이미지는 AVIF, Picture에 widths/sizes, LCP 먹는 히어로는 loading="eager" 이런 식으로 밀어 넣는다. 5는 Sharp·포맷 조합 쪽이 덜 귀찮다.
---
import { Image, Picture } from "astro:assets"
import hero from "../assets/hero.jpg"
---
<Image src={hero} alt="hero" width={1200} height={630} format="avif" quality={80} loading="eager" />
<Picture
src={hero}
alt="hero"
widths={[400, 800, 1200]}
sizes="(min-width: 1024px) 1200px, 100vw"
formats={["avif", "webp", "jpg"]}
/>
빌드/편의 쪽 잡담: Vite 6, esbuild MDX, HTML 출력·CSS 번들 쪽이 조금씩 달라지고, Turso·Astro DB 실험까지 타면 “정적+데이터” 경계를 더 쓰게 된다. 전부 나열하진 않을게. 중요한 건 HMR·빌드 체감이 4때랑은 확실히 달랐다는 것.
“그래서 블로그 한 장” 뼈는 여전히 이런 느낌. getCollection → getStaticPaths → post.render(). 콘텐츠 사이트의 기본골.
---
// src/pages/blog/[...slug].astro
import { getCollection } from "astro:content"
import Layout from "../../layouts/Post.astro"
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft)
return posts.map((p) => ({ params: { slug: p.slug }, props: { post: p } }))
}
const { post } = Astro.props
const { Content } = await post.render()
---
<Layout title={post.data.title} description={post.data.description}>
<article>
<h1>{post.data.title}</h1>
<time>{post.data.pubDate.toLocaleDateString()}</time>
<Content />
</article>
</Layout>
이 블로그도 Cloudflare 쪽 쓰면 Workers로 server:defer 붙이고, KV·D1은 Astro.locals.runtime으로 때린다. 최소한의 설정은 이런 느낌.
// astro.config.mjs
import cloudflare from "@astrojs/cloudflare"
export default defineConfig({
output: "server",
adapter: cloudflare({
platformProxy: { enabled: true },
}),
server: { port: 4321 },
})
4에서 5로 옮길 때 나는 “새 minimal 템플릿이랑 구조를 한 번 겹쳐 보고, astro upgrade 맞고, content/config를 content.config로 옮기고, 애매한 dynamic은 Server Islands로 빼고, 폼은 Actions로 빼는” 순서로 갔다. experimental에 세션 켤지 말지는 팀이 정하면 되고, 캐시 꼬이면 store.clear()를 만능으로 부르지 말고 “변경 감지에만”으로 줄이는 편이 낫다. Server Island가 캐시에 안 먹으면 Cache-Control·쿠키·쿼리 키가 섞였는지부터 본다. Actions 타입 풀리면 astro:actions import랑 moduleResolution: "bundler"부터. i18n 루프는 prefixDefaultLocale이랑 미들웨어 URL 규칙이 안 맞는 경우가 제일 흔하고, 이미지는 무조건 avif+widths로 때려도 된다. 이거 전부 “절”로 나누기엔 애매해서 그냥 툭툭 던져 둔다.
끝말: Astro 5는 “정적은 빠른데 동적이 귀찮다”는 말을 Content Layer랑 Server Islands로 쪼개 먹는 릴리스다. 콘텐츠 사이트면 Astro라는 내 취향은 2026년이 돼서도 그대로고, Next는 여전히 “앱” 쪽이면 사과하고 간다. pkglog.com도 이 스택 기준이고, Astro 총정리, Content Collections, Next 15이랑 같이 읽으면 감이 붙는다. 막히면 공식 docs.astro.build이 답이고, 키워드는 태그 줄 그대로 검색해도 이 길 따라온다.