Astro Content Collections 완벽 가이드 | 타입 안전·스키마·MDX·블로그 구축

Astro Content Collections 완벽 가이드 | 타입 안전·스키마·MDX·블로그 구축

이 글의 핵심

Astro Content Collections로 타입 안전한 콘텐츠 관리 시스템을 구축하는 완벽 가이드입니다. 스키마 정의, MDX, 블로그, 다국어, SEO까지 실전 예제로 정리했습니다.

실무 경험 공유: 기술 블로그 플랫폼을 Astro Content Collections로 구축하면서, 타입 안전성을 100% 확보하고 콘텐츠 관리 오류를 완전히 제거한 경험을 공유합니다.

들어가며: “마크다운 관리가 복잡해요”

실무 문제 시나리오

시나리오 1: 프론트매터 오타
YAML 프론트매터에 오타가 있어도 런타임에만 발견됩니다. Content Collections는 빌드 타임에 검증합니다.

시나리오 2: 타입 안전성 부족
마크다운 데이터를 사용할 때 타입이 없습니다. Content Collections는 자동 타입 생성합니다.

시나리오 3: 복잡한 쿼리
여러 조건으로 콘텐츠를 필터링하기 어렵습니다. Content Collections는 강력한 쿼리 API를 제공합니다.


1. Content Collections란?

핵심 개념

Content Collections는 Astro의 타입 안전 콘텐츠 관리 시스템입니다.

주요 장점:

  • 타입 안전: Zod 스키마로 프론트매터 검증
  • 자동 타입 생성: TypeScript 타입 자동 생성
  • 강력한 쿼리: 필터링, 정렬, 페이지네이션
  • MDX 지원: React 컴포넌트 사용 가능
  • 빌드 타임 검증: 오류를 미리 발견

2. 기본 설정

디렉터리 구조

src/
└── content/
    ├── config.ts
    ├── blog/
    │   ├── post-1.md
    │   └── post-2.mdx
    └── docs/
        ├── intro.md
        └── guide.md

스키마 정의

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const blogCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    updatedDate: z.date().optional(),
    author: z.string().default('Anonymous'),
    tags: z.array(z.string()),
    draft: z.boolean().default(false),
    featured: z.boolean().default(false),
  }),
});

const docsCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    order: z.number(),
    category: z.enum(['guide', 'api', 'tutorial']),
  }),
});

export const collections = {
  blog: blogCollection,
  docs: docsCollection,
};

3. 콘텐츠 작성

마크다운

---
title: 'Getting Started with Astro'
description: 'Learn how to build fast websites with Astro'
pubDate: 2026-04-11
author: 'JB'
tags: ['astro', 'tutorial']
draft: false
featured: true
---

# Getting Started

Astro is a modern static site generator...

MDX (컴포넌트 사용)

---
title: 'Interactive Guide'
description: 'Learn with interactive examples'
pubDate: 2026-04-11
tags: ['interactive']
---

import Button from '@/components/Button.astro';
import Counter from '@/components/Counter.tsx';

# Interactive Guide

Click the button below:

<Button>Click me</Button>

Try the counter:

<Counter client:load />

4. 콘텐츠 조회

전체 조회

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

const allPosts = await getCollection('blog');
const publishedPosts = allPosts.filter(post => !post.data.draft);
---

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

필터링 및 정렬

---
import { getCollection } from 'astro:content';

// 필터링
const featuredPosts = await getCollection('blog', ({ data }) => {
  return data.featured && !data.draft;
});

// 정렬
const sortedPosts = featuredPosts.sort((a, b) => 
  b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);

// 태그별 필터링
const tag = Astro.params.tag;
const tagPosts = await getCollection('blog', ({ data }) => {
  return data.tags.includes(tag);
});
---

단일 조회

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

const { slug } = Astro.params;
const post = await getEntry('blog', slug);

if (!post) {
  return Astro.redirect('/404');
}

const { Content } = await post.render();
---

<article>
  <h1>{post.data.title}</h1>
  <p>By {post.data.author} on {post.data.pubDate.toLocaleDateString()}</p>
  <Content />
</article>

5. 동적 라우팅

getStaticPaths

---
// 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>
  <Content />
</article>

태그 페이지

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

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

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

<h1>Posts tagged with "{tag}"</h1>
<ul>
  {posts.map(post => (
    <li>
      <a href={`/blog/${post.slug}`}>{post.data.title}</a>
    </li>
  ))}
</ul>

6. 페이지네이션

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

export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
  const posts = await getCollection('blog', ({ data }) => !data.draft);
  const sortedPosts = posts.sort((a, b) => 
    b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
  );
  
  return paginate(sortedPosts, { pageSize: 10 });
};

const { page } = Astro.props;
---

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

<nav>
  {page.url.prev && <a href={page.url.prev}>Previous</a>}
  <span>Page {page.currentPage} of {page.lastPage}</span>
  {page.url.next && <a href={page.url.next}>Next</a>}
</nav>

7. 관련 글 추천

---
import { getCollection } from 'astro:content';

const { slug } = Astro.params;
const post = await getEntry('blog', slug);
const allPosts = await getCollection('blog', ({ data }) => !data.draft);

// 같은 태그를 가진 글 찾기
const relatedPosts = allPosts
  .filter(p => 
    p.slug !== slug && 
    p.data.tags.some(tag => post.data.tags.includes(tag))
  )
  .slice(0, 3);
---

<article>
  <h1>{post.data.title}</h1>
  <Content />
</article>

<aside>
  <h2>Related Posts</h2>
  <ul>
    {relatedPosts.map(related => (
      <li>
        <a href={`/blog/${related.slug}`}>{related.data.title}</a>
      </li>
    ))}
  </ul>
</aside>

8. 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', ({ data }) => !data.draft);
  
  return rss({
    title: 'My Blog',
    description: 'A blog about web development',
    site: context.site,
    items: posts.map(post => ({
      title: post.data.title,
      description: post.data.description,
      pubDate: post.data.pubDate,
      link: `/blog/${post.slug}/`,
    })),
  });
}

9. 다국어 지원

// src/content/config.ts
const blogCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    lang: z.enum(['en', 'ko', 'ja']).default('en'),
    translationKey: z.string().optional(),
  }),
});
---
// 같은 글의 다른 언어 버전 찾기
const currentPost = await getEntry('blog', slug);
const allPosts = await getCollection('blog');

const translations = allPosts.filter(post => 
  post.data.translationKey === currentPost.data.translationKey &&
  post.slug !== slug
);
---

<nav>
  {translations.map(translation => (
    <a href={`/blog/${translation.slug}`}>
      {translation.data.lang.toUpperCase()}
    </a>
  ))}
</nav>

10. 실전 예제: 기술 블로그

// src/content/config.ts
import { defineCollection, z } from 'astro:content';

const blogCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    updatedDate: z.date().optional(),
    author: z.string(),
    category: z.enum(['frontend', 'backend', 'devops', 'ai']),
    tags: z.array(z.string()),
    draft: z.boolean().default(false),
    featured: z.boolean().default(false),
    coverImage: z.string().optional(),
    readingTime: z.number(),
    relatedPosts: z.array(z.string()).optional(),
  }),
});

export const collections = { blog: blogCollection };
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import Layout from '@/layouts/Layout.astro';

const allPosts = await getCollection('blog', ({ data }) => !data.draft);
const sortedPosts = allPosts.sort((a, b) => 
  b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);

const featuredPosts = sortedPosts.filter(post => post.data.featured).slice(0, 3);
const recentPosts = sortedPosts.slice(0, 10);

const categories = [...new Set(allPosts.map(post => post.data.category))];
---

<Layout title="Blog">
  <section>
    <h2>Featured Posts</h2>
    <div class="grid">
      {featuredPosts.map(post => (
        <article class="card">
          {post.data.coverImage && (
            <img src={post.data.coverImage} alt={post.data.title} />
          )}
          <h3>
            <a href={`/blog/${post.slug}`}>{post.data.title}</a>
          </h3>
          <p>{post.data.description}</p>
          <div class="meta">
            <span>{post.data.category}</span>
            <span>{post.data.readingTime}분</span>
          </div>
        </article>
      ))}
    </div>
  </section>

  <section>
    <h2>Recent Posts</h2>
    <ul>
      {recentPosts.map(post => (
        <li>
          <a href={`/blog/${post.slug}`}>{post.data.title}</a>
          <time>{post.data.pubDate.toLocaleDateString()}</time>
        </li>
      ))}
    </ul>
  </section>

  <aside>
    <h2>Categories</h2>
    <ul>
      {categories.map(category => (
        <li>
          <a href={`/blog/category/${category}`}>{category}</a>
        </li>
      ))}
    </ul>
  </aside>
</Layout>

정리 및 체크리스트

핵심 요약

  • Content Collections: Astro의 타입 안전 콘텐츠 관리
  • Zod 스키마: 프론트매터 검증
  • 자동 타입 생성: TypeScript 타입 자동 생성
  • 강력한 쿼리: 필터링, 정렬, 페이지네이션
  • MDX 지원: React 컴포넌트 사용 가능

구현 체크리스트

  • Content Collections 설정
  • 스키마 정의
  • 콘텐츠 작성
  • 동적 라우팅 구현
  • 페이지네이션 구현
  • RSS 피드 생성
  • SEO 최적화

같이 보면 좋은 글

  • Astro 블로그 완벽 가이드
  • Next.js 15 완벽 가이드
  • MDX 완벽 가이드

이 글에서 다루는 키워드

Astro, Content Collections, MDX, TypeScript, Blog, CMS, Static Site

자주 묻는 질문 (FAQ)

Q. Content Collections vs 일반 마크다운, 어떤 게 나은가요?

A. Content Collections는 타입 안전성, 스키마 검증, 강력한 쿼리 API를 제공합니다. 소규모 프로젝트는 일반 마크다운도 충분하지만, 중대형 프로젝트는 Content Collections를 권장합니다.

Q. MDX를 사용해야 하나요?

A. 인터랙티브 컴포넌트가 필요하면 MDX를 사용하세요. 단순 텍스트만 있다면 일반 마크다운이 더 빠릅니다.

Q. CMS와 통합할 수 있나요?

A. 네, Contentful, Sanity 등 Headless CMS와 통합 가능합니다. 다만 Content Collections의 타입 안전성 이점은 줄어듭니다.

Q. 성능은 어떤가요?

A. 빌드 타임에 모든 콘텐츠를 처리하므로 런타임 성능은 매우 빠릅니다. 콘텐츠가 수천 개여도 문제없습니다.

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