Astro Islands 아키텍처: 부분 수화로 최고 성능 달성하기

프론트엔드

AstroIslands Architecture부분 수화성능 최적화Static Site

이 글은 누구를 위한 것인가

  • 블로그, 문서 사이트를 최고 성능으로 만들고 싶은 팀
  • 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보다 훨씬 나은 선택이다.