Astro Content Collections 완벽 가이드 | 타입 안전·스키마·MDX·블로그 구축
이 글의 핵심
Astro Content Collections로 타입 안전한 콘텐츠 관리 시스템을 구축하는 완벽 가이드. 스키마 정의, MDX, 블로그, 다국어, SEO까지 실전 예제로 정리. Astro·Content Collections·MDX 중심으로 설명합니다.
이 글의 핵심
Astro Content Collections로 타입 안전한 콘텐츠 관리 시스템을 구축하는 완벽 가이드입니다. 스키마 정의, MDX, 블로그, 다국어, SEO까지 실전 예제로 정리했으며, glob 고급 옵션·file loader·커스텀 loader, Zod 스키마 패턴(변환·판별 유니온·검증), getCollection·인덱스·getEntries 기반 쿼리 최적화, 프로덕션 운영·CI·초안 정책, 트러블슈팅까지 콘텐츠 시스템 전반을 다룹니다.
실무 경험 공유: 기술 블로그 플랫폼을 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,
};
(참고) Astro 4·5 권장: 프로젝트 루트의 content.config.ts
구버전은 src/content/config.ts 한 곳에 스키마를 두는 방식이었습니다. Astro 4 이후에는 프로젝트 루트의 content.config.ts(또는 src/content.config.ts)에 컬렉션을 정의하고, 아래에서 설명하는 loader 로 파일·외부 소스를 연결하는 패턴이 표준에 가깝습니다. 실제 서비스에서는 glob으로 src/content/blog/**/*.md 등을 묶거나, Headless CMS·DB에서 긁어오는 커스텀 loader를 붙이는 식으로 확장합니다.
심화 1. 콘텐츠 레이어: glob과 loader
Content Collections는 “마크다운 파일을 읽는다”는 표현보다, 빌드 시점에 엔트리 목록을 만들고 스키마로 검증한 뒤 astro:content로 노출한다는 쪽이 정확합니다. 그 첫 단추가 loader입니다.
glob loader (파일시스템)
astro/loaders의 glob()은 베이스 디렉터리 + glob 패턴으로 매칭되는 파일을 하나의 컬렉션으로 등록합니다. 파일 경로가 곧 엔트리 ID(슬러그 계산의 기준이 됨)에 대응되므로, 폴더 구조를 URL 설계와 맞추면 이후 쿼리가 단순해집니다.
// content.config.ts (예시)
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({
base: './src/content/blog',
pattern: '**/*.{md,mdx}',
}),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
}),
});
export const collections = { blog };
glob 고급: pattern 배열, generateId, retainBody
동일 컬렉션에 여러 확장자·경로 규칙을 쓰려면 pattern에 문자열 배열을 넘깁니다. 예를 들어 루트 글과 시리즈 하위 폴더만 포함하거나, 테스트용 *.draft.md를 제외하는 식으로 나눌 수 있습니다(프로젝트의 glob 구현에 따라 부정 패턴 지원 여부를 확인하세요).
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({
base: './src/content/blog',
pattern: ['**/*.{md,mdx}', '!**/node_modules/**'],
}),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
}),
});
generateId: 파일 경로가 URL 슬러그 규칙과 다를 때(예: en/post-name vs post-name) 엔트리 ID를 직접 만들 수 있습니다. ID는 getEntry('blog', id)·라우트 params와 맞아야 하므로, 한 번 정한 규칙을 문서화하는 것이 좋습니다.
loader: glob({
base: './src/content/blog',
pattern: '**/*.{md,mdx}',
generateId: ({ entry }) =>
entry
.replace(/\.mdx?$/, '')
.replace(/^en\//, 'en/'), // 예: 영문은 접두 유지
}),
retainBody: 마크다운 파서가 본문을 처리하는 타입에서, 렌더된 HTML 외 원문 문자열을 entry.body로 남길지를 제어합니다. 기본은 true이며, 메타만 쓰는 데이터 파이프라인에서 불필요하면 false로 메모리·직렬화 부담을 줄일 수 있습니다.
file loader: JSON·YAML로 엔트리 묶음 로드
astro/loaders의 file()은 단일 JSON·YAML·TOML 파일에서 엔트리를 읽습니다. 파일이 객체 배열이면 각 원소에 id 또는 slug가 있어야 하고, 문자열 키 객체면 키가 곧 엔트리 ID가 됩니다. Headless CMS에서 내보낸 스냅샷, 디자인 목업용 시드 데이터, A/B용 별도 목록을 컬렉션으로 올릴 때 유용합니다.
// src/content/data/posts.json
[
{ "id": "hello-world", "title": "Hello", "pubDate": "2026-04-01", "body": "# Hello\n\n..." },
{ "id": "second", "title": "Second", "pubDate": "2026-04-02" }
]
import { defineCollection, z } from 'astro:content';
import { file } from 'astro/loaders';
const imported = defineCollection({
loader: file('./src/content/data/posts.json'),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
body: z.string().optional(),
}),
});
export const collections = { imported };
스키마 필드와 JSON 필드가 다르면 z.preprocess로 CMS 필드명을 바꾼 뒤 검증하는 패턴이 흔합니다(아래 스키마 패턴 참고).
커스텀 loader (CMS·REST) — store·parseData 패턴
외부 API에서 글 목록을 받아 오려면 Loader 인터페이스를 구현하고, 컬렉션별 store에 parseData로 검증된 레코드를 넣습니다. parseData는 컬렉션에 정의한 Zod 스키마를 그대로 적용하므로, 원격 페이로드가 틀리면 빌드가 실패합니다. meta 저장소에는 마지막 동기화 시각·ETag 등을 넣어 디버깅에 쓸 수 있습니다.
// loaders/cms.ts
import type { Loader } from 'astro/loaders';
export function cmsPostsLoader(apiUrl: string): Loader {
return {
name: 'cms-posts',
async load({ store, parseData, logger, meta }) {
store.clear();
const res = await fetch(apiUrl);
if (!res.ok) {
logger.error(`CMS fetch failed: ${res.status}`);
return;
}
const rows = (await res.json()) as Array<Record<string, unknown>>;
for (const raw of rows) {
const id = String(raw.slug ?? raw.id ?? '');
if (!id) continue;
const data = await parseData({ id, data: raw });
store.set({ id, data });
}
meta.set('syncedAt', new Date().toISOString());
},
};
}
// content.config.ts (발췌)
import { defineCollection, z } from 'astro:content';
import { cmsPostsLoader } from './loaders/cms';
const cmsBlog = defineCollection({
loader: cmsPostsLoader('https://api.example.com/posts'),
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
}),
});
운영 시 유의: API 장애 시 빌드 전체가 막히므로, 타임아웃·재시도·캐시된 JSON 폴백을 두는 팀이 많습니다. 프리뷰 전용 URL과 프로덕션 URL을 환경 변수로 나누는 것도 일반적입니다.
언제 glob인가: Git으로 버전 관리하는 문서, 블로그, 문서 사이트처럼 저장소 안에 마크다운/MDX가 있는 경우가 전형적입니다. CI에서 검증·프리뷰 빌드하기도 쉽습니다.
트레이드오프(외부 소스): 소스가 외부이면 캐시·레이트 리밋·프리뷰 환경 변수 분기 등 운영 이슈가 늘고, “저장소만 보면 되는 glob”보다 파이프라인이 길어집니다. 반대로 편집 UI·워크플로는 CMS 쪽이 강합니다.
type: 'content'(구 방식)와의 정리
과거 예제의 defineCollection({ type: 'content', ... })는 디렉터리 규칙에 기대어 파일을 찾던 모델입니다. 지금은 loader로 데이터 소스를 명시하는 쪽이 확장성과 가독성 면에서 유리합니다. 기존 글·튜토리얼이 구 방식을 쓰더라도, 개념(스키마·getCollection)은 동일하니 마이그레이션 시 loader + 스키마만 맞추면 됩니다.
심화 2. 마크다운 프론트매터 파싱과 검증
파싱: YAML → 메타, 나머지 → 본문
마크다운/MDX 파일은 상단의 --- … --- 구간이 YAML 프론트매터, 그 아래가 본문입니다. 빌드 시 Astro는 이를 분리해, 프론트매터는 메타데이터 객체로, 본문은 MD/MDX 컴파일 파이프라인(remark/rehype, MDX의 경우 JSX 변환)으로 넘깁니다.
검증: Zod 스키마가 “단일 진실”
schema에 정의한 Zod 객체가 허용 필드·타입·기본값을 결정합니다. z.coerce.date()처럼 문자열로 적힌 날짜를 Date로 바꾸거나, z.enum으로 카테고리를 제한하는 식으로 편집 실수를 빌드 단계에서 차단합니다.
schema: z.object({
title: z.string(),
pubDate: z.coerce.date(),
tags: z.array(z.string()).default([]),
});
실무 팁: 팀에서 필드를 늘릴 때는 스키마를 먼저 고치고, 그다음 마크다운을 수정하면 역순으로 하면 생기는 런타임 불일치를 줄일 수 있습니다. 대규모 저장소에서는 npm run validate 같은 별도 프론트매터 검증 스크립트를 CI에 두는 경우도 많습니다(본 레포도 validate, validate:related-posts, check-links 등을 운용할 수 있음).
스키마 패턴 확장: transform, union, discriminatedUnion, refine, image()
필드가 늘수록 단순 z.object만으로는 부족해집니다. 아래는 실전에서 자주 쓰는 패턴입니다.
z.preprocess / z.transform: YAML 문자열이 "2026-04-18"처럼 들어오면 z.coerce.date()로 충분하지만, CMS가 "2026/04/18" 같은 형식을 쓰면 전처리로 통일한 뒤 z.date()에 넘기면 검증 메시지가 명확해집니다.
pubDate: z.preprocess(
(v) => (typeof v === 'string' ? v.replace(/\//g, '-') : v),
z.coerce.date()
),
z.union / z.discriminatedUnion: 레거시 글은 author: string, 신규는 author: { name: string; url?: string }처럼 형태가 두 갈래일 때 판별 필드(kind 등)를 두고 discriminatedUnion으로 안전하게 합칩니다.
const authorLegacy = z.object({ kind: z.literal('legacy'), name: z.string() });
const authorRich = z.object({
kind: z.literal('rich'),
name: z.string(),
url: z.string().url().optional(),
});
const author = z.discriminatedUnion('kind', [authorLegacy, authorRich]);
refine / superRefine: relatedPosts 배열에 중복 slug가 없는지, seriesOrder가 series가 있을 때만 필수인지 등 필드 간 제약을 걸 때 사용합니다.
relatedPosts: z
.array(z.string())
.optional()
.superRefine((arr, ctx) => {
if (!arr) return;
const seen = new Set<string>();
for (const slug of arr) {
if (seen.has(slug)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `중복 slug: ${slug}` });
}
seen.add(slug);
}
}),
schema: ({ image }) => z.object({ ... }) + image(): Astro가 제공하는 image()는 public/ 또는 src/ 이미지에 대한 메타와 최적화 파이프와 연동됩니다. OG·히어로 이미지처럼 반드시 이미지 자산으로 취급할 필드에 씁니다.
schema: ({ image }) =>
z.object({
title: z.string(),
heroImage: image().optional(),
thumbnail: z.string().optional(), // 외부 URL·임의 경로는 문자열로 유지
}),
중첩 객체·기본값: seo: z.object({ title: z.string(), description: z.string() }).optional()처럼 그룹화하면 프론트매터가 길어져도 의미 단위가 분명해집니다. faq는 z.array(z.object({ q: z.string(), a: z.string() })) 형태로 두면 구조화 데이터와도 맞추기 쉽습니다.
본문과 메타의 경계
프론트매터는 목록·SEO·라우팅에 쓰이고, 본문은 렌더 결과에 쓰입니다. post.render()로 얻는 Content는 본문만 담당하므로, 레이아웃에서 post.data.title과 <Content />를 나누어 쓰는 패턴이 명확합니다.
심화 3. MDX 컴파일과 컴포넌트 주입
MDX는 마크다운 문법에 JSX를 섞을 수 있게 합니다. Astro에서는 @astrojs/mdx 통합으로 빌드 시 MDX → JavaScript/컴포넌트 트리로 변환되고, 페이지 쪽에서 await post.render()로 삽입합니다.
import와 인라인 컴포넌트
MDX 파일 상단에서 Astro·React·Vue 등 컴포넌트를 가져와 본문 안에서 태그처럼 씁니다. React 등은 client:* 지시어로 하이드레이션 범위를 지정합니다(예: client:load).
---
title: 'Interactive Guide'
pubDate: 2024-04-22
tags: ['interactive']
---
import Button from '@/components/Button.astro';
import Counter from '@/components/Counter.tsx';
# Interactive Guide
<Button>클릭</Button>
<Counter client:load />
요소 매핑(MDX의 components)
MDX 설정에서 특정 HTML 태그를 컴포넌트로 치환할 수 있습니다. 예를 들어 모든 pre를 코드 하이라이트 래퍼로 바꾸면, 글마다 import 없이 일관된 스타일을 줄 수 있습니다. 이는 디자인 시스템·접근성 컴포넌트를 본문에 강제할 때 유용합니다.
성능 관점
MDX가 늘수록 빌드 시간과 번들에 포함되는 클라이언트 코드가 늘 수 있습니다. 정적 본문만 필요한 글은 .md로 두고, 인터랙션이 필요한 글만 .mdx로 분리하는 것이 일반적인 타협입니다.
심화 4. getCollection 쿼리와 최적화
getCollection('blog')는 해당 컬렉션의 엔트리 전부를 가져옵니다. 두 번째 인자로 predicate를 주면, Astro가 내부적으로 필터링에 활용할 수 있어 불필요한 엔트리를 줄이는 데 도움이 됩니다.
const published = await getCollection('blog', ({ data }) => !data.draft);
Predicate 설계: 비용과 의미
Predicate는 “이 엔트리를 컬렉션 결과에 포함할지”만 결정합니다. draft·locale·날짜 하한선처럼 자주 쓰는 조건을 여기 두면, 같은 컬렉션을 여러 페이지에서 부를 때 의도가 코드에 드러나고 중복 필터 로직이 줄어듭니다. 반면 태그별 상세 페이지처럼 파라미터에 따라 달라지는 조건은 getStaticPaths에서 한 번 집계하는 편이 낫습니다.
한 번만 정렬·한 번만 인덱스 구축
안티패턴: 모든 .astro 파일에서 getCollection → sort → filter를 각각 호출하면, 빌드 캐시가 있어도 가독성이 떨어지고 정렬 기준(예: pubDate vs updatedDate)이 파일마다 달라질 위험이 있습니다.
권장: 공통 유틸 모듈에 loadPublishedPosts()처럼 “발행 글 + 고정 정렬”을 캡슐화하거나, 상위 레이아웃·getStaticPaths에서 이미 정렬된 배열을 props로 넘깁니다.
태그·연도·시리즈처럼 키 → 글 목록 구조가 필요하면, 빌드 시 한 번만 Map을 만듭니다.
// lib/blog-index.ts
import { getCollection, type CollectionEntry } from 'astro:content';
type BlogEntry = CollectionEntry<'blog'>;
export function buildTagIndex(posts: BlogEntry[]) {
const byTag = new Map<string, BlogEntry[]>();
for (const post of posts) {
for (const tag of post.data.tags ?? []) {
const list = byTag.get(tag) ?? [];
list.push(post);
byTag.set(tag, list);
}
}
return byTag;
}
getStaticPaths에서는 const posts = await getCollection(...); const tagIndex = buildTagIndex(posts);처럼 단일 소스에서 파생 데이터를 만들고, 각 태그 페이지에는 tagIndex.get(tag)만 넘깁니다.
getEntries로 ID 목록만 해석
relatedPosts: string[]처럼 slug/id 목록만 프론트매터에 두었을 때, 글 본문에서 getCollection 전체를 두 번 훑지 않도록 getEntries로 필요한 엔트리만 가져올 수 있습니다(프로젝트의 astro:content 생성 타입에 맞게 호출).
import { getEntries } from 'astro:content';
// relatedSlugs: 프론트매터에 적어 둔 slug(파일 경로 기반 id와 동일한 경우가 많음)
const related = (await getEntries(
relatedSlugs.map((slug) => ({ collection: 'blog' as const, slug }))
)).filter((e): e is NonNullable<typeof e> => e != null);
엔트리 키가 id로만 관리되는 데이터 컬렉션이면 { collection: 'blog', id } 형태를 씁니다. ID가 잘못되었으면 getEntry가 undefined를 돌려주므로, validate:related-posts 같은 스크립트와 함께 쓰면 안전합니다.
관련 글 추천: O(n²) 회피
포스트마다 “전체 글 × 태그 교집합”을 다시 계산하면 글이 많아질수록 비용이 제곱에 가깝게 늡니다. 위의 태그 인덱스를 만들어 두면, 현재 글의 태그에 대해 후보 집합만 합치고 점수(동일 태그 개수, 최근 날짜)로 정렬하는 식으로 줄일 수 있습니다.
컬렉션 분리와 빌드 시간
글·릴리즈 노트·짧은 칼럼을 한 컬렉션에 모두 넣으면 getCollection 한 번이 무거워집니다. 성격이 다르면 컬렉션을 나누고(blog / changelog), 목록 페이지에서만 필요한 컬렉션을 부르면 빌드·타입 생성 범위도 분리됩니다. 같은 목록을 여러 페이지에서 가공하지 않도록, getStaticPaths에서 한 번 집계해 props로 내리는 점은 위 buildTagIndex 예시와 함께 적용하면 됩니다.
심화 5. 프로덕션 콘텐츠 패턴
운영 중인 블로그에서는 콘텐츠 품질·빌드 안정성·검색 노출이 동시에 걸립니다. 아래는 저장소 기반 Astro 사이트에서 자주 쓰는 패턴입니다.
초안·목록 노출·프리뷰 배포
draft: 목록·RSS·사이트맵에서 빼되, 직접 URL로 검수할 수 있게 두는 팀이 많습니다. 반대로 프로덕션에서도 초안 URL을 막으려면 미들웨어나 빌드 시 정적 HTML 생성 제외를 검토합니다.listInBlog등 메타 플래그: 시리즈 목차용 글은 본문은 필요하지만 목록에는 안 올리는 식으로,draft와 다른 축의 플래그를 둡니다(본 레포content.config.ts의listInBlog아이디어와 유사).- 프리뷰(스테이징):
PUBLIC_SHOW_DRAFTS=true같은 환경 변수로getCollectionpredicate를 바꿔, 프리뷰 배포에서만 초안 표시합니다. 프로덕션 빌드에서는 변수를 비활성화합니다.
const showDrafts = import.meta.env.PUBLIC_SHOW_DRAFTS === 'true';
const posts = await getCollection(
'blog',
({ data }) => showDrafts || !data.draft
);
스키마 진화와 마이그레이션
- 필드 추가는
optional()·default()로 기존 글을 깨지 않게 한 뒤, 점진적으로 프론트매터를 채웁니다. - 필드 이름 변경은
z.preprocess로 구·신 키를 한동안 병행 수용하거나,scripts/에 일괄 치환 스크립트를 두고 커밋을 나눕니다. relatedPosts·tags처럼 다른 글을 가리키는 값은 스키마만으로는 참조 무결성이 보장되지 않으므로,validate:related-posts·npm run tags같은 저장소 레벨 검증을 CI에 넣습니다.
에셋·OG·썸네일
- 히어로:
image()로 두면 빌드 파이프라인과 정렬되고, LCP 이미지 최적화에 유리합니다. - OG 정적 이미지:
thumbnail을 문자열 URL로 두고public/og/...에 미리 생성한 WebP를 가리키는 방식은 배포 캐시·CDN과 맞추기 쉽습니다. - 일관된 규칙: 파일명·slug·OG 파일명을 한 규칙(예: slug와 동일한 베이스 이름)으로 묶어 두면 누락을 줄일 수 있습니다.
CI 파이프라인에 넣기 좋은 검사
| 단계 | 예시 | 효과 |
|---|---|---|
| 프론트매터 | npm run validate | 스키마 전에 팀 규칙(필수 필드·날짜 형식) 검사 |
| 내부 링크 | npm run check-links | 깨진 앵커·상대 경로 조기 발견 |
| 관련 글 | npm run validate:related-posts | 존재하지 않는 slug 참조 방지 |
| BOM/인코딩 | npm run strip-bom (빌드 전) | Windows 편집기로 생긴 BOM으로 인한 파싱 실패 완화 |
프로젝트에 스크립트 이름이 다를 수 있으므로, 자신의 package.json에 맞게 치환하면 됩니다.
빌드 시간·메모리·증분
- 글이 수천 개 이상이면 클린 빌드 시간·Node 메모리가 병목이 됩니다.
ASTRO_BUILD_CONCURRENCY조정,build:incremental같은 증분 빌드 스크립트, 캐시 클리어 정책을 README에 적어 두면 온콜 대응이 쉬워집니다. - MDX 비중이 크면 클라이언트 번들도 함께 커지므로, 인터랙션 글만 MDX로 두는 확장 전략이 비용 대비 효과가 큽니다.
보안·비밀 정보
- 프론트매터는 저장소에 평문으로 남습니다. API 키·내부 URL은 절대 넣지 않고, 환경 변수 + 서버 엔드포인트로 처리합니다.
- 커스텀 loader가 외부 API를 부를 때는 토큰을 CI 시크릿에만 두고, 로그에 URL·응답 본문이 찍히지 않게 합니다.
3. 콘텐츠 작성
마크다운
---
title: 'Getting Started with Astro'
description: 'Learn how to build fast websites with Astro'
pubDate: 2024-04-22
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. Astro Content Collections 완벽 가이드에 대한 완전한 가이드입니다. 실전 예제와 함께 핵심 개념부터 고급 활용까지 다룹니다.'
pubDate: 2024-04-22
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>
필터링 및 정렬
predicate로 서버에서 한 번 걸러 온 뒤, 메모리에서 sort·filter를 조합합니다(태그 전용 목록은 아래 동적 라우팅 절의 getStaticPaths 예시가 더 적합합니다).
---
import { getCollection } from 'astro:content';
const featuredPosts = await getCollection('blog', ({ data }) => data.featured && !data.draft);
const sortedByDate = featuredPosts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const tag = Astro.params.tag as string;
const forTag = await getCollection('blog', ({ data }) => data.tags.includes(tag));
---
<ul>
{sortedByDate.map((post) => (
<li>{post.data.title}</li>
))}
</ul>
<p>
태그 <strong>{tag}</strong> 글 수: {forTag.length}
</p>
단일 조회
---
// 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, 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();
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. 다국어 지원
TypeScript/JavaScript 예제 코드입니다.
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
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(),
}),
});
---
import { getCollection, getEntry } from 'astro:content';
const { slug } = Astro.params;
const currentPost = await getEntry('blog', slug!);
if (!currentPost) {
return Astro.redirect('/404');
}
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의 타입 안전 콘텐츠 관리
- Loader:
glob으로 저장소 기반 엔트리를 로드하거나, 커스텀 loader로 CMS·API와 연결 - Zod 스키마: 프론트매터 검증과 기본값·강제 타입으로 빌드 단계에서 오류 차단
- 파이프라인: YAML 메타 → Zod, 본문 → MD/MDX 컴파일 →
render()로 삽입 - MDX: JSX·컴포넌트·
client:*지시어; 요소 매핑으로 본문 일관성 유지 getCollection: predicate로 필터링,getStaticPaths에서 한 번 집계해 props로 전달하면 중복 연산 감소- 프로덕션: draft 정책, 스키마 마이그레이션, CI 검증, 증분 빌드·캐시 전략
- 쿼리: predicate·인덱스·
getEntries로 불필요한 전체 스캔·O(n²) 회피 - 트러블슈팅: 날짜/YAML·slug·MDX import·loader 빈 데이터
구현 체크리스트
-
content.config.ts에 컬렉션·loader(glob·file·커스텀 등) 정의 - Zod 스키마로 필수 필드·날짜·태그 검증(
preprocess·refine필요 시) - 마크다운/MDX 콘텐츠 작성 규칙(프론트매터) 팀 합의
- 동적 라우팅·
getStaticPaths에서 목록 재사용 설계 - 태그·연도·시리즈 인덱스를 한 번 구축해 재사용
-
relatedPosts는getEntries또는 검증 스크립트로 무결성 확보 - 페이지네이션·태그·RSS
- CI에서 프론트매터·링크·관련 글 검증(선택)
- SEO·다국어·초안 노출 정책
같이 보면 좋은 글
- Astro 블로그 완벽 가이드
- Next.js 15 완벽 가이드
- MDX 완벽 가이드
이 글에서 다루는 키워드
Astro, Content Collections, MDX, TypeScript, Blog, CMS, Static Site
심화 6. 트러블슈팅 (자주 막히는 지점)
스키마 검증 오류: “Expected X, received Y”
- 날짜: YAML에서
pubDate: '2025-08-28'은 숫자로 파싱될 수 있습니다.따옴표로 문자열 고정**(’2026-04-18’)**하거나z.coerce.date()`를 사용합니다. - 빈 값: 필드를 비워 두면
""가 들어와z.string().min(1)에서 실패합니다. 편집기 스니펫으로 필수 필드를 항상 채우거나, 빈 문자열을 허용할지 스키마에서 명시합니다. - 배열 vs 문자열:
tags: foo처럼 한 줄만 쓰면 문자열이 되므로,z.array(z.string())와 맞지 않습니다.tags: [foo]또는tags: [foo, bar]형태로 통일합니다.
getEntry / getEntries가 undefined
- slug vs id: 파일 경로 기반 슬러그와 프론트매터
slug오버라이드가 섞이면 기대한 키로 찾지 못합니다.astro:content생성 타입과 실제post.slug를 한 페이지에서console.log로 확인해 단일 규칙으로 맞춥니다. - 컬렉션 이름 오타:
getEntry('bolg', slug)처럼 한 글자만 틀려도 조용히 실패할 수 있으므로, TypeScriptCollectionKey를 활성화해 컴파일 타임에 잡습니다.
glob이 파일을 못 찾음
base경로:./src/content/blog처럼 프로젝트 루트 기준인지 확인합니다. 상대 경로가 어긋나면 빈 컬렉션이 됩니다.pattern: 확장자 대소문자(.MD)·숨김 폴더·node_modules제외 규칙을 점검합니다. 필요하면 매칭 파일 목록을 쉘에서glob으로 먼저 확인합니다.
MDX: import 경로·컴포넌트 오류
- 별칭(
@/)은tsconfig·Vite 설정과 맞아야 합니다. 빌드만 실패하고 에디터는 통과하는 경우가 많으므로astro build로그의 첫 스택을 기준으로 합니다. - 클라이언트 컴포넌트에
client:*를 빼면 인터랙션이 동작하지 않고, 반대로 불필요한 하이드레이션은 번들만 키웁니다.
커스텀 loader: 빌드는 되는데 데이터가 비어 있음
store.clear()후 set 누락: 루프 중continue로 건너뛴 항목이 많지 않은지,id추출 실패로 전부 스킵되지 않았는지 로그를 추가합니다.- API 실패:
fetch가 4xx/5xx일 때 조용히 return하는 패턴은 디버깅을 어렵게 하므로, 최소한logger.error와 비제로 exit 코드를 남깁니다.
성능: 빌드가 갑자기 느려짐
- 새 MDX 다수 추가: 클라이언트 의존성·큰 의존성 트리를 끌어온 컴포넌트가 원인인 경우가 많습니다. 해당 글만 분리해 빌드 시간을 비교합니다.
- 거대
getCollection후처리: 매 페이지에서 전체 배열을 정렬·중복 계산하면 비용이 누적됩니다. 위 심화 4의 인덱스·유틸 캡슐화를 적용합니다.
자주 묻는 질문 (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. 빌드 타임에 모든 콘텐츠를 처리하므로 런타임 성능은 매우 빠릅니다. 다만 글 수가 매우 많아지면 빌드 시간이 늘 수 있으므로, getCollection 호출을 페이지마다 중복 가공하지 않도록 설계하고, 필요 시 컬렉션 분리·증분 빌드를 검토합니다.
Q. glob loader와 커스텀 loader 중 무엇을 써야 하나요?
A. 마크다운을 Git으로 관리한다면 glob이 단순하고 재현성이 좋습니다. 편집·승인 워크플로가 CMS에 있다면 커스텀 loader로 원격 데이터를 엔트리로 맞추면 됩니다. 타입 검증은 두 경우 모두 Zod로 통일할 수 있습니다.
Q. 프론트매터는 어디까지가 스키마 검증 대상인가요?
A. --- 구간의 YAML 필드가 스키마의 대상이며, 본문은 마크다운/MDX 파이프라인으로 처리됩니다. 날짜·enum·배열 기본값 등은 스키마에서 정의하는 것이 안전합니다.
Q. glob 대신 file loader를 쓰면 어떤 경우가 좋나요?
A. 마크다운 파일 대신 JSON/YAML로 엔트리 목록을 한 파일에서 관리할 때(예: CMS 내보내기 스냅샷, 시드 데이터, 실험용 목록) 적합합니다. Git 기반 글 작성은 여전히 glob이 편집 diff와 충돌 해결 면에서 유리합니다.
Q. getCollection으로 전부 가져온 뒤 필터링하면 안 되나요?
A. 작은 규모에서는 문제없습니다. 글이 많아지면 predicate로 1차 제한하고, 태그·관련 글은 인덱스·getEntries로 후보를 줄이는 편이 빌드 시간과 코드 복잡도 모두에 유리합니다.
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「Astro Content Collections 완벽 가이드 | 타입 안전·스키마·MDX·블로그 구축」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「Astro Content Collections 완벽 가이드 | 타입 안전·스키마·MDX·블로그 구축」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정 불일치 | 프로필·시크릿·기본값, 리전 | 스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.