Communitygithub.com

qalimero/qalim-portfolio

Astro 프레임워크를 사용한 정적 웹사이트 구축 및 배포에 대한 종합적인 안내서입니다.

qalim-portfolio란 무엇인가요?

이 스킬은 Astro 프레임워크로 정적 웹사이트를 구축하고 배포하는 방법을 상세히 설명합니다. SSG(정적 사이트 생성) 원리, 콘텐츠 컬렉션, Markdown/MDX, 부분 수화, 아일랜드 아키텍처 등 Astro의 핵심 개념을 다룹니다. 특히 Netlify, Vercel, GitHub Pages, GCP/Firebase 같은 주요 플랫폼에 배포하는 방법과 함께 Shiki 문법 강조, Mermaid 다이어그램 통합, Pagefind 기반 클라이언트 측 검색, 다국어(i18n) 지원 등 실무에 필요한 고급 기능들을 제공합니다. 개발자는 'create astro site', 'deploy astro to firebase', 'astro markdown setup' 같은 명령어로 이 스킬을 호출할 수 있습니다.

지원 대상✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/qalimero/qalim-portfolio/tree/HEAD/.claude/skills/publishing-astro-websites

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

미리보기

스킬 README에서

qalim-portfolio preview

문서

Astro 웹사이트 퍼블리싱

Astro의 제로 런타임 SSG 접근 방식, 부분 수화, 그리고 광범위한 Markdown 지원을 활용하여 빠르고 콘텐츠 중심적인 정적 웹사이트를 구축하세요.

목차

빠른 시작

# 새 프로젝트 생성 (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 필요).

다크 모드 테마 전략:

  1. CSS 변수 - 브라우저가 런타임에 색상을 결정하도록 합니다:
// mermaid 설정
mermaid.initialize({
  theme: 'base',
  themeVariables: {
    primaryColor: 'var(--diagram-primary)',
    lineColor: 'var(--diagram-line)'
  }
});
  1. Picture 요소 - 두 테마를 모두 생성하고 미디어 쿼리로 전환:
<picture>
  <source srcset="/diagrams/flow-dark.svg" media="(prefers-color-scheme: dark)">
  <img src="/diagrams/flow-light.svg" alt="플로우 다이어그램">
</picture>
  1. 인라인 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

기능PagefindFuse.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 버전을 기준으로 작성되었습니다. 항상 공식 문서를 참조하여 최신 정보를 확인하세요.

관련 스킬