Astro Content Collections로 콘텐츠를 타입 안전하게 관리하기

블로그를 운영하다 보면 마크다운 파일이 수십, 수백 개로 늘어나는 건 시간문제입니다. 처음에는 프런트매터에 title이나 date를 성실히 적다가도, 어느 순간 date 포맷을 잘못 쓴다든지, tags를 빠뜨린다든지 하는 실수가 슬슬 나타나기 시작하죠. 문제는 이런 실수가 조용히 사이트에 반영되어 깨진 페이지나 누락된 정보로 이어진다는 겁니다. 😅

Astro의 Content Collections는 바로 이 문제를 해결해 줍니다. 마크다운 파일의 프런트매터를 Zod 스키마로 정의해두면 빌드할 때 잘못된 데이터를 즉시 잡아내고 편집기에서 자동 완성까지 받을 수 있거든요.

이 글에서는 Content Collections의 기본 개념부터 실전에서 유용한 고급 패턴까지 하나씩 다뤄보겠습니다.

콘텐츠 컬렉션이 필요한 이유

마크다운 기반 블로그에서 각 포스트의 프런트매터는 일종의 데이터베이스 레코드와 같습니다. 제목, 날짜, 태그, 설명 등의 필드가 있고, 모든 포스트가 동일한 구조를 따라야 하죠.

그런데 마크다운 파일의 프런트매터는 그냥 YAML입니다. 별도의 검증 없이 문자열을 파싱할 뿐이라, 어떤 포스트에는 date가 있고 어떤 포스트에는 없어도 빌드가 잘 됩니다. tags를 배열 대신 문자열로 적어도 에러가 나지 않고요.

---
title: "좋은 포스트"
date: 2026-02-23
tags:
  - Astro
  - TypeScript
---
---
title: 문제가 있는 포스트
date: "어제"
tags: Astro
---

위의 두 프런트매터 중 아래쪽은 date가 날짜 형식이 아니고 tags가 배열이 아닌데, 순수 YAML 파싱만으로는 이런 문제를 잡아낼 수 없습니다.

Content Collections는 Zod 스키마를 사용해서 이 프런트매터를 빌드 시점에 검증합니다. 잘못된 데이터가 있으면 사이트 빌드 자체가 실패하기 때문에 깨진 상태로 배포될 일이 없어지는 거죠.

시작하기: 디렉토리 구조

Content Collections를 사용하려면 src/content/ 디렉토리 아래에 콘텐츠를 배치합니다. 디렉토리 이름이 곧 컬렉션 이름이 되는데, 예를 들어 블로그 포스트를 관리한다면 이런 구조가 됩니다.

src/
└── content/
    ├── config.ts       # 스키마 정의
    └── posts/          # "posts" 컬렉션
        ├── hello-world.md
        ├── astro-tutorial.md
        └── react-hooks.md

src/content/config.ts는 컬렉션의 스키마를 정의하는 설정 파일입니다. Astro는 이 파일을 읽어서 각 마크다운 파일의 프런트매터가 스키마에 맞는지 검증하고, TypeScript 타입을 자동으로 생성합니다.

하나의 프로젝트에 여러 컬렉션을 만들 수도 있습니다. 블로그 포스트 외에 저자 정보, 프로젝트 포트폴리오 같은 것도 별도 컬렉션으로 관리할 수 있죠.

src/
└── content/
    ├── config.ts
    ├── posts/          # 블로그 포스트
    ├── authors/        # 저자 정보
    └── projects/       # 포트폴리오

스키마 정의하기

컬렉션의 핵심은 스키마 정의입니다. src/content/config.ts에서 defineCollection과 Zod를 사용해 프런트매터의 구조를 선언합니다.

가장 기본적인 형태부터 보겠습니다.

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

const posts = defineCollection({
  type: "content",
  schema: z.object({
    title: z.string(),
    date: z.coerce.date(),
    tags: z.array(z.string()).default([]),
  }),
});

export const collections = { posts };

여기서 type: "content"는 마크다운이나 MDX 같은 콘텐츠 파일을 다룬다는 의미입니다. Astro는 type: "data"도 지원하는데, 이건 JSON이나 YAML 파일을 다룰 때 씁니다. 저자 프로필이나 사이트 설정 같은 데이터를 관리할 때 유용하죠.

스키마에서 몇 가지 눈여겨볼 점이 있습니다.

z.coerce.date()는 문자열을 Date 객체로 자동 변환합니다. YAML에서 date: 2026-02-23이라고 적으면 문자열 "2026-02-23"으로 파싱되는데, z.coerce.date()가 이걸 JavaScript Date 객체로 바꿔주는 거죠. 덕분에 페이지에서 post.data.date.getTime()처럼 Date 메서드를 바로 사용할 수 있습니다.

z.array(z.string()).default([])tags 필드가 없을 때 빈 배열을 기본값으로 쓰겠다는 뜻입니다. 이렇게 하면 태그 없는 포스트도 에러 없이 처리할 수 있고, 코드에서 post.data.tags가 항상 배열임을 보장받습니다.

Zod의 스키마 정의 문법이 낯설다면 Zod를 통한 타입스크립트 친화적인 스키마 정의를 먼저 읽어보세요.

실전 스키마: 블로그에서 실제로 쓰는 구조

기본 스키마만으로도 충분히 쓸 수 있지만, 실제 블로그를 운영하다 보면 점점 더 다양한 필드가 필요해집니다. 이 블로그에서 실제로 사용하고 있는 스키마를 보면서 각 필드가 어떤 역할을 하는지 알아보죠.

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

const posts = defineCollection({
  type: "content",
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      description: z.string(),
      date: z.coerce.date(),
      tags: z.array(z.string()).default([]),
      categories: z.array(z.string()).optional(),
      updated: z.coerce.date().optional(),
      cover: image().optional(),
      pinned: z.boolean().default(false),
      draft: z.boolean().default(false),
    }),
});

export const collections = { posts };

기본 스키마와 비교하면 몇 가지 필드가 추가됐습니다.

우선 description은 SEO용 메타 설명인데, z.string()으로 필수 필드로 지정했습니다. 검색 엔진 결과에 표시되는 중요한 필드라서 빠뜨리면 안 되거든요.

categoriestags를 분리한 이유도 재밌습니다. 원래 이 블로그는 categories만 쓰다가 나중에 tags로 전환했는데, 기존 포스트의 categories 데이터를 한꺼번에 마이그레이션하기보다는 두 필드를 동시에 지원하면서 코드에서 합치는 방식을 택했습니다. categories.optional()로 선언해서 새 글에는 안 써도 되고, 기존 글도 그대로 유지되는 거죠.

updated는 수정일을 기록하는 필드입니다. 작성일(date)과 별도로 관리하면 “이 글은 언제 마지막으로 업데이트됐는지” 표시할 수 있어서 독자에게 신뢰감을 줍니다.

cover는 소셜 미디어 공유 시 사용할 OG 이미지 경로입니다. 여기서 주목할 점은 z.string()이 아니라 image()를 사용한다는 겁니다. schema가 함수 형태로 ({ image })를 받고 있는데, 이 image() 헬퍼는 Astro가 제공하는 특별한 스키마입니다. 로컬 이미지 경로를 빌드 시점에 검증해서, 존재하지 않는 이미지를 참조하면 빌드 에러를 발생시킵니다. 이미지 최적화에 대해 더 알고 싶다면 Astro 이미지 최적화를 참고하세요.

draft는 초안 관리를 위한 불리언 필드입니다. .default(false)로 선언해서 프런트매터에 명시하지 않으면 자동으로 false가 됩니다. 이 필드를 쿼리할 때 어떻게 활용하는지는 바로 다음 섹션에서 확인할 수 있습니다.

콘텐츠 쿼리하기

스키마를 정의했으니 이제 데이터를 가져다 써볼까요? Astro는 getCollection 함수로 컬렉션의 모든 항목을 조회할 수 있게 해줍니다.

import { getCollection } from "astro:content";

const allPosts = await getCollection("posts");

이 한 줄로 src/content/posts/ 디렉토리의 모든 마크다운 파일을 가져옵니다. 반환되는 각 항목에는 data (검증된 프런트매터), slug (파일명 기반 URL 슬러그), body (마크다운 본문), render() (HTML로 렌더링하는 함수) 등이 담겨 있습니다.

getCollection의 두 번째 인자로 필터 함수를 전달할 수도 있습니다. 이 블로그에서는 이걸 활용해서 개발 모드와 프로덕션 모드의 동작을 다르게 처리하고 있습니다.

src/utils/posts.ts
import { getCollection } from "astro:content";

export async function getPublishedPosts() {
  const posts = await getCollection("posts", ({ data }) => {
    // 개발 모드에서는 초안 포함 모든 포스트 표시
    if (import.meta.env.DEV) return true;
    // 프로덕션에서는 초안 제외
    return data.draft !== true;
  });
  return posts;
}

import.meta.env.DEV는 Astro가 제공하는 환경 변수로, astro dev로 실행 중이면 true입니다. 이렇게 하면 글을 쓰면서 draft: true로 설정해놓고 개발 서버에서 미리보기를 하다가, 준비가 되면 draft 줄을 지우거나 false로 바꿔서 배포할 수 있습니다.

정렬과 변환

콘텐츠를 가져왔으면 보통은 정렬을 해서 최신 글이 위로 오도록 합니다.

const posts = await getPublishedPosts();
const sorted = posts.sort(
  (a, b) => b.data.date.getTime() - a.data.date.getTime()
);

data.date가 Zod의 z.coerce.date() 덕분에 이미 Date 객체이므로 getTime()을 바로 호출할 수 있습니다. 만약 스키마에서 z.string()으로 선언했다면 여기서 new Date(a.data.date)처럼 변환을 해야 했을 겁니다. 스키마에서 타입을 정확히 선언하면 사용하는 쪽의 코드가 이렇게 깔끔해집니다.

좀 더 복잡한 정렬도 가능합니다. 예를 들어 이 블로그에서는 고정(pinned) 포스트를 상단에 노출하면서 나머지는 날짜순으로 보여주고 있습니다.

posts.sort((a, b) => {
  if (a.data.pinned && !b.data.pinned) return -1;
  if (!a.data.pinned && b.data.pinned) return 1;
  return b.data.date.getTime() - a.data.date.getTime();
});

정렬된 포스트를 페이지에 표시할 때는 보통 원본 데이터를 그대로 쓰지 않고, 필요한 필드만 뽑아서 가볍게 만듭니다. 마크다운 본문 전체를 목록 페이지에서 들고 있을 필요는 없으니까요.

const summaries = await Promise.all(
  sorted.map(async (post) => ({
    slug: `/${post.slug}/`,
    title: post.data.title,
    tags: getAllTags(post),
    excerpt: await getExcerpt(post.body),
  }))
);

여기서 getExcerpt는 마크다운 본문에서 이미지나 특수 문법을 제거한 뒤 앞부분 300자를 잘라서 발췌문을 만드는 유틸리티 함수입니다.

페이지에서 렌더링하기

개별 포스트 페이지에서 마크다운을 HTML로 렌더링하는 부분도 중요합니다. Astro의 정적 사이트 생성(SSG)에서는 getStaticPaths 함수가 빌드 시점에 모든 페이지의 경로를 만들어 줍니다.

src/pages/[slug].astro
---
import { getPublishedPosts } from "../utils/posts";

export async function getStaticPaths() {
  const posts = await getPublishedPosts();
  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.date.toLocaleDateString("ko-KR")}</time>
  <Content />
</article>

핵심은 post.render()입니다. 이 메서드가 마크다운 본문을 파싱해서 코드 블록에 구문 강조를 적용하고 HTML로 변환한 뒤 Astro 컴포넌트로 반환합니다. 반환된 Content를 JSX처럼 <Content />로 삽입하면 렌더링된 HTML이 그 자리에 들어갑니다.

getStaticPaths에서 params로 넘긴 slug는 URL 경로가 됩니다. hello-world.md 파일이면 slug"hello-world"이고 /hello-world/ 경로로 접근할 수 있는 거죠.

태그 시스템 구축하기

Content Collections의 타입 안전성이 빛을 발하는 또 다른 영역이 태그 시스템입니다. CollectionEntry 타입을 활용하면 컬렉션 항목을 다루는 유틸리티 함수를 안전하게 만들 수 있어요.

import { type CollectionEntry } from "astro:content";

function getAllTags(post: {
  data: { categories?: string[]; tags: string[] };
}): string[] {
  return [
    ...new Set([...(post.data.categories || []), ...post.data.tags]),
  ];
}

Set으로 중복을 제거하면서 categoriestags를 하나로 합치고 있습니다. categoriesoptional이라 || []로 빈 배열을 기본값으로 넣는 부분도 스키마 정의와 정확히 맞아떨어지죠.

태그별 포스트 개수를 구하는 것도 간단합니다.

function getTagCounts(
  posts: { data: { categories?: string[]; tags: string[] } }[]
): Record<string, number> {
  return posts.reduce((acc, post) => {
    getAllTags(post).forEach((tag) => {
      acc[tag] = (acc[tag] || 0) + 1;
    });
    return acc;
  }, {} as Record<string, number>);
}

이 함수를 써서 태그 네비게이션을 만들 수 있습니다. 예를 들어 “JavaScript (42)“처럼 태그 옆에 포스트 수를 표시하는 식이죠.

태그별 페이지도 getStaticPaths로 생성합니다.

src/pages/tag/[tag]/index.astro
---
import { getPublishedPosts, getTagCounts, getAllTags } from "../../../utils/posts";

export async function getStaticPaths() {
  const posts = await getPublishedPosts();
  const tagCounts = getTagCounts(posts);

  return Object.keys(tagCounts).map((tag) => ({
    params: { tag },
    props: {
      posts: posts.filter((post) => getAllTags(post).includes(tag)),
    },
  }));
}
---

모든 태그에 대해 개별 페이지를 자동 생성하기 때문에 새로운 태그를 추가할 때 별도의 설정이 필요 없습니다. 마크다운 프런트매터에 태그를 적기만 하면 빌드할 때 알아서 해당 태그 페이지가 만들어집니다.

연관 포스트 찾기

콘텐츠가 많아지면 “이 글을 읽은 사람이 관심 가질 만한 다른 글”을 보여주고 싶어지죠. 공유 태그 수를 기반으로 연관 포스트를 계산하는 방법을 알아봅시다.

function getRelatedPosts(
  currentPost: CollectionEntry<"posts">,
  allPosts: CollectionEntry<"posts">[],
  limit = 4
): CollectionEntry<"posts">[] {
  const currentTagSet = new Set(getAllTags(currentPost));

  return allPosts
    .filter((post) => post.slug !== currentPost.slug)
    .map((post) => ({
      post,
      score: getAllTags(post).filter((tag) => currentTagSet.has(tag)).length,
    }))
    .filter(({ score }) => score > 0)
    .sort(
      (a, b) =>
        b.score - a.score ||
        b.post.data.date.getTime() - a.post.data.date.getTime(),
    )
    .slice(0, limit)
    .map(({ post }) => post);
}

현재 포스트의 태그와 겹치는 태그가 많을수록 높은 점수를 받고, 점수가 같으면 최신 글이 우선입니다. 이런 로직은 정적 사이트이기 때문에 getStaticPaths 안에서 빌드 시점에 한 번만 계산하면 됩니다. 런타임 비용이 전혀 없죠.

src/pages/[slug].astro
---
export async function getStaticPaths() {
  const posts = await getPublishedPosts();
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: {
      post,
      relatedPosts: getRelatedPosts(post, posts),
    },
  }));
}
---

getStaticPaths에서 props로 연관 포스트를 미리 계산해서 넘기면, 페이지 컴포넌트에서는 받아서 렌더링만 하면 됩니다.

Content Collections와 TypeScript

Content Collections를 쓰면서 체감하는 가장 큰 장점은 역시 TypeScript 통합입니다.

스키마를 정의하면 Astro가 .astro/types.d.ts에 타입을 자동 생성합니다. getCollection("posts")의 반환값이 자동으로 타입이 지정되기 때문에, post.data.titlestring이고 post.data.dateDate이고 post.data.tagsstring[]임을 편집기가 알고 있습니다.

어떤 느낌인지 코드로 직접 보면 감이 올 겁니다.

const posts = await getCollection("posts");
const post = posts[0];

// ✅ 타입이 자동으로 추론됨
post.data.title;       // string
post.data.date;        // Date
post.data.tags;        // string[]
post.data.draft;       // boolean
post.data.cover;       // ImageMetadata | undefined

// ❌ 스키마에 없는 필드는 컴파일 에러
post.data.author;      // Property 'author' does not exist

스키마에 없는 필드를 참조하면 타입 에러가 발생합니다. 이건 단순히 편의를 넘어서, 프런트매터 필드명을 변경하거나 삭제할 때 영향받는 코드를 컴파일러가 알려준다는 뜻입니다.

CollectionEntry<"posts"> 타입을 함수의 매개변수에 사용하면 컬렉션 항목을 다루는 유틸리티 함수도 타입 안전하게 작성할 수 있습니다.

import { type CollectionEntry } from "astro:content";

function formatPostDate(post: CollectionEntry<"posts">): string {
  return post.data.date.toLocaleDateString("ko-KR", {
    year: "numeric",
    month: "long",
    day: "numeric",
  });
}

타입스크립트를 쓰는데도 유효성 검증이 필요할까?라는 글에서 다뤘듯이, TypeScript의 타입 시스템은 컴파일 타임에만 존재합니다. 하지만 Content Collections의 Zod 스키마는 실제 데이터를 런타임에 검증하면서 동시에 TypeScript 타입도 추론해줍니다. 타입 안전성과 데이터 검증이라는 두 마리 토끼를 한꺼번에 잡는 거죠.

자주 겪는 실수와 해결법

Content Collections를 처음 도입할 때 흔히 마주치는 문제와 해결법을 모아봤습니다.

날짜 필드에 z.date() 대신 z.coerce.date()를 쓰세요. YAML의 날짜는 파서에 따라 문자열로 읽힐 수 있습니다. z.date()는 이미 Date 객체인 값만 받아들이기 때문에 "2026-02-23" 같은 문자열에서 실패합니다. z.coerce.date()를 쓰면 문자열을 Date로 자동 변환해줍니다.

optionaldefault를 구분해서 쓰세요. z.string().optional()은 값이 없을 때 undefined를 반환합니다. z.array(z.string()).default([])는 값이 없을 때 빈 배열을 반환합니다. 코드에서 post.data.tags?.length처럼 옵셔널 체이닝을 쓸 일이 없게 하려면 default를 활용하는 게 좋습니다.

이미지 경로는 image() 헬퍼를 사용하세요. 커버 이미지를 z.string()으로 선언하면 빌드할 때 해당 파일이 존재하는지 확인하지 않습니다. image() 헬퍼를 사용하면 빌드 시점에 이미지 파일의 존재 여부를 검증하고, 자동으로 최적화 파이프라인에 포함시킵니다.

// ❌ 이미지 경로를 검증하지 않음
schema: z.object({
  cover: z.string().optional(),
})

// ✅ 빌드 시 이미지 존재 여부까지 검증
schema: ({ image }) => z.object({
  cover: image().optional(),
})

컬렉션 이름과 디렉토리 이름을 맞추세요. defineCollection으로 정의한 이름과 src/content/ 아래의 디렉토리 이름이 일치해야 합니다. export const collections = { posts }라고 했으면 디렉토리도 src/content/posts/여야 합니다.

data 컬렉션 활용하기

지금까지는 마크다운 콘텐츠를 다루는 type: "content" 컬렉션만 봤는데, Astro에는 type: "data" 컬렉션도 있습니다. JSON이나 YAML 파일로 구조화된 데이터를 관리할 때 유용해요.

예를 들어 저자 정보를 별도 컬렉션으로 관리한다면 이렇게 할 수 있습니다.

src/content/config.ts
const authors = defineCollection({
  type: "data",
  schema: z.object({
    name: z.string(),
    bio: z.string(),
    avatar: z.string().url(),
    social: z.object({
      github: z.string().url().optional(),
      twitter: z.string().url().optional(),
    }),
  }),
});

export const collections = { posts, authors };
src/content/authors/daleseo.json
{
  "name": "DaleSeo",
  "bio": "웹 개발자",
  "avatar": "https://example.com/avatar.jpg",
  "social": {
    "github": "https://github.com/DaleSeo"
  }
}

type: "data" 컬렉션은 render() 메서드가 없는 대신 data 프로퍼티로 검증된 데이터에 바로 접근할 수 있습니다. 네비게이션 메뉴 항목, 사이트 설정, FAQ 목록 같은 것도 이렇게 타입 안전하게 관리할 수 있죠.

마치며

Content Collections는 단순히 마크다운을 불러오는 기능이 아닙니다. Zod 스키마로 데이터를 검증하고 TypeScript 타입을 자동 생성하는 건 물론이고 빌드 시점에 이미지 경로까지 검증해주는 콘텐츠 관리 시스템이에요.

핵심만 짚어보면, src/content/config.ts에서 스키마를 정의해두면 잘못된 프런트매터를 빌드 시점에 잡아낼 수 있습니다. getCollection으로 타입이 보장된 데이터를 가져와서 필터링이나 정렬도 안전하게 처리할 수 있고요. CollectionEntry 타입 덕분에 유틸리티 함수도 타입 안전하게 작성할 수 있죠.

처음에는 스키마 정의하는 게 번거로워 보일 수 있는데, 콘텐츠가 50개, 100개로 늘어났을 때 “빌드가 통과했으면 데이터는 확실히 올바르다”는 확신을 가질 수 있다는 게 정말 큰 차이를 만듭니다.

Astro에 대해 더 알고 싶다면 Astro의 전체적인 개요를 살펴보시고, Zod 자체에 대해 깊이 알고 싶다면 Zod로 유효성 검증과 타입 선언의 두 마리 토끼 잡기도 함께 읽어보세요. Astro 공식 문서의 Content Collections 가이드에서 최신 API 변경사항도 확인할 수 있습니다.

This work is licensed under CC BY 4.0 CC BY

Discord