Astro로 기술 블로그 만들기 | 콘텐츠 컬렉션·MDX·SEO·배포까지

Astro로 기술 블로그 만들기 | 콘텐츠 컬렉션·MDX·SEO·배포까지

이 글의 핵심

Astro로 기술 블로그를 만드는 전 과정을 다룹니다. Content Collections, MDX, 태그·검색·시리즈, RSS·Sitemap, OG 이미지, 다국어, SSR/SSG 선택, Cloudflare Pages 배포까지 실전 예제로 정리합니다.

들어가며

Astro콘텐츠 중심 사이트(블로그, 문서, 포트폴리오)에 최적화된 정적 사이트 생성기입니다. 기본이 Zero JavaScript라 빌드 시 HTML만 남고, 필요한 곳에만 React·Vue·Svelte 컴포넌트를 아일랜드(Islands)로 추가할 수 있습니다.

이 글에서는 Astro로 기술 블로그를 만드는 전 과정을 다룹니다: Content Collections, MDX, 태그·검색·시리즈, RSS·Sitemap, OG 이미지, 다국어, SSR/SSG 선택, 그리고 Cloudflare Pages 배포까지.

배포 후 검색 유입 구조는 기술 블로그 방문자 늘리기에서, Cloudflare Pages 설정은 Cloudflare Pages 완벽 가이드를 참고하세요.


목차

  1. Astro 프로젝트 시작
  2. Content Collections로 블로그 글 관리
  3. MDX로 컴포넌트 넣기
  4. 태그·카테고리·시리즈
  5. 검색 기능
  6. RSS·Sitemap·OG 이미지
  7. 다국어(i18n)
  8. SSR vs SSG 선택
  9. 배포 (Cloudflare Pages)
  10. 정리

1. Astro 프로젝트 시작

1-1. 프로젝트 생성

npm create astro@latest my-blog
cd my-blog
npm install
npm run dev

템플릿 선택: Blog 템플릿 선택 시 기본 구조 자동 생성.

1-2. 프로젝트 구조

my-blog/
├── src/
│   ├── content/
│   │   ├── blog/
│   │   │   ├── post-1.md
│   │   │   └── post-2.mdx
│   │   └── config.ts      # Content Collections 스키마
│   ├── pages/
│   │   ├── index.astro
│   │   ├── blog/
│   │   │   ├── [slug].astro
│   │   │   └── tag/[tag].astro
│   │   └── rss.xml.ts
│   ├── components/
│   └── layouts/
├── public/
├── astro.config.mjs
└── package.json

2. Content Collections로 블로그 글 관리

Content Collections는 마크다운 파일을 타입 안전하게 관리하는 Astro의 핵심 기능입니다.

2-1. 스키마 정의

// src/content/config.ts
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),
    author: z.string().default('pkglog'),
    readingMinutes: z.number().optional(),
    relatedPosts: z.array(z.string()).default([]),
  }),
});

export const collections = { blog };

2-2. 마크다운 작성

---
title: 'Astro로 블로그 만들기'
description: 'Astro 블로그 시작 가이드'
pubDate: 2026-04-01
tags: ['Astro', '블로그', 'JAMstack']
draft: false
---

## 들어가며

Astro는 콘텐츠 중심 사이트에 최적화되어 있습니다.

2-3. 글 목록 가져오기

// src/pages/blog/index.astro
---
import { getCollection } from 'astro:content';

const allPosts = await getCollection('blog', ({ data }) => {
  // draft 제외, 날짜 필터
  return !data.draft && data.pubDate <= new Date();
});

// 최신순 정렬
const posts = allPosts.sort((a, b) => 
  b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---

<ul>
  {posts.map(post => (
    <li>
      <a href={`/blog/${post.slug}/`}>{post.data.title}</a>
    </li>
  ))}
</ul>

2-4. 개별 글 페이지

// src/pages/blog/[slug].astro
---
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  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>
  <time>{post.data.pubDate.toLocaleDateString('ko-KR')}</time>
  <Content />
</article>

타입 안전: post.data.title은 자동 완성되고, 스키마 위반 시 빌드 실패.


3. MDX로 컴포넌트 넣기

MDX는 마크다운 안에 JSX를 쓸 수 있게 해 줍니다.

3-1. MDX 설치

npm install @astrojs/mdx
// astro.config.mjs
import mdx from '@astrojs/mdx';

export default defineConfig({
  integrations: [mdx()],
});

3-2. MDX 파일 작성

---
title: '인터랙티브 예제'
pubDate: 2026-04-01
---

import Counter from '../../components/Counter.jsx';

## 카운터 예제

<Counter client:load />

일반 마크다운과 컴포넌트를 섞을 수 있습니다.

3-3. 컴포넌트 예제

// src/components/Counter.jsx
import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

클라이언트 디렉티브:

  • client:load: 페이지 로드 시 즉시
  • client:idle: 브라우저 idle 시
  • client:visible: 뷰포트 진입 시

4. 태그·카테고리·시리즈

4-1. 태그 페이지

// src/pages/blog/tag/[tag].astro
---
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  const tags = [...new Set(posts.flatMap(p => p.data.tags))];
  
  return tags.map(tag => ({
    params: { tag },
    props: {
      posts: posts.filter(p => p.data.tags.includes(tag))
    },
  }));
}

const { tag } = Astro.params;
const { posts } = Astro.props;
---

<h1>태그: {tag}</h1>
<ul>
  {posts.map(post => (
    <li><a href={`/blog/${post.slug}/`}>{post.data.title}</a></li>
  ))}
</ul>

4-2. 시리즈 관리

// src/content/config.ts
const blog = defineCollection({
  schema: z.object({
    // ...
    seriesId: z.string().optional(),
    seriesOrder: z.number().optional(),
  }),
});
// 시리즈 글 가져오기
const seriesPosts = allPosts
  .filter(p => p.data.seriesId === 'algorithm')
  .sort((a, b) => (a.data.seriesOrder || 0) - (b.data.seriesOrder || 0));

5-1. 클라이언트 검색 (Fuse.js)

npm install fuse.js
// src/pages/search.astro
---
import { getCollection } from 'astro:content';

const posts = await getCollection('blog');
const searchData = posts.map(p => ({
  slug: p.slug,
  title: p.data.title,
  description: p.data.description,
  tags: p.data.tags,
}));
---

<script define:vars={{ searchData }}>
  import Fuse from 'fuse.js';
  
  const fuse = new Fuse(searchData, {
    keys: ['title', 'description', 'tags'],
    threshold: 0.3,
  });
  
  document.getElementById('search').addEventListener('input', (e) => {
    const results = fuse.search(e.target.value);
    // 결과 렌더링
  });
</script>

<input id="search" type="text" placeholder="검색..." />
<div id="results"></div>

5-2. 서버 검색 (Pagefind)

npm install -D pagefind
// package.json
{
  "scripts": {
    "build": "astro build && pagefind --site dist"
  }
}

빌드 후 자동으로 검색 인덱스 생성.


6. RSS·Sitemap·OG 이미지

6-1. RSS 피드

// src/pages/rss.xml.ts
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,
      description: post.data.description,
      pubDate: post.data.pubDate,
      link: `/blog/${post.slug}/`,
    })),
  });
}

6-2. Sitemap

// astro.config.mjs
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://example.com',
  integrations: [sitemap()],
});

빌드 시 dist/sitemap-index.xml 자동 생성.

6-3. OG 이미지 (Satori)

npm install satori sharp
// src/pages/og/[slug].png.ts
import { getCollection } from 'astro:content';
import satori from 'satori';
import sharp from 'sharp';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

export async function GET({ props }) {
  const { post } = props;
  
  const svg = await satori(
    <div style={{ 
      width: '1200px', 
      height: '630px',
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      padding: '80px',
      background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
      color: 'white',
    }}>
      <h1 style={{ fontSize: '64px', margin: 0 }}>{post.data.title}</h1>
      <p style={{ fontSize: '32px', marginTop: '20px' }}>{post.data.description}</p>
    </div>,
    {
      width: 1200,
      height: 630,
      fonts: [/* 폰트 로드 */],
    }
  );
  
  const png = await sharp(Buffer.from(svg)).png().toBuffer();
  
  return new Response(png, {
    headers: { 'Content-Type': 'image/png' },
  });
}

메타 태그:

<meta property="og:image" content={`https://example.com/og/${slug}.png`} />

7. 다국어(i18n)

7-1. 설정

// astro.config.mjs
export default defineConfig({
  i18n: {
    defaultLocale: 'ko',
    locales: ['ko', 'en'],
    routing: {
      prefixDefaultLocale: false, // /ko/ 없이 /blog/
    },
  },
});

7-2. 언어별 폴더

src/content/blog/
├── my-post.md          # 한국어
└── en/
    └── my-post.md      # 영어

7-3. 언어 전환

// src/utils/i18n.ts
export function getAlternateSlug(slug: string, locale: string) {
  if (locale === 'en') return `en/${slug}`;
  return slug.replace(/^en\//, '');
}
<link rel="alternate" hreflang="en" href={`/blog/${getAlternateSlug(slug, 'en')}/`} />
<link rel="alternate" hreflang="ko" href={`/blog/${slug}/`} />

8. SSR vs SSG 선택

Astro는 기본이 SSG(Static Site Generation)지만, 필요한 페이지만 SSR로 전환할 수 있습니다.

8-1. 전체 SSG (기본)

// astro.config.mjs
export default defineConfig({
  output: 'static', // 기본값
});

빌드 시 모든 페이지가 HTML로 생성.

8-2. Hybrid (일부 SSR)

export default defineConfig({
  output: 'hybrid',
  adapter: cloudflare(), // 또는 node(), vercel()
});
// src/pages/api/views.ts
export const prerender = false; // 이 페이지만 SSR

export async function GET() {
  const views = await getViewCount();
  return new Response(JSON.stringify({ views }));
}

8-3. 전체 SSR

export default defineConfig({
  output: 'server',
  adapter: cloudflare(),
});

모든 페이지가 요청마다 렌더링.

선택 기준:

  • 블로그 글: SSG (빌드 시 HTML)
  • 조회수·댓글: SSR API 또는 클라이언트 fetch
  • 검색: 클라이언트 검색 또는 SSR 엔드포인트

9. 배포 (Cloudflare Pages)

9-1. GitHub 연동

  1. Cloudflare 대시보드PagesCreate a project
  2. GitHub 저장소 연결
  3. 빌드 설정:
    • Framework preset: Astro
    • Build command: npm run build
    • Build output directory: dist

9-2. Wrangler CLI

npm install -D wrangler
npm run build
wrangler pages deploy dist --project-name=my-blog

9-3. GitHub Actions

name: Deploy to Cloudflare Pages

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install
        run: npm ci
      
      - name: Build
        run: npm run build
        env:
          NODE_OPTIONS: '--max-old-space-size=4096'
      
      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy dist --project-name=my-blog

자세한 배포 설정은 Cloudflare Pages 완벽 가이드를 참고하세요.


10. 실전 팁

10-1. 빌드 성능

대량 페이지 (1,000개+):

// astro.config.mjs
export default defineConfig({
  build: {
    concurrency: 16, // 병렬 렌더링
  },
});

OG 이미지 캐시:

// scripts/generate-og-images.mjs
import { existsSync } from 'fs';

for (const post of posts) {
  const ogPath = `public/og/${post.slug}.png`;
  if (existsSync(ogPath)) {
    console.log(`캐시 사용: ${post.slug}`);
    continue;
  }
  // 생성 로직
}

10-2. 읽기 시간 계산

// src/utils/reading-time.ts
export function calculateReadingTime(content: string): number {
  const wordsPerMinute = 200; // 한국어는 더 낮게
  const words = content.split(/\s+/).length;
  return Math.ceil(words / wordsPerMinute);
}
// src/pages/blog/[slug].astro
const readingTime = calculateReadingTime(post.body);

10-3. 관련 글 추천

// src/utils/related-posts.ts
export function getRelatedPosts(currentPost, allPosts) {
  return allPosts
    .filter(p => p.slug !== currentPost.slug)
    .map(p => ({
      post: p,
      score: countCommonTags(currentPost.data.tags, p.data.tags),
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, 3)
    .map(item => item.post);
}

function countCommonTags(tags1, tags2) {
  return tags1.filter(t => tags2.includes(t)).length;
}

10-4. 코드 하이라이팅

Astro는 기본으로 Shiki를 사용합니다.

// astro.config.mjs
export default defineConfig({
  markdown: {
    shikiConfig: {
      theme: 'github-dark',
      langs: ['javascript', 'typescript', 'python', 'cpp'],
    },
  },
});

10-5. 댓글 (Giscus)

<!-- src/components/Comments.astro -->
<script
  src="https://giscus.app/client.js"
  data-repo="username/repo"
  data-repo-id="..."
  data-category="Comments"
  data-category-id="..."
  data-mapping="pathname"
  data-reactions-enabled="1"
  data-theme="light"
  async
></script>

11. 고급 기능

11-1. View Transitions (페이지 전환 애니메이션)

---
// src/layouts/Layout.astro
import { ViewTransitions } from 'astro:transitions';
---

<html>
  <head>
    <ViewTransitions />
  </head>
  <body>
    <slot />
  </body>
</html>

페이지 이동 시 부드러운 전환 효과.

11-2. Middleware

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

export const onRequest = defineMiddleware(async (context, next) => {
  const start = Date.now();
  const response = await next();
  
  console.log(`${context.url.pathname} - ${Date.now() - start}ms`);
  
  return response;
});

11-3. 환경 변수

// .env
PUBLIC_SITE_URL=https://example.com
PRIVATE_API_KEY=secret123
// PUBLIC_으로 시작하면 클라이언트에서도 접근 가능
const siteUrl = import.meta.env.PUBLIC_SITE_URL;

// PRIVATE_는 서버에서만
const apiKey = import.meta.env.PRIVATE_API_KEY;

12. 정리

핵심 요약

Astro 블로그 장점:

  • 빠른 속도: Zero JS, 정적 HTML
  • 타입 안전: Content Collections
  • 유연성: MDX, React·Vue 아일랜드
  • SEO: RSS, Sitemap, OG 이미지 자동화

추천 스택:

  • 프레임워크: Astro 5+
  • 스타일: Tailwind CSS
  • 검색: Fuse.js 또는 Pagefind
  • 댓글: Giscus (GitHub Discussions)
  • 배포: Cloudflare Pages
  • CI/CD: GitHub Actions

체크리스트

프로젝트 설정:

  • Content Collections 스키마 정의
  • 태그·시리즈 구조 설계
  • RSS·Sitemap 설정
  • OG 이미지 생성 스크립트

콘텐츠:

  • 마크다운 템플릿 (frontmatter)
  • 코드 블록 스타일
  • 목차 자동 생성
  • 관련 글 로직

배포:

  • 환경 변수 설정
  • 빌드 캐시 최적화
  • 커스텀 도메인
  • Analytics 연동

다음 단계

Astro 블로그와 함께 보면 좋은 글:

  • Astro + Cloudflare Pages 스택 분석 (Vercel·WordPress 비교)
  • Cloudflare Pages 완벽 가이드
  • 기술 블로그 방문자 늘리기·내부 링크
  • Node.js + GitHub Actions CI/CD
  • Technical SEO with Next.js App Router — SSR/SSG 비교 (영문)

참고 자료:

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3