Astro 5 가이드 — Content Layer·Server Islands·Sessions 새 기능과 실전
이 글의 핵심
정적 사이트에 로그인 사용자 정보 같은 동적 요소가 조금만 들어가도 페이지 전체를 SSR로 돌려야 하는 딜레마가 있었습니다. Astro 5의 새 기능이 이 문제를 어떻게 푸는지 보여 주고, i18n·이미지 최적화 변경점과 Astro 4에서 올릴 때 확인할 빌드·마이그레이션 체크리스트를 정리합니다.
옛날엔 랜딩이랑 문서를 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: 어떤 데이터 소스든 타입 안전하게
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 }
위 인라인 로더(notionPosts)는 배열을 돌려주는 가장 단순한 형태라, 반환하는 각 항목에 id 필드가 반드시 있어야 합니다. 없으면 빌드가 Loader must return an array of objects with an id 계열 에러로 멈춥니다. 여기서 id는 Notion 페이지 ID이므로 아래의 getEntry("notionPosts", "featured-slug")처럼 슬러그로 조회하면 undefined가 돌아옵니다. 슬러그로 찾고 싶다면 id: item.properties.Slug...처럼 슬러그를 id로 쓰거나 getCollection으로 받아 find해야 합니다. 또 Notion API의 rich_text[0]처럼 배열 첫 원소를 바로 꺼내는 코드는 속성이 비어 있는 페이지 하나 때문에 Cannot read properties of undefined로 빌드 전체가 실패하므로, 실제 로더에서는 옵셔널 체이닝과 기본값을 넣는 것이 안전합니다. 인라인 로더는 빌드할 때마다 전부 다시 가져오는 반면, 아래의 객체형 Loader는 store와 meta를 이용해 변경분만 반영하는 증분 로딩을 구현할 수 있다는 차이가 있습니다.
쓰는 쪽 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")
---
커스텀 Loader 작성하기
팀이 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`)
},
}
}
store.set({ id, data })로 넣은 data는 defineCollection의 schema가 있다면 그 스키마로 검증된 뒤 저장되는데, 로더 안에서 검증하려면 load 함수 인자로 받는 parseData({ id, data })를 거쳐야 합니다. Stripe 상품 객체처럼 필드가 많은 응답을 통째로 넣으면 콘텐츠 저장소(.astro/data-store.json)가 불필요하게 커지므로, 페이지에서 실제로 쓰는 필드만 골라 넣는 편이 빌드가 가볍습니다. 비밀 키는 process.env로 읽고 있어 빌드 환경(CI)에만 있으면 되지만, 이 값이 콘텐츠 데이터에 섞여 클라이언트로 나가지 않는지는 한 번 확인해야 합니다. 로더에서 데이터 소스가 바뀌었는데 개발 서버에 옛 데이터가 계속 보인다면 .astro 캐시가 원인인 경우가 많아, astro sync --force나 캐시 디렉터리 삭제로 해결됩니다.
Server Islands: 캐시와 개인화를 동시에
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>}
동작 방식을 알면 캐시 설계가 쉬워집니다. server:defer가 붙은 컴포넌트는 빌드(또는 페이지 렌더링) 시점에 fallback 슬롯만 HTML에 들어가고, 페이지에 작은 스크립트가 삽입되어 브라우저가 로드 후 /_server-islands/UserBadge?... 같은 별도 GET 요청으로 그 조각의 HTML을 받아 fallback 자리에 끼워 넣습니다. 컴포넌트에 넘긴 props는 이 요청 URL에 암호화되어 실리므로 함수나 순환 참조 객체 같은 직렬화 불가능한 값은 넘길 수 없고, props가 크면 URL이 길어져 POST로 바뀌면서 캐시가 되지 않습니다. 쿠키는 이 요청에 함께 실려 가므로 위 예제처럼 섬 안에서 Astro.cookies를 읽을 수 있지만, 섬은 페이지 본문과 다른 요청이라 섬 안에서 페이지의 응답 헤더나 상태 코드를 바꿀 수는 없습니다. 또 섬 요청 자체가 서버에서 처리되어야 하므로 output: "static"인 사이트라도 서버 어댑터(Node, Cloudflare, Vercel 등)가 있어야 동작합니다.
전체는 Cache-Control: s-maxage=31536000, immutable 같은 방식으로 묶고, UserBadge만 사용자별로 튀는 구조다. 따라서 “CDN hit는 높이고, 로그인·장바구니·뱃지” 같은 건 안 내려놓는다. 이 패턴 쓰다 보면 Next에서 온 애들이 가장 먼저 “아 예전엔 이것 하려고 페이지를 통째로 dynamic으로 만들었는데”라고 말한다. 그게 Server Islands 쓸 때의 리얼한 마이그레이션 감정입니다.
다만 페이지 HTML에 immutable과 1년 s-maxage를 거는 것은 배포할 때마다 CDN 캐시를 비울 수 있는 체계가 있을 때만 안전합니다. 그렇지 않으면 수정한 글이 CDN에 옛 버전으로 계속 남습니다. 저도 정적 HTML에 긴 캐시를 걸었다가 배포 후 “고친 게 반영이 안 된다”는 상황을 겪은 뒤로는, HTML은 짧은 s-maxage에 stale-while-revalidate를 조합하고 해시가 붙은 JS·CSS·이미지 같은 에셋에만 immutable을 거는 쪽을 택합니다. 섬 응답은 사용자별로 달라지므로 CDN이 공유 캐시하지 않도록 private 또는 no-store로 두어야 하고, 이를 잘못 설정하면 다른 사람의 로그인 뱃지가 캐시되어 보이는 심각한 사고가 됩니다.
Actions API: 폼과 mutation을 서버로
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>}
이 예제에서 짚어 둘 것이 세 가지 있습니다. 첫째, <form method="POST" action={actions.createComment}>처럼 JS 없이 동작하는 폼 제출은 그 페이지가 요청 시점에 렌더링될 때만 결과를 받을 수 있습니다. 미리 렌더링된 정적 페이지라면 export const prerender = false를 두거나 output: "server"여야 Astro.getActionResult()가 값을 돌려줍니다. 둘째, 핸들러에서 일반 Error를 던지면 클라이언트에는 내부 서버 에러로 뭉뚱그려 전달되므로, 인증 실패처럼 의도한 에러는 astro:actions의 ActionError를 throw new ActionError({ code: "UNAUTHORIZED", message: "로그인이 필요합니다" })처럼 써야 상태 코드와 메시지가 제대로 전달됩니다. 셋째, 쿠키의 session.value를 그대로 userId로 쓰는 것은 예제용 단순화이고, 실제로는 세션 ID로 서버 쪽 세션을 조회해 사용자 ID를 얻어야 합니다. 쿠키 값은 사용자가 마음대로 바꿀 수 있기 때문입니다. 코드의 post와 comments는 다른 곳에서 정의되었다고 가정한 부분입니다.
자바스크립트로 때리고 싶으면 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: 실험적 세션 관리
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>
Sessions는 Astro 5.1에서 실험 기능으로 들어왔고, 이후 5.x 버전에서 안정화되면서 experimental.session 대신 최상위 session 설정으로 옮겨졌습니다. 자신이 쓰는 버전의 문서를 확인해 설정 위치를 맞춰야 하며, 버전을 올린 뒤 experimental.session을 그대로 두면 설정 검증 경고나 에러가 납니다. fs 드라이버는 서버 한 대에서만 의미가 있어, 인스턴스가 여러 대이거나 서버리스 환경이라면 Redis나 KV 같은 공유 저장소 드라이버를 써야 요청마다 세션이 사라지지 않습니다. 세션 값은 서버에 저장되고 브라우저에는 세션 ID 쿠키만 가므로 쿠키 용량 제한(약 4KB)을 신경 쓰지 않아도 되지만, 위 예제의 방문 수처럼 읽고-쓰기가 겹치는 값은 동시 요청에서 경쟁 조건이 생길 수 있습니다.
i18n과 이미지 최적화
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>
getLocaleByPath()는 URL 전체가 아니라 로케일에 대응하는 경로 조각(예: "en")을 받아 로케일 코드를 돌려주는 함수라서, Astro.url.pathname을 그대로 넘기면 기대한 값이 나오지 않을 수 있습니다. 현재 페이지의 로케일이 필요하면 i18n 라우팅이 계산해 둔 Astro.currentLocale을 쓰는 것이 간단합니다. prefixDefaultLocale: false에서는 한국어가 /about, 영어가 /en/about이 되는데, 미들웨어에서 Accept-Language로 리다이렉트까지 걸면 “영어 브라우저 사용자가 한국어 페이지를 열 수 없다”거나 검색 엔진 크롤러가 리다이렉트 루프에 빠지는 문제가 생기기 쉬워, 자동 리다이렉트보다는 언어 전환 링크와 hreflang 태그를 두는 편이 안전합니다.
이미지는 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 → render(post). 콘텐츠 사이트의 기본골.
---
// src/pages/blog/[...slug].astro
import { getCollection, render } from "astro:content"
import Layout from "../../layouts/Post.astro"
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft)
// Content Layer(glob 로더)에서는 slug 대신 id를 사용
return posts.map((p) => ({ params: { slug: p.id }, props: { post: p } }))
}
const { post } = Astro.props
const { Content } = await render(post) // Astro 5: post.render() 대신 render(post)
---
<Layout title={post.data.title} description={post.data.description}>
<article>
<h1>{post.data.title}</h1>
<time>{post.data.pubDate.toLocaleDateString()}</time>
<Content />
</article>
</Layout>
Astro 4에서 옮길 때 가장 많이 깨지는 부분이 이 페이지입니다. glob 로더로 불러온 항목에는 slug 속성이 없고 id만 있으며, 렌더링은 항목의 메서드(post.render())가 아니라 astro:content에서 가져온 render(post) 함수로 바뀌었습니다. 예전 코드를 그대로 두면 params.slug가 undefined가 되어 getStaticPaths() 에러가 나거나 post.render is not a function이 납니다(레거시 호환 플래그를 켜면 한동안 동작하기도 합니다). id는 기본적으로 파일 경로에서 확장자를 뺀 값이라 foo/index.md 같은 구조에서는 URL이 달라질 수 있으므로, 이관 후에는 생성된 URL 목록을 빌드 전과 비교해 보는 것이 안전합니다. 참고로 위 schema에는 draft와 description이 없어 필터와 post.data.description이 항상 undefined가 되므로, 쓰는 필드는 스키마에 z.boolean().default(false)처럼 정의해 두어야 타입 검사도 제대로 됩니다.
이 블로그도 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 조합이 기본값으로 무난하다. 다만 AVIF 인코딩은 WebP보다 훨씬 느려서 이미지가 수천 장인 사이트에서는 빌드 시간이 크게 늘고, CI 캐시(node_modules/.astro)를 보존하지 않으면 매 빌드마다 다시 인코딩한다. 이것은 전부 “절”로 나누기엔 애매해서 그냥 툭툭 던져 둔다.
마무리: 정적과 동적의 균형
Astro 5는 “정적은 빠른데 동적이 귀찮다”는 말을 Content Layer랑 Server Islands로 쪼개 먹는 릴리스다. 콘텐츠 사이트면 Astro라는 내 취향은 2026년이 돼서도 그대로이며, Next는 여전히 “앱” 쪽이면 사과하고 간다. pkglog.com도 이 스택 기준이며, Astro 4에서 달라진 것: Dev Toolbar, Content Collections v2, View Transitions, Astro Content Collections 심화 가이드 — 스키마·타입 안정성·i18n·동적 라우팅, Next 15이랑 같이 읽으면 감이 붙는다. 막히면 공식 docs.astro.build이 답이며, 특히 버전별 업그레이드 가이드(v5 migration)를 먼저 읽는 것이 시행착오를 가장 많이 줄여 준다.
같이 보면 좋은 글
- Astro 4에서 달라진 것: Dev Toolbar, Content Collections v2, View Transitions
- Astro Content Collections 심화 가이드 — 스키마·타입 안정성·i18n·동적 라우팅
- Next.js 15 정리