Astro 콘텐츠 컬렉션 스키마 검증 — 타입 안전하게 글 발행 파이프라인 만들기
Astro 콘텐츠 컬렉션의 defineCollection과 zod 스키마로 frontmatter 검증을 자동화하고, 빌드 시 타입 에러로 잘못된 메타데이터를 잡는 실전 설정법.

Astro 3.0부터 도입된 콘텐츠 컬렉션(src/content/)은 마크다운/MDX 파일을 타입 안전하게 다루는 기능을 제공한다. 그런데 기본 템플릿만 따라 하면 defineCollection에 스키마를 적당히 넣고 끝내기 쉽다. 실제 운영에서 빌드 실패 원인의 30% 이상이 frontmatter 스키마 위반이었고, 이걸 런타임이 아닌 빌드 타임에 잡으려면 zod 스키마를 어떻게 짜야 하는지 현장에서 부딪히며 정리한 내용이다.
사실: 스키마 없이 발행하다 터진 케이스
지난달 interests 카테고리 글을 올리려다 npm run build가 빨간불이 켜졌다. 에러 메시지는:
[ERROR] Content validation error in "src/content/posts/new-post.md":
- Expected string, received undefined at "heroAlt"
- Expected array, received string at "tags"
- Expected date, received string at "pubDate"
원인은 단순했다. heroAlt를 빼먹었고, tags를 배열이 아닌 콤마 문자열로 적었으며, pubDate를 ISO 문자열이 아닌 2026-08-05 형식으로 적었다. Astro 기본 템플릿의 defineCollection은 type: 'content'만 지정하고 스키마를 생략하는 경우가 많은데, 이러면 frontmatter 검증이 전혀 안 된다. 파일이 빌드에 포함되고 나서야 타입 에러가 터지는데, 이미 커밋·푸시된 뒤라 롤백 비용이 크다.
이유: zod 스키마가 빌드 파이프라인의 게이트가 되려면
Astro 콘텐츠 컬렉션은 defineCollection({ schema: z.object({...}) }) 형태로 zod 스키마를 받는다. 이 스키마는 빌드 시점에 모든 파일을 파싱하며 검증한다. 검증이 실패하면 빌드가 멈추고, CI/CD 파이프라인(깃허브 액션, Cloudflare Pages 빌드 등)도 함께 멈춘다. 즉 배포 전 게이트 역할을 한다.
문제는 스키마를 “대충” 짜면 검증이 헛돌거나 너무 엄격해져서 정상 글도 막아버린다는 점이다. 현장에서 겪은 대표적인 함정 세 가지:
- 날짜 파싱:
z.date()를 쓰면 ISO 문자열(2026-08-05T10:00:00+09:00)만 통과하고,2026-08-05같은 날짜만 있는 문자열은 떨어뜨린다. 실제 글쓰기엔z.string().datetime({ offset: true }).or(z.string().date())처럼 유연하게 받아야 한다. - 배열 vs 문자열:
tags를z.array(z.string())로만 두면"tag1, tag2"문자열을 넣었을 때 에러 메시지가 “Expected array, received string”으로만 나와서 원인을 바로 알기 어렵다.z.string().transform(s => s.split(',').map(t => t.trim())).pipe(z.array(z.string()))처럼 변환을 태우면 둘 다 받아들인다. - 선택 필드 기본값:
heroImage는 필수지만updatedDate는 선택이다.z.string().url().optional()로 두면 undefined가 들어와도 통과하지만,relatedSlugs는 빈 배열 기본값이 낫다.z.array(z.string()).default([])로 주면 빠뜨려도 빌드가 안 터진다.
평가: 엄격함과 편의성 사이의 실전 균형
우리 블로그에 적용 중인 src/content/config.ts 스키마 핵심만 추리면 이렇다:
import { defineCollection, z } from 'astro:content';
const posts = defineCollection({
type: 'content',
schema: z.object({
title: z.string().min(1),
description: z.string().min(1),
pubDate: z.string().datetime({ offset: true }).or(z.string().date()),
updatedDate: z.string().datetime({ offset: true }).or(z.string().date()).optional(),
category: z.enum(['it-trials', 'interests', 'finance', 'healthcare', 'tennis', 'sports', 'media']),
tags: z.union([
z.array(z.string()),
z.string().transform(s => s.split(',').map(t => t.trim()))
]).pipe(z.array(z.string())),
heroImage: z.string().url(),
heroAlt: z.string().min(1),
relatedSlugs: z.array(z.string()).default([]),
}),
});
export const collections = { posts };
이 설정으로 빌드 시 검증되는 것들:
category가 7개 슬러그 중 하나여야 함(오타 방지)heroImage가 반드시 유효한 HTTPS URL이어야 함(로컬 경로·상대 경로 차단)tags가 배열이든 콤마 문자열이든 정규화되어 배열로 들어옴pubDate/updatedDate가 ISO 또는 날짜 문자열이면 모두 통과relatedSlugs를 안 쓰면 빈 배열로 자동 채워짐
반면 일부러 검증하지 않는 것도 있다. title/description 길이에 상한을 두지 않았다(SEO 가이드라인은 권장이지만 빌드에서 막을 이유는 없다). heroAlt에 라이선스 표기 강제도 하지 않았다(휴먼 리뷰 단계에서 확인).
부연: 스키마 에러 메시지를 개발자가 바로 읽게 하기
zod 기본 에러 메시지는 ["tags"]: Expected array, received string 같은 식이라 어느 필드가 문제인지 한눈에 안 들어온다. z.custom()이나 .refine()으로 커스텀 메시지를 달아두면 CI 로그에서 바로 보인다:
heroImage: z.string().url({ message: 'heroImage는 반드시 https://로 시작하는 절대 URL이어야 합니다' }),
category: z.enum(CATEGORIES, { errorMap: () => ({ message: `category는 ${CATEGORIES.join(', ')} 중 하나여야 합니다` }) }),
이렇게 해두면 빌드 실패 시 “heroImage는 반드시 https://로 시작하는 절대 URL이어야 합니다”라는 문장이 로그에 찍혀서, 파일을 열지 않고도 원인을 알 수 있다.
또 하나, 로컬에서 astro check만 돌려도 타입 에러를 잡을 수 있다. package.json에 "check": "astro check" 스크립트를 넣고 커밋 전 훅(husky + lint-staged)에 걸면 푸시 전에 잡힌다. 우리 레포에선 pre-commit 훅으로 npm run check를 돌리는데, 이 덕분에 배포 빌드에서 frontmatter 에러로 실패한 적이 없다.
출처
- 내부 실측:
npm run build실패 로그,astro check출력,src/content/config.ts실제 설정 - Astro 공식 문서 - Content Collections Schema — 열람: 2026-08-05
- zod 공식 문서 - String validation — 열람: 2026-08-05