이 글은 누구를 위한 것인가
- 블로그, 문서 사이트를 최고 성능으로 만들고 싶은 팀
- Next.js의 무거운 JS 번들을 줄이고 싶은 개발자
- 여러 UI 프레임워크를 혼용하는 마이크로 프론트엔드를 고려하는 팀
들어가며
Astro는 기본적으로 JavaScript를 전혀 보내지 않는다. 상호작용이 필요한 "섬(Island)"에만 선택적으로 JS를 수화한다. 결과: 평균 90% JS 감소, LCP < 1초.
이 글은 bluefoxdev.kr의 Astro 완전 가이드 를 참고하여 작성했습니다.
1. Islands 아키텍처 이해
[전통적 SPA vs Astro Islands]
전통 SPA (React/Next.js):
모든 페이지 → 전체 JS 번들 다운로드
수화: 전체 DOM
JS 크기: 수백 KB ~ MB
TTI: 3-8초
Astro Islands:
정적 HTML 기본
상호작용 컴포넌트만 수화
나머지: 순수 HTML (JS 없음)
TTI: < 1초
[수화 지시어]
client:load → 즉시 수화 (중요한 상호작용)
client:idle → requestIdleCallback 후 수화 (낮은 우선순위)
client:visible → Intersection Observer로 뷰포트 진입 시 수화
client:media → 미디어 쿼리 충족 시 수화
client:only → 서버 렌더링 없이 클라이언트에서만
[지원 UI 프레임워크]
React, Preact, Vue, Svelte, Solid, Lit
같은 페이지에서 혼용 가능
2. Astro 프로젝트 구현
---
// src/pages/index.astro
import Layout from '../layouts/Layout.astro';
import HeroStatic from '../components/HeroStatic.astro';
import SearchWidget from '../components/SearchWidget.tsx'; // React
import CommentSection from '../components/Comments.svelte'; // Svelte
import VideoPlayer from '../components/VideoPlayer.vue'; // Vue
const posts = await Astro.glob('../content/posts/*.md');
---
<Layout title="홈">
<!-- 정적 HTML, JS 없음 -->
<HeroStatic title="환영합니다" />
<!-- 즉시 수화: 검색은 바로 사용해야 함 -->
<SearchWidget client:load posts={posts} />
<!-- 지연 수화: 뷰포트 진입 시에만 -->
<CommentSection client:visible postId="home" />
<!-- 미디어 쿼리 충족 시만: 데스크톱에서만 -->
<VideoPlayer client:media="(min-width: 768px)" src="/intro.mp4" />
</Layout>
// src/components/SearchWidget.tsx (React Island)
import { useState, useCallback } from 'react';
interface Post {
frontmatter: { title: string; description: string; slug: string };
url: string;
}
export default function SearchWidget({ posts }: { posts: Post[] }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Post[]>([]);
const search = useCallback((q: string) => {
setQuery(q);
if (!q.trim()) {
setResults([]);
return;
}
const filtered = posts.filter(post =>
post.frontmatter.title.toLowerCase().includes(q.toLowerCase()) ||
post.frontmatter.description.toLowerCase().includes(q.toLowerCase())
);
setResults(filtered);
}, [posts]);
return (
<div className="search-widget">
<input
type="search"
value={query}
onChange={(e) => search(e.target.value)}
placeholder="검색어를 입력하세요"
className="w-full px-4 py-2 border rounded-lg"
/>
{results.length > 0 && (
<ul className="mt-2 border rounded-lg divide-y">
{results.map(post => (
<li key={post.url}>
<a href={post.url} className="block p-3 hover:bg-gray-50">
<div className="font-medium">{post.frontmatter.title}</div>
<div className="text-sm text-gray-600">{post.frontmatter.description}</div>
</a>
</li>
))}
</ul>
)}
</div>
);
}
// src/content/config.ts - Content Collections (타입 안전 콘텐츠)
import { defineCollection, z } from 'astro:content';
const postsCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
date: z.date(),
description: z.string(),
tags: z.array(z.string()),
draft: z.boolean().default(false),
cover: z.string().optional(),
}),
});
export const collections = {
posts: postsCollection,
};
// src/pages/posts/[slug].astro
---
import { getCollection, getEntry } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
export async function getStaticPaths() {
const posts = await getCollection('posts', ({ data }) => !data.draft);
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content, headings } = await post.render();
---
<Layout title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<time>{post.data.date.toLocaleDateString('ko-KR')}</time>
<Content />
</article>
</Layout>
// astro.config.mjs - 통합 설정
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import svelte from '@astrojs/svelte';
import vue from '@astrojs/vue';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://example.com',
integrations: [
react(),
svelte(),
vue(),
tailwind(),
sitemap(),
],
// View Transitions API
// 페이지 간 부드러운 전환 (MPA에서 SPA 같은 경험)
experimental: {
viewTransitions: true,
},
// 빌드 최적화
build: {
inlineStylesheets: 'auto',
},
vite: {
build: {
cssMinify: 'lightningcss',
},
},
});
---
// View Transitions 사용
import { ViewTransitions } from 'astro:transitions';
---
<head>
<ViewTransitions />
</head>
<!-- 특정 요소에 전환 이름 지정 -->
<img
src={post.cover}
alt={post.title}
transition:name={`cover-${post.slug}`}
/>
<!-- 목록에서 상세 페이지로 이동 시 이미지가 부드럽게 이동 -->
마무리
Astro Islands의 핵심 철학은 "기본 정적, 선택적 상호작용"이다. client:visible만 잘 활용해도 뷰포트 밖 컴포넌트의 JS를 지연시켜 초기 로딩을 크게 개선할 수 있다. Content Collections는 마크다운 콘텐츠에 타입 안전성을 제공해 빌드 시 누락된 필드를 잡아준다. 블로그, 문서 사이트, 마케팅 페이지처럼 콘텐츠 중심 사이트라면 Astro가 Next.js보다 훨씬 나은 선택이다.