Astro 웹사이트 퍼블리싱
Astro의 제로 런타임 SSG 접근 방식, 부분 수화, 그리고 광범위한 Markdown 지원을 활용하여 빠르고 콘텐츠 중심적인 정적 웹사이트를 구축하세요.
목차
- 빠른 시작
- 사용하지 말아야 할 경우
- 프로젝트 구조
- SSG vs SSR vs 하이브리드
- 콘텐츠 컬렉션 — 콘텐츠 계층 API, 사용자 정의 로더
- 문법 강조 — Shiki, 트랜스포머, Expressive Code
- 다이어그램 통합 — Mermaid, PlantUML, 다크 모드 테마
- 클라이언트 측 검색 — Pagefind (컨트롤, 가중치), Fuse.js
- 버전별 문서 — 다중 버전
- 국제화 (i18n) — 라우팅, 폴백
- 일반 패턴 — 페이지네이션, 태그, RSS, 폼
- 성능 모범 사례 — 프리페칭, 중요 CSS
- 배포 — Netlify
- 배포 전 체크리스트
- 테스트 및 품질 — Vitest, Playwright, 링크 검사
- 문제 해결
빠른 시작
# 새 프로젝트 생성 (Markdown 사이트에는 Blog 템플릿 사용)
npm create astro@latest
# 개발
npm run dev # 로컬 서버 http://localhost:4321
npm run build # dist/ 디렉토리에 정적 파일 생성
npm run preview # 프로덕션 빌드 미리보기
사용하지 말아야 할 경우
이 스킬은 정적 사이트 생성(SSG) 에 초점을 맞춥니다. 다음의 경우 다른 접근 방식을 고려하세요:
- 실시간 데이터 애플리케이션 - 데이터베이스 연결과 함께 SSR 모드 사용
- 사용자 인증 흐름 - 서버 측 세션 처리가 필요함
- 동적 재고가 있는 전자상거래 - 하이브리드 모드 또는 전체 SSR 사용
- 단일 페이지 애플리케이션 (SPA) - React/Vue 프레임워크를 직접 사용하는 것을 고려
하이브리드 SSG+SSR 패턴은 Astro의 어댑터 문서를 참조하세요.
프로젝트 구조
src/
components/ # Astro, React, Vue, Svelte 컴포넌트
content/ # 콘텐츠 컬렉션 (Markdown/MDX)
config.ts # 컬렉션 스키마
docs/ # 예제 컬렉션
layouts/ # 슬롯이 있는 페이지 래퍼
pages/ # 파일 기반 라우팅
public/ # 정적 애셋 (이미지, 글꼴, 파비콘)
astro.config.mjs # 프레임워크 설정
SSG vs SSR vs 하이브리드
| 모드 | 페이지 렌더링 시점 | 사용 사례 |
|---|---|---|
| SSG (기본값) | 빌드 시간 | 블로그, 문서, 마케팅 사이트 |
| SSR | 각 요청 시 | 동적 데이터, 개인화 |
| 하이브리드 | 혼합 | 정적 페이지 + 동적 엔드포인트 |
순수 정적 사이트의 경우 기본 output: 'static'을 사용하세요. 어댑터가 필요하지 않습니다.
콘텐츠 컬렉션
레거시 패턴 (Astro 4.x)
src/content/config.ts에서 스키마를 정의하세요:
import { defineCollection, z } from "astro:content";
export const collections = {
docs: defineCollection({
schema: z.object({
title: z.string(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
order: z.number().optional(),
draft: z.boolean().default(false)
})
})
};
콘텐츠 계층 API (Astro 5.0+)
glob() 로더를 사용하는 새로운 패턴 - 대규모 사이트의 경우 최대 75% 더 빠른 빌드:
// src/content.config.ts (참고: 다른 파일명)
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
schema: ({ image }) => z.object({
title: z.string(),
pubDate: z.coerce.date(),
draft: z.boolean().default(false),
cover: image(), // 이미지 존재 여부 확인
author: reference('authors'), // 컬렉션 간 참조
})
});
export const collections = { blog };
고급 스키마 패턴
schema: ({ image }) => z.object({
cover: image(), // src/ 내 이미지 확인
category: z.enum(['tech', 'news']),
author: reference('authors'), // 컬렉션 간 참조
relatedPosts: z.array(reference('blog')).optional(),
})
사용자 정의 로더 (원격 콘텐츠)
외부 API(GitHub 릴리스, CMS 등)에서 콘텐츠를 가져옵니다:
// src/loaders/github-releases.ts
import type { Loader } from 'astro/loaders';
export function githubReleasesLoader(repo: string): Loader {
return {
name: 'github-releases',
load: async ({ store, logger }) => {
logger.info(`${repo}의 릴리스 가져오는 중`);
const response = await fetch(`https://api.github.com/repos/${repo}/releases`);
const releases = await response.json();
for (const release of releases) {
store.set({
id: release.tag_name,
data: {
version: release.tag_name,
published_at: release.published_at,
body: release.body // Markdown 릴리스 노트
}
});
}
}
};
}
content.config.ts에 등록:
import { githubReleasesLoader } from './loaders/github-releases';
const releases = defineCollection({
loader: githubReleasesLoader('owner/repo'),
schema: z.object({
version: z.string(),
published_at: z.string(),
body: z.string(),
})
});
컬렉션을 쿼리하고 렌더링:
---
import { getCollection } from "astro:content";
export async function getStaticPaths() {
const docs = await getCollection("docs");
return docs.map(doc => ({
params: { slug: doc.slug },
props: { doc }
}));
}
const { doc } = Astro.props;
const { Content } = await doc.render();
---
<article>
<h1>{doc.data.title}</h1>
<Content />
</article>
문법 강조
기본 Shiki 설정
import { defineConfig } from "astro/config";
export default defineConfig({
markdown: {
shikiConfig: {
theme: "github-dark",
wrap: true
}
}
});
밝은/어두운 이중 테마
shikiConfig: {
themes: {
light: 'github-light',
dark: 'github-dark',
},
}
테마 전환을 위한 CSS 추가:
@media (prefers-color-scheme: dark) {
.astro-code, .astro-code span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
}
줄 강조 및 트랜스포머
```typescript {2,4}
const a = 1;
const b = 2; // 강조됨
const c = 3;
console.log(a + b + c); // 강조됨
```
Shiki 트랜스포머 (Astro 4.14+):
import { transformerNotationFocus, transformerNotationDiff } from '@shikijs/transformers';
shikiConfig: {
transformers: [transformerNotationFocus(), transformerNotationDiff()],
}
코드에서 표기법 주석 사용:
// [!code focus]- 이 줄에 집중// [!code ++]- 추가로 표시 (녹색)// [!code --]- 삭제로 표시 (빨간색)
Expressive Code (문서에 권장)
복사 버튼, 파일명, 차이 강조가 포함된 다양한 코드 블록:
npm install astro-expressive-code
import expressiveCode from 'astro-expressive-code';
export default defineConfig({
integrations: [expressiveCode()],
});
기능: 복사 버튼, 파일 탭, 줄 마커, 터미널 프레임, 텍스트 마커.
다이어그램 통합
Mermaid (권장)
Astro 통합 설치:
npm install astro-mermaid mermaid
// astro.config.mjs
import { defineConfig } from 'astro/config';
import mermaid from 'astro-mermaid';
export default defineConfig({
integrations: [mermaid({ theme: 'default' })]
});
Markdown에서 사용:
```mermaid
graph TD;
A-->B;
B-->C;
```
기능: 클라이언트 측 렌더링, 자동 테마 전환, 오프라인 가능, Playwright 불필요.
대안 (빌드 타임 정적 SVG): 사전 렌더링된 다이어그램을 위해 Playwright와 함께 rehype-mermaid 사용 (npx playwright install --with-deps 필요).
다크 모드 테마 전략:
- CSS 변수 - 브라우저가 런타임에 색상을 결정하도록 합니다:
// mermaid 설정
mermaid.initialize({
theme: 'base',
themeVariables: {
primaryColor: 'var(--diagram-primary)',
lineColor: 'var(--diagram-line)'
}
});
- Picture 요소 - 두 테마를 모두 생성하고 미디어 쿼리로 전환:
<picture>
<source srcset="/diagrams/flow-dark.svg" media="(prefers-color-scheme: dark)">
<img src="/diagrams/flow-light.svg" alt="플로우 다이어그램">
</picture>
- 인라인 SVG - CSS로 SVG 클래스 대상 지정 (위험: 스타일 충돌) - role='image' 또는 role='presentation'을 사용하여 접근성 기준 추가:
.dark .mermaid-svg .node rect {
fill: var(--bg-dark);
}
PlantUML
npx astro add plantuml
Markdown에서 사용:
```plantuml
@startuml
Alice -> Bob: 안녕하세요
Bob --> Alice: 안녕!
@enduml
```
클라이언트 측 검색
Pagefind (대규모 사이트에 권장)
빌드 시간에 인덱싱하는 제로 설정 정적 검색:
npm install pagefind
package.json에 추가:
{
"scripts": {
"build": "astro build && npx pagefind --site dist",
"postbuild": "pagefind --site dist"
}
}
컴포넌트에서 사용:
<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
<script src="/pagefind/pagefind-ui.js" type="text/javascript"></script>
<div id="search"></div>
<script>
window.addEventListener('DOMContentLoaded', () => {
new PagefindUI({ element: '#search', showSubResults: true });
});
</script>
기능: 외부 서비스 불필요, 오프라인 작동, 자동 인덱싱, 작은 번들 (~10KB).
세분화된 인덱싱 제어:
<!-- 헤더/사이드바가 아닌 기본 콘텐츠만 인덱싱 -->
<main data-pagefind-body>
<h1 data-pagefind-meta="title">페이지 제목</h1>
<p data-pagefind-weight="10">중요한 소개 텍스트</p>
<!-- 검색 스니펫에서 제외 -->
<nav data-pagefind-ignore>
<a href="/related">관련 게시물</a>
</nav>
</main>
| 속성 | 목적 |
|---|---|
data-pagefind-body | 이 요소로만 인덱싱 제한 |
data-pagefind-ignore | 인덱스에서 요소 제외 |
data-pagefind-meta="key" | 메타데이터 필드 정의 |
data-pagefind-weight="10" | 관련성 부스트 (기본값: 1) |
Pagefind vs Fuse.js
| 기능 | Pagefind | Fuse.js |
|---|---|---|
| 아키텍처 | 사전 빌드된 바이너리 청크 | 런타임 인메모리 |
| 대역폭 | 낮음 (필요한 청크만 로드) | 높음 (전체 인덱스 다운로드) |
| 확장성 | 10,000페이지 이상 | 500페이지 미만 |
| 다국어 | 네이티브 형태소 분석 | 수동 설정 |
| 사용 사례 | 전체 사이트 검색 | 작은 목록 필터링 |
Fuse.js (경량 대안)
사용자 정의 UI가 필요한 소규모 사이트용:
---
import { getCollection } from "astro:content";
const posts = await getCollection('blog');
const searchIndex = JSON.stringify(posts.map(post => ({
title: post.data.title,
slug: post.slug,
body: post.body.slice(0, 500)
})));
---
<input type="search" id="search" placeholder="검색..." />
<ul id="results"></ul>
<script define:vars={{ searchIndex }}>
import Fuse from 'fuse.js';
const fuse = new Fuse(JSON.parse(searchIndex), {
keys: ['title', 'body'],
threshold: 0.3
});
document.getElementById('search').addEventListener('input', (e) => {
const results = fuse.search(e.target.value);
const resultsEl = document.getElementById('results');
// 이전 결과를 안전하게 지움
resultsEl.replaceChildren();
// 안전한 DOM 메서드를 사용하여 결과 구축
results.forEach(r => {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = `/blog/${r.item.slug}`;
link.textContent = r.item.title;
li.appendChild(link);
resultsEl.appendChild(li);
});
});
</script>
기업 요구 사항의 경우 Algolia(호스팅 검색 API)를 고려하세요.
버전별 문서
다중 버전 문서 패턴
폴더 기반 구조 사용:
src/content/docs/
v1/
getting-started.md
api-reference.md
v2/
getting-started.md
api-reference.md
new-feature.md
국제화 (i18n)
astro.config.mjs에서 설정:
export default defineConfig({
i18n: {
defaultLocale: "en",
locales: ["en", "fr", "es"],
routing: {
prefixDefaultLocale: false
}
}
});
로케일별로 콘텐츠 구성:
src/content/docs/
en/
getting-started.md
fr/
getting-started.md
컴포넌트에서 로케일 감지:
---
const locale = Astro.currentLocale || 'en';
---
번역 누락 시 폴백
번역이 없을 때 배너와 함께 영어 콘텐츠 표시:
---
// src/pages/[lang]/[...slug].astro
import { getCollection, getEntry } from "astro:content";
const languages = ['en', 'es', 'fr'];
const defaultLang = 'en';
export async function getStaticPaths() {
const englishDocs = await getCollection('docs', ({ id }) => id.startsWith('en/'));
const paths = [];
for (const doc of englishDocs) {
const slug = doc.id.replace(/^en\//, '');
for (const lang of languages) {
const localizedId = `${lang}/${slug}`;
const localizedDoc = await getEntry('docs', localizedId);
paths.push({
params: { lang, slug },
props: {
entry: localizedDoc || doc, // 영어로 폴백
isFallback: !localizedDoc
}
});
}
}
return paths;
}
const { entry, isFallback } = Astro.props;
const { Content } = await entry.render();
---
{isFallback && (
<div class="translation-notice">
이 페이지는 아직 해당 언어로 제공되지 않습니다.
</div>
)}
<Content />
일반 패턴
페이지네이션 목록
50개 이상의 게시물이 있는 사이트의 경우 페이지로 분할:
---
// src/pages/blog/page/[page].astro
import { getCollection } from "astro:content";
const POSTS_PER_PAGE = 10;
export async function getStaticPaths() {
const allPosts = await getCollection("blog");
const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
return Array.from({ length: totalPages }, (_, i) => {
const page = i + 1;
const start = i * POSTS_PER_PAGE;
const end = start + POSTS_PER_PAGE;
return {
params: { page: String(page) },
props: {
posts: allPosts.slice(start, end),
currentPage: page,
totalPages
}
};
});
}
const { posts, currentPage, totalPages } = Astro.props;
---
<ul>
{posts.map(post => (
<li>
<a href={`/blog/${post.slug}`}>{post.data.title}</a>
</li>
))}
</ul>
<nav>
{currentPage > 1 && <a href={`/blog/page/${currentPage - 1}`}>이전</a>}
<span>{currentPage} / {totalPages}</span>
{currentPage < totalPages && <a href={`/blog/page/${currentPage + 1}`}>다음</a>}
</nav>
태그 필터링
태그 페이지 생성:
---
// src/pages/tags/[tag].astro
import { getCollection } from "astro:content";
export async function getStaticPaths() {
const posts = await getCollection("blog");
const tags = [...new Set(posts.flatMap(post => post.data.tags || []))];
return tags.map(tag => ({
params: { tag },
props: {
posts: posts.filter(post => post.data.tags?.includes(tag)),
tag
}
}));
}
const { posts, tag } = Astro.props;
---
<h1>{tag} 태그의 게시물</h1>
<ul>
{posts.map(post => (
<li><a href={`/blog/${post.slug}`}>{post.data.title}</a></li>
))}
</ul>
RSS 피드
astro.config.mjs에서 통합 사용:
import rss from '@astrojs/rss';
src/pages/rss.xml.js 엔드포인트:
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const posts = await getCollection('blog');
return rss({
title: '내 블로그',
description: '블로그 설명',
site: context.site,
items: posts.map(post => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: `/blog/${post.slug}/`,
})),
customData: `<language>ko</language>`,
});
}
폼 처리
정적 사이트의 경우 정적 폼 서비스를 통합하거나 서버리스 함수로 전송:
<form action="https://formspree.io/f/your_form_id" method="POST">
<label for="email">이메일</label>
<input type="email" name="email" required>
<label for="message">메시지</label>
<textarea name="message" required></textarea>
<button type="submit">전송</button>
</form>
성능 모범 사례
프리페칭
Astro의 내장 프리페칭 활성화:
export default defineConfig({
prefetch: true
});
개별 링크에 대한 사용자 정의:
<a href="/about" data-astro-prefetch="hover">소개</a>
<a href="/docs" data-astro-prefetch="tap">문서</a>
중요 CSS
인라인 중요 CSS로 초기 로드 성능 향상:
export default defineConfig({
build: {
inlineStylesheets: 'always'
}
});
이미지 최적화
Astro의 <Image /> 및 <Picture /> 컴포넌트 사용:
---
import { Image, Picture } from 'astro:assets';
import heroImage from '../images/hero.png';
---
<Image src={heroImage} alt="히어로 이미지" width={800} height={400} />
<Picture src={heroImage} formats={['avif', 'webp']} alt="최적화된 이미지" />
배포
Netlify
npm install @astrojs/netlify
// astro.config.mjs
import netlify from '@astrojs/netlify';
export default defineConfig({
output: 'static',
adapter: netlify()
});
netlify.toml:
[build]
command = "npm run build"
publish = "dist"
[[redirects]]
from = "/blog/*"
to = "/blog/:splat"
status = 200
Vercel
npm install @astrojs/vercel
import vercel from '@astrojs/vercel/serverless';
export default defineConfig({
output: 'server',
adapter: vercel()
});
GitHub Pages
GitHub Actions로 빌드 및 배포:
name: Deploy to GitHub Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run build
- uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist
astro.config.mjs에서 site 설정:
export default defineConfig({
site: 'https://username.github.io',
base: '/repository-name',
});
GCP / Firebase
Firebase Hosting 정적 배포:
npm install -g firebase-tools
firebase init hosting
firebase.json:
{
"hosting": {
"public": "dist",
"ignore": ["firebase.json", "**/.*"],
"rewrites": [{
"source": "**",
"destination": "/index.html"
}]
}
}
npm run build
firebase deploy --only hosting
배포 전 체크리스트
- 프로덕션 URL로
site및base설정 - 사용하지 않는 CSS 제거 (purgeCSS 사용)
-
<head>의 메타 태그 확인 (Open Graph, Twitter Cards) - 404 페이지 생성 (
src/pages/404.astro) - 모바일 반응형 레이아웃 확인
- Analytics 통합
- 사이트맵 생성 (
@astrojs/sitemap) - 모든 내부 링크 확인 (끊어진 링크 없음)
- 접근성 확인 (ARIA 레이블, 대체 텍스트)
# 사이트맵 설치
npm install @astrojs/sitemap
import sitemap from '@astrojs/sitemap';
export default defineConfig({
integrations: [sitemap()]
});
테스트 및 품질
단위 테스트 (Vitest)
npm install vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true
}
});
E2E 테스트 (Playwright)
npm install @playwright/test
npx playwright install
// tests/home.spec.js
import { test, expect } from '@playwright/test';
test('홈 페이지 로드', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toContainText('내 사이트');
});
끊어진 링크 검사
npx broken-link-checker http://localhost:4321
또는 CI에서 lychee 사용:
lychee --config .lychee.toml dist/**/*.html
문제 해결
일반적인 문제
| 문제 | 해결 방법 |
|---|---|
| 이미지가 표시되지 않음 | 경로가 /public 기준인지 또는 가져오기(import) 사용 확인 |
| 404 페이지 | site 및 base 설정 확인 |
| 느린 빌드 | 콘텐츠 계층 API로 마이그레이션, 콘텐츠 파일 최적화 |
| 페이지가 업데이트되지 않음 | 캐시 무효화 설정 확인 |
빌드 오류
Error: ENOENT: no such file or directory- 가져오기 경로 확인TypeError: Cannot read properties of undefined- 콘텐츠 컬렉션 스키마 확인SyntaxError: Unexpected token- Astro 템플릿 구문 확인 (---펜스)
추가 리소스
라이선스
이 스킬은 교육 목적으로 제공됩니다. Astro는 MIT 라이선스에 따라 제공됩니다.
이 문서는 Astro 5.x 버전을 기준으로 작성되었습니다. 항상 공식 문서를 참조하여 최신 정보를 확인하세요.