Astro 4에서 달라진 것: Dev Toolbar, Content Collections, View Transitions
이 글의 핵심
Astro는 기본적으로 HTML만 내보내고 필요한 컴포넌트만 수화하지만, client 지시어를 잘못 고르면 이 장점이 금방 사라집니다. 지시어별 의도와 설계 패턴, Zod의 refine·transform을 이용한 콘텐츠 검증, 이미지 파이프라인과 캐시, View Transitions와 함께 쓸 때의 함정까지 프로덕션 SSG 사이트를 운영하는 관점에서 다룹니다.
이 글의 핵심
Astro는 콘텐츠 중심 웹사이트를 위한 프레임워크로, 아일랜드 아키텍처로 클라이언트 JavaScript를 최소화합니다. 이 글은 Astro 4에서 달라진 점을 정리하고, 아일랜드의 수화 경로와 client:* 지시어 선택, Vite 설정, Content Collections와 Zod 스키마, View Transitions, 다크 모드, 배포까지 실무 관점에서 이어 설명합니다.
Astro 4 변경 요약
Astro 4.0(2023년 12월)은 정적 사이트 생성(SSG)을 기본값으로 유지하면서 개발 경험을 개선하는 데 초점을 맞췄고, 이후 4.x 마이너 버전에서 여러 실험 기능이 추가되었습니다.
| 영역 | 내용 |
|---|---|
| 개발 도구 | 개발 서버 화면 하단의 Dev Toolbar로 아일랜드 검사, 접근성·성능 감사 |
| 국제화 | 3.5에서 실험 기능이던 i18n 라우팅이 4.0에서 정식 기능이 됨 |
| 콘텐츠 | 기존 src/content/config.ts 기반 컬렉션 유지, 4.14에서 로더 기반 Content Layer를 실험 기능으로 추가 |
| 서버 기능 | Actions(4.8), astro:env(4.10), Server Islands(4.12)가 실험 기능으로 추가 |
View Transitions(<ViewTransitions />)는 Astro 3.0에서 도입되었고 Astro 4에서도 같은 API로 쓰입니다. Astro 5에서는 Content Layer와 Server Islands가 정식 기능이 되었고, <ViewTransitions /> 컴포넌트 이름이 <ClientRouter />로 바뀌었으며, output: 'hybrid'가 없어졌습니다. 4에서 5로 올릴 때는 이 세 가지를 우선 확인하면 됩니다.
프로젝트 생성
npm create astro@latest
대화형 프롬프트에서 템플릿(Blog 등)과 TypeScript 설정을 고르면 페이지와 콘텐츠 디렉터리가 함께 만들어집니다. 지금 npm create astro를 실행하면 최신 메이저 버전이 설치되므로, Astro 4를 유지해야 한다면 생성 후 package.json에서 "astro": "^4.16.0"처럼 메이저 4로 고정하고, 공식 통합 패키지(@astrojs/*)도 Astro 4와 호환되는 버전으로 맞춰야 합니다.
{
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"check": "astro check"
}
}
astro build는 프로덕션 결과물을 만들고, astro preview는 그 결과물을 로컬에서 확인할 때 씁니다. astro check는 .astro 파일까지 포함한 타입 검사를 실행하므로 CI에 넣어 두면 좋습니다.
Dev Toolbar
Dev Toolbar는 개발 서버로 페이지를 열면 화면 하단에 나타나는 도구 모음입니다. 프로덕션 빌드에는 포함되지 않습니다. 기본으로 들어 있는 앱은 세 가지입니다. Inspect는 페이지의 아일랜드를 강조 표시하고, 각 아일랜드의 props와 client:* 지시어를 보여 줍니다. Audit은 이미지에 alt가 없는 경우 같은 접근성 문제와 일부 성능 문제를 찾아 줍니다. 메뉴의 Astro 앱은 문서와 통합 링크를 모아 둔 곳입니다. 통합 패키지가 자체 툴바 앱을 추가할 수도 있습니다.
실무에서는 Inspect로 페이지에 의도하지 않은 아일랜드가 있는지, 화면 아래쪽 컴포넌트가 client:load로 즉시 수화되고 있지는 않은지를 확인하는 용도로 가장 많이 씁니다. 툴바가 방해된다면 astro.config.mjs의 devToolbar: { enabled: false }나 astro preferences disable devToolbar로 끌 수 있습니다.
아일랜드 아키텍처의 동작
아일랜드 아키텍처는 페이지 전체를 React·Vue 앱으로 만드는 대신, 서버에서 정적 HTML을 먼저 완성하고 상호작용이 필요한 영역만 클라이언트 번들로 연결하는 구조입니다.
빌드 타임: 서버 렌더링과 아일랜드 경계
.astro 컴포넌트는 서버에서만 실행됩니다. 템플릿은 빌드 시점(SSR이라면 요청 시점)에 HTML로 확정되고, React·Vue·Svelte 같은 프레임워크 컴포넌트도 client:* 지시어가 없으면 서버에서 HTML로만 렌더링되어 JavaScript가 전혀 전송되지 않습니다. 지시어가 붙은 컴포넌트만 아일랜드가 되며, Astro는 해당 위치를 <astro-island> 커스텀 엘리먼트로 감싸고 컴포넌트 URL, 렌더러 URL, 직렬화된 props를 속성으로 기록합니다.
그래서 페이지는 하나의 큰 SPA 루트가 아니라 여러 개의 독립된 하이드레이션 루트로 나뉩니다. 초기 JavaScript 비용은 페이지 전체가 아니라 아일랜드들의 합에 비례합니다. 다만 같은 프레임워크를 쓰는 아일랜드끼리는 런타임 청크를 공유하므로, React 아일랜드가 여러 개라도 React 런타임은 한 번만 내려받습니다.
런타임: 지시어에 따른 수화 시점
<astro-island>는 지시어에 따라 수화 시점을 정합니다. client:load는 페이지 로드 직후, client:idle은 requestIdleCallback으로 브라우저가 한가할 때, client:visible은 IntersectionObserver로 요소가 화면에 들어올 때 컴포넌트 코드를 불러와 수화합니다. 이 차이는 메인 스레드 점유 시간과 상호작용 가능 시점에 직접 영향을 줍니다.
MPA와의 관계
Astro 사이트는 기본적으로 멀티 페이지 애플리케이션(MPA)입니다. 링크를 누르면 브라우저가 새 HTML 문서를 받아 렌더링하고, 아일랜드는 페이지마다 새로 수화됩니다. View Transitions를 켜면 클라이언트 라우터가 새 HTML을 가져와 현재 문서와 교체하므로 체감은 SPA에 가까워지지만, 페이지마다 완전한 HTML이 존재한다는 MPA의 성질은 유지됩니다.
부분 수화 전략
| 지시어 | 수화 시점 | 적합한 경우 |
|---|---|---|
client:load | 페이지 로드 직후 | 헤더 검색, 첫 화면에서 바로 써야 하는 컨트롤 |
client:idle | 브라우저가 한가할 때 | 곧 필요하지만 첫 렌더에는 필수가 아닌 위젯 |
client:visible | 화면에 들어올 때 | 차트, 댓글, 글 하단 위젯 |
client:media="(max-width: 768px)" | 미디어 쿼리가 맞을 때 | 모바일에서만 쓰는 메뉴 |
client:only="react" | 서버 렌더링 없이 클라이언트에서만 | window에 강하게 의존하는 컴포넌트 |
client:only는 서버에서 렌더링하지 않으므로 어떤 프레임워크의 컴포넌트인지 알려 줘야 하고(client:only="react"), 수화 전까지 그 자리는 비어 있습니다. 레이아웃이 밀리지 않게 자리 크기를 CSS로 잡아 두는 것이 좋습니다.
기본값은 서버에서 HTML을 만들고 필요할 때만 수화하는 것입니다. 습관적으로 client:load를 붙이면 Astro를 쓰는 이점이 크게 줄어듭니다. 긴 글 페이지라면 같은 기능도 작은 컴포넌트로 쪼개 client:visible로 미루는 편이 유리한 경우가 많습니다.
여러 아일랜드가 상태를 공유해야 할 때는 각 아일랜드가 독립된 루트라서 React Context 같은 트리 기반 상태 공유가 경계를 넘지 못한다는 점을 기억해야 합니다. Astro 문서는 이런 경우 Nano Stores 같은 프레임워크 독립적인 작은 스토어를 권장합니다. URL이나 쿠키로 풀 수 있는 상태라면 그쪽이 더 단순합니다.
여러 프레임워크를 함께 쓸 수 있다는 점도 신중하게 다뤄야 합니다. 한 페이지에 React 아일랜드와 Vue 아일랜드가 섞이면 두 런타임이 모두 내려갑니다. 정적 표시만 하는 컴포넌트는 .astro로 옮겨 JavaScript를 없애고, 아일랜드 경계는 폼·위젯·차트처럼 실제 상호작용 단위로 잡는 것이 좋습니다.
빌드 설정과 Vite
출력 모드와 어댑터
Astro 4의 출력 모드는 세 가지입니다. output: 'static'(기본값)은 모든 페이지를 빌드 시점에 HTML로 만듭니다. output: 'server'는 기본적으로 모든 페이지를 요청 시점에 렌더링하고, output: 'hybrid'는 기본은 정적으로 두고 일부 페이지만 서버에서 렌더링합니다. 서버 렌더링이 필요하면 @astrojs/node, @astrojs/vercel, @astrojs/netlify, @astrojs/cloudflare 같은 어댑터를 연결합니다.
Vite 레벨 튜닝
Astro의 개발 서버와 빌드는 Vite 위에서 동작하므로 astro.config.mjs의 vite 키로 Vite 설정을 그대로 넘길 수 있습니다. 다만 Astro가 아일랜드 단위 청크와 어댑터별 출력을 따로 조율하므로, Vite 옵션은 증상이 있을 때만 측정 후 최소한으로 바꾸는 편이 안전합니다.
개발 서버에서 특정 CommonJS 패키지 때문에 첫 로딩이 느리거나 오류가 난다면 optimizeDeps.include에 그 패키지만 넣어 사전 번들을 강제합니다.
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
vite: {
optimizeDeps: {
include: ['some-heavy-cjs-lib'],
},
},
});
build.rollupOptions.output.manualChunks로 벤더 청크를 따로 나눌 수도 있지만, Astro는 이미 페이지와 아일랜드 단위로 청크를 나눕니다. 수동 청크는 원래 필요 없는 페이지에도 큰 청크를 내려보내게 만들기 쉬우므로, rollup-plugin-visualizer 같은 번들 분석기로 문제를 확인한 뒤에만 적용합니다.
SSR 빌드에서 어떤 패키지를 서버 번들에 포함할지는 vite.ssr.noExternal과 vite.ssr.external로 조정합니다. Cloudflare처럼 node_modules를 런타임에 불러올 수 없는 환경에서는 의존성이 번들에 포함되어야 하고, Node 전용 API를 쓰는 패키지는 아예 쓸 수 없거나 호환 플래그가 필요합니다. 어댑터 문서의 호환성 안내를 먼저 확인하세요.
export default defineConfig({
vite: {
ssr: {
noExternal: ['some-isomorphic-package'],
},
},
});
환경 변수
Vite 규약에 따라 import.meta.env.PROD, import.meta.env.SSR 같은 값을 쓸 수 있습니다. PUBLIC_ 접두어가 붙은 변수만 클라이언트 코드에 노출되고 빌드 결과물에 그대로 들어가므로, 비밀 값에는 이 접두어를 쓰지 않고 서버에서 실행되는 코드에서만 읽습니다.
이미지 최적화
astro:assets의 Image 컴포넌트는 import한 로컬 이미지를 빌드 시점에 WebP 등으로 변환하고 크기를 조정하며, width와 height를 자동으로 넣어 레이아웃 이동(CLS)을 막습니다.
---
import { Image } from 'astro:assets';
import hero from '../assets/hero.jpg';
---
<Image src={hero} alt="히어로 이미지" width={1200} height={630} loading="eager" />
최적화 대상은 src/ 아래에서 import한 이미지나 컬렉션 스키마의 image() 헬퍼로 참조한 이미지입니다. public/에 둔 이미지는 문자열 경로로 쓸 수 있지만 변환되지 않습니다. 원격 이미지를 변환하려면 image.domains나 image.remotePatterns에 도메인을 허용해야 합니다.
빌드 캐시
Astro는 변환한 이미지 등을 cacheDir(기본값 node_modules/.astro)에 저장하고 다음 빌드에서 재사용합니다. 이미지가 많은 사이트에서는 빌드 시간의 대부분이 이미지 변환이므로, CI에서 이 디렉터리를 캐시해 두면 반복 빌드 시간이 크게 줄어듭니다. 반대로 로컬에서만 재현되는 이상한 빌드 문제는 캐시 불일치일 수 있으므로, 캐시를 지우고 다시 빌드해 보는 것이 첫 진단 단계입니다.
Content Collections
Astro 4의 기본 방식: src/content/config.ts
Astro 4에서 컬렉션은 src/content/ 아래 디렉터리이고, src/content/config.ts에서 각 컬렉션의 종류와 Zod 스키마를 정의합니다. Markdown·MDX는 type: 'content', JSON·YAML은 type: 'data'입니다.
// src/content/config.ts (Astro 4)
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
level: z.enum(['초급', '중급', '고급']).optional(),
}),
});
export const collections = { blog };
페이지에서 조회와 렌더링
---
// src/pages/blog/[...slug].astro (Astro 4)
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
type: 'content' 컬렉션의 항목은 파일 경로에서 만든 slug를 가지며, 본문은 post.render()로 렌더링합니다. 목록 페이지의 링크도 같은 slug 규칙으로 만들어야 경로가 어긋나지 않습니다.
4.14의 실험적 Content Layer와 Astro 5
Astro 4.14는 experimental.contentLayer 플래그로 로더 기반 컬렉션을 도입했습니다. 컬렉션을 디렉터리가 아니라 로더로 정의하므로, 로컬 파일뿐 아니라 CMS나 API에서 가져온 데이터도 같은 방식으로 다룰 수 있고, 대규모 사이트에서 빌드 성능과 메모리 사용이 개선됩니다. Astro 5에서 이 방식이 정식 기능이 되면서 설정 파일은 src/content.config.ts로 옮겨 갔습니다.
// src/content.config.ts (Astro 5, 또는 4.14+ 실험 플래그 사용 시)
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
}),
});
export const collections = { blog };
로더 기반 컬렉션에서는 slug 대신 id를 쓰고, 본문은 astro:content에서 가져온 render(post) 함수로 렌더링합니다. Astro 4 프로젝트를 5로 올릴 때 가장 많이 고치는 부분이 이 slug→id, post.render()→render(post) 변경입니다.
Zod 스키마로 운영 규칙 강제하기
컬렉션 스키마는 타입 정의를 넘어 콘텐츠 운영 규칙을 빌드 시점에 강제하는 장치입니다. 필드가 많아질수록 .refine(), .transform(), .superRefine()으로 규칙을 명시해 두면 다른 사람이 frontmatter를 고칠 때도 실수가 빌드에서 걸러집니다.
필드 간 규칙: refine
updatedDate가 pubDate보다 앞서면 안 된다는 식의 필드 간 제약은 refine으로 표현합니다.
const blog = defineCollection({
type: 'content',
schema: z
.object({
title: z.string().min(1),
description: z.string().max(160),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
draft: z.boolean().default(false),
})
.refine(
(data) => !data.updatedDate || data.updatedDate >= data.pubDate,
{ message: 'updatedDate는 pubDate보다 이전일 수 없습니다.', path: ['updatedDate'] }
),
});
path를 지정하면 오류 메시지에 어느 필드가 문제인지 함께 표시되어 원인을 찾기 쉽습니다.
정규화: transform
태그의 공백을 제거하고 중복을 없애는 정규화는 transform에서 처리하면 페이지 코드가 단순해집니다.
const tags = z
.array(z.string())
.default([])
.transform((arr) => Array.from(new Set(arr.map((t) => t.trim()).filter(Boolean))));
level처럼 허용 값이 정해진 필드는 z.enum([...])으로 두고, 허용 값 목록을 한곳에서 관리하면 잘못된 라벨이 들어오는 것을 막을 수 있습니다. 문자열을 날짜로 바꾸는 식의 두 단계 변환은 z.coerce나 z.preprocess로 충분한 경우가 많으므로, 팀원이 읽기 어려운 복잡한 스키마는 피하는 것이 좋습니다.
프로덕션 SSG 운영
블로그 목록은 getCollection 결과를 정렬·필터한 뒤 HTML로 만듭니다. 글이 많아지면 paginate()로 목록을 페이지 단위로 나누고, 태그·연도별 아카이브는 getStaticPaths에서 경로를 미리 전개합니다.
캐시 정책은 HTML과 에셋을 나눠 생각합니다. 빌드된 JS·CSS·이미지는 파일 이름에 해시가 붙으므로 긴 TTL과 immutable을 줘도 안전하고, HTML은 배포 직후 바로 반영되도록 짧은 TTL을 둡니다.
CI에서는 Node 버전을 engines나 CI 설정으로 고정하고 npm ci로 lockfile 그대로 설치합니다. 페이지 수가 수천 개 규모로 커지면 빌드 중 메모리가 부족할 수 있어 NODE_OPTIONS=--max-old-space-size=... 조정이 필요할 수 있습니다. 배포 전 astro check와 astro build를 함께 돌리면 스키마 변경이 기존 글 전체와 호환되는지 한 번에 검증됩니다.
MDX
MDX를 쓰려면 npx astro add mdx로 @astrojs/mdx 통합을 추가합니다. MDX에서는 글 안에서 컴포넌트를 import해 설명과 함께 배치할 수 있고, 아일랜드 지시어도 그대로 쓸 수 있습니다.
---
title: '샘플 MDX'
description: 'MDX에서 컴포넌트 사용'
pubDate: '2024-05-29'
---
import Chart from '../../components/Chart.tsx';
## 실시간 예제
아래 차트는 화면에 들어올 때만 수화됩니다.
<Chart client:visible />
인터랙티브 컴포넌트는 작게 나누고 client:visible이나 client:idle로 미루는 편이 안전합니다. 글마다 다른 차트 라이브러리를 쓰면 번들이 계속 늘어나므로, 공통 차트·코드 에디터 래퍼를 한두 개로 정해 재사용하는 것이 좋습니다.
View Transitions
전역 활성화
---
// src/layouts/Base.astro (Astro 4)
import { ViewTransitions } from 'astro:transitions';
---
<html lang="ko">
<head>
<meta charset="utf-8" />
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html>
레이아웃의 <head>에 <ViewTransitions />를 넣으면 같은 사이트 안의 링크 이동을 클라이언트 라우터가 가로채, 새 페이지 HTML을 받아 현재 문서와 교체하고 전환 애니메이션을 적용합니다. 브라우저가 View Transitions API를 지원하지 않으면 기본적으로 대체 애니메이션을 씁니다.
요소별 애니메이션과 유지
<!-- 이 요소의 전환 애니메이션 지정 -->
<main transition:animate="slide">
<slot />
</main>
<!-- 페이지가 바뀌어도 이 요소와 그 상태를 유지 -->
<aside transition:persist="sidebar">
<nav>...</nav>
</aside>
transition:animate는 링크가 아니라 전환될 요소에 붙이는 지시어입니다. transition:persist를 붙인 요소는 새 페이지에서 교체되지 않고 그대로 옮겨지므로, 재생 중인 오디오 플레이어나 아일랜드의 내부 상태가 유지됩니다.
스크립트 재실행 규칙
View Transitions를 켜면 페이지 이동이 문서 전체를 다시 로드하지 않으므로, 번들된 <script>(모듈 스크립트)는 처음 한 번만 실행되고 이후 페이지에서는 다시 실행되지 않습니다. DOMContentLoaded에 의존한 초기화 코드는 두 번째 페이지부터 동작하지 않습니다. 페이지마다 실행해야 하는 코드는 astro:page-load 이벤트에 등록합니다.
<script>
document.addEventListener('astro:page-load', () => {
// 페이지마다 다시 찾아야 하는 요소에 이벤트 연결
});
</script>
스크롤 위치는 클라이언트 라우터가 브라우저 기록에 맞춰 복원합니다. 앞으로 이동하면 맨 위로, 뒤로 가기는 이전 위치로 돌아갑니다.
다크 모드
다크 모드는 <html>에 dark 클래스를 토글하는 방식이 가장 흔합니다. 페이지가 처음 그려지기 전에 테마를 적용해야 깜빡임이 없으므로 <head>에 인라인 스크립트를 둡니다. View Transitions를 함께 쓰면 두 가지를 더 처리해야 합니다. 페이지가 교체될 때 새 문서의 <html> 속성이 적용되면서 dark 클래스가 사라지므로 astro:after-swap에서 다시 적용해야 하고, 버튼 요소도 새 페이지의 것으로 바뀌므로 이벤트 리스너를 astro:page-load에서 다시 연결해야 합니다.
---
const title = 'Astro 4 가이드';
---
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
<script is:inline>
function applyTheme() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const dark = stored === 'dark' || (!stored && prefersDark);
document.documentElement.classList.toggle('dark', dark);
}
applyTheme();
// View Transitions로 문서가 교체된 직후 다시 적용
document.addEventListener('astro:after-swap', applyTheme);
</script>
</head>
<body class="bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<button type="button" id="theme-toggle" class="rounded border px-3 py-1 text-sm">
테마 전환
</button>
<slot />
<script>
document.addEventListener('astro:page-load', () => {
document.getElementById('theme-toggle')?.addEventListener('click', () => {
const dark = document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', dark ? 'dark' : 'light');
});
});
</script>
</body>
</html>
Tailwind CSS를 쓴다면 darkMode: 'class' 설정과 위 방식이 잘 맞습니다. 색상을 CSS 변수로 정의해 두면 클래스 하나만 바꿔도 사이트 전체 색이 일관되게 바뀝니다.
하이브리드 렌더링
Astro 4에서 일부 경로만 서버에서 렌더링하려면 output: 'hybrid'와 어댑터를 설정하고, 서버 렌더링이 필요한 페이지에만 export const prerender = false를 붙입니다. 반대로 output: 'server'에서는 모든 페이지가 기본적으로 서버 렌더링이고, 정적으로 만들 페이지에 prerender = true를 붙입니다. Astro 5에서는 hybrid가 없어지고 output: 'static'에서도 prerender = false로 개별 페이지를 서버 렌더링할 수 있게 바뀌었습니다.
페이지를 정적으로 만들 수 있는지는 빌드 시점에 모든 입력이 정해지는지로 판단합니다. getStaticPaths로 경로가 모두 나열되고 쿠키나 쿼리 문자열에 의존하지 않는다면 정적으로 충분합니다. src/pages/api/posts.json.ts처럼 GET을 내보내는 엔드포인트도 정적 모드에서는 빌드 때 한 번 실행되어 JSON 파일로 저장되므로 검색 인덱스나 피드 생성에 유용합니다. 요청 본문을 받는 POST 엔드포인트는 서버 렌더링 라우트여야 하므로 어댑터가 필요합니다.
서버 렌더링을 쓰면 CDN 캐시 전략이 정적 사이트보다 복잡해지므로, 기본은 정적으로 두고 꼭 필요한 경로만 서버로 넘기는 것이 좋습니다.
배포
정적 모드에서는 dist/가 결과물입니다. Vercel, Netlify, Cloudflare Pages 모두 빌드 명령 npm run build, 출력 디렉터리 dist로 설정하면 됩니다. 서버 렌더링이 필요할 때만 플랫폼별 어댑터를 추가합니다.
// Vercel (Astro 4용 @astrojs/vercel 7.x)
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';
export default defineConfig({
output: 'hybrid',
adapter: vercel(),
});
// Netlify
import netlify from '@astrojs/netlify';
export default defineConfig({ output: 'hybrid', adapter: netlify() });
// Cloudflare Pages
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({ output: 'hybrid', adapter: cloudflare() });
Vercel 어댑터의 import 경로는 Astro 5용 버전에서 @astrojs/vercel로 바뀌었으므로, 설치한 어댑터 버전의 문서를 기준으로 하세요. Cloudflare는 Workers 런타임에서 실행되므로 Node 내장 모듈을 쓰는 의존성이 있으면 nodejs_compat 호환 플래그가 필요하거나 아예 동작하지 않을 수 있습니다.
SEO 구조
title과 description은 페이지마다 고유하게 작성하고, canonical URL, Open Graph 이미지, JSON-LD는 BaseHead.astro 같은 공통 컴포넌트에 모아 두는 것이 관리하기 쉽습니다. 마크업은 article, header, nav, main을 의미에 맞게 쓰고 h1은 페이지당 하나로 둡니다.
---
const { title, description, pubDate, canonicalURL } = Astro.props;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: title,
description,
datePublished: pubDate instanceof Date ? pubDate.toISOString() : pubDate,
mainEntityOfPage: canonicalURL,
};
---
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
검색엔진은 canonical URL로 중복 URL을 통합하므로, 도메인이나 URL 구조를 바꿀 때는 301 리디렉션과 canonical을 함께 계획해야 합니다.
트러블슈팅
| 증상 | 점검 |
|---|---|
| 빌드 시 콘텐츠 스키마 오류 | 오류 메시지의 파일과 필드를 확인하고, frontmatter 키와 Zod 스키마가 일치하는지, refine 조건을 위반하지 않았는지 봅니다. |
| View Transitions 후 버튼이 동작하지 않음 | 초기화 코드가 astro:page-load가 아니라 한 번만 실행되는 위치에 있는지 확인합니다. |
| 페이지 이동 후 다크 모드가 풀림 | astro:after-swap에서 테마 클래스를 다시 적용하는지 확인합니다. |
| 이미지가 최적화되지 않음 | public/의 이미지를 문자열 경로로 쓰고 있는지, 원격 이미지라면 허용 도메인 설정이 있는지 확인합니다. |
| Cloudflare SSR 빌드나 실행 실패 | Node 전용 패키지가 섞였는지, nodejs_compat 플래그와 어댑터 호환성 안내를 확인합니다. |