Astro Content Collections 심화 가이드 — 스키마·타입 안정성·i18n·동적 라우팅
이 글의 핵심
Content Collections는 Markdown·MDX를 타입 안전하게 다루는 Astro의 핵심 기능입니다. 이 글에서는 Zod 스키마 설계, CollectionEntry 타입 활용, locale·originalId 기반 다국어, [...slug] 동적 라우팅, 클라이언트 검색·서버 필터, 빌드·런타임 최적화, 블로그·문서 사이트 실전 패턴을 체계적으로 정리합니다.
이 글의 핵심
Astro Content Collections는 정적 콘텐츠를 “코드베이스의 일급 데이터”로 취급하게 해 줍니다. 파일 시스템에 놓인 Markdown·MDX를 스키마로 검증하고, TypeScript가 프론트매터 필드를 추론하며, getCollection·getEntry로 일관된 API만 노출합니다. 이 글은 입문 튜토리얼을 넘어, 프로덕션 블로그·문서 사이트에서 마주치는 타입 설계, 다국어, 라우팅, 검색, 성능 이슈를 한 흐름으로 묶습니다.
독자는 다음을 기대할 수 있습니다.
- 컬렉션·엔트리·레퍼런스의 역할 관계와 빌드 타임 동작
- Zod 스키마를 통한 검증 전략과
CollectionEntry제네릭 활용 locale·originalId등 메타데이터로 번역 쌍을 관리하는 패턴[...slug]·중첩 폴더와getStaticPaths통합- 목록·태그·전문 검색·JSON API를 통한 필터링
- 이미지·MDX·부분 컴파일 관점의 최적화
- 실무에서 자주 쓰는 블로그·문서 사이트 아키텍처
1. Content Collections의 핵심 개념
1.1 왜 컬렉션인가
Markdown만 써도 블로그는 만들 수 있습니다. 그러나 글이 수십·수백 개가 되면 프론트매터 누락, 필드 이름 불일치, 날짜 형식 오류 같은 문제가 누적됩니다. Content Collections는 이런 오류를 빌드 타임에 잡아, 배포 후에야 터지는 런타임 버그를 줄입니다.
정리하면 컬렉션은 다음을 제공합니다.
- 단일 진실 공급원:
src/content/<이름>/아래 파일이 곧 데이터 소스 - 스키마 검증: Zod로 필드 타입·기본값·변환 규칙 명시
- 타입 추론:
astro:content의CollectionEntry로 자동 완성 - 로더 추상화:
glob등으로 디렉터리 패턴과 확장자를 선언적으로 지정
1.2 컬렉션·엔트리·레퍼런스
- 컬렉션(collection): 논리적 그룹. 예:
blog,docs,changelog. - 엔트리(entry): 컬렉션 안의 한 파일에 대응.
id,data(프론트매터),body(본문) 등을 가집니다. - 레퍼런스(reference): 한 엔트리가 다른 엔트리를 가리킬 때 스키마에서
reference()로 연결할 수 있습니다(버전·프로젝트 설정에 따라 사용 가능 여부 확인).
프로젝트마다 content.config.ts 위치와 로더 문법이 Astro 메이저 버전에 따라 달라질 수 있으므로, 공식 문서의 “Content Collections” 절을 기준으로 맞추는 것이 안전합니다.
1.3 빌드 타임 vs 런타임
SSG에서는 getCollection이 빌드 시 실행되어 HTML·JSON이 생성됩니다. 즉 “DB 쿼리”에 해당하는 필터·정렬 대부분은 빌드 한 번의 비용으로 끝납니다. 반면 브라우저에서 전체 본문을 내려받아 검색하는 방식은 페이로드와 초기 로딩 트레이드오프가 있으므로, 6장에서 검색 전략을 나눕니다.
2. 스키마 정의와 타입 추론
2.1 Zod 스키마의 역할
Zod 스키마는 단순 타입 표기가 아니라 런타임 검증기입니다. 잘못된 날짜 문자열, 빈 태그 배열, 허용되지 않은 level 값 등을 빌드에서 거절합니다.
설계 시 권장 사항은 다음과 같습니다.
- 필수 필드는 최소화하고, 나머지는
.optional()·.default()로 완화해 기존 글 마이그레이션 비용을 줄입니다. - 날짜는
z.coerce.date()처럼 문자열·Date 모두 수용해 프론트매터 작성 실수를 흡수합니다. - 열거형은
z.enum()으로 UI·필터와 동일한 값 집합을 고정합니다. - 이미지는
schema: ({ image }) => ...형태로image()헬퍼를 쓰면 최적화 파이프라인과 연동하기 좋습니다.
2.2 Astro 5 스타일 content.config.ts 예시
아래는 blog 컬렉션을 glob 로더로 읽고 Zod로 검증하는 최소 예시입니다. 실제 프로젝트의 필드는 조직 규칙에 맞게 확장하면 됩니다.
// 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: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: image().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
locale: z.enum(['ko', 'en']).default('ko'),
originalId: z.string().optional(),
level: z.enum(['초급', '중급', '고급']).optional(),
category: z.string().optional(),
readingMinutes: z.number().optional(),
}),
});
export const collections = { blog };
이 스키마가 있으면 getCollection('blog')의 각 항목에서 data.title 등이 항상 존재한다고 TypeScript가 이해합니다. 프론트매터에 title이 없으면 빌드 실패로 바로 드러납니다.
2.3 CollectionEntry와 유틸 타입
타입을 명시적으로 다룰 때는 CollectionEntry를 사용합니다.
import type { CollectionEntry } from 'astro:content';
type BlogEntry = CollectionEntry<'blog'>;
function summarize(post: BlogEntry): string {
return post.data.description;
}
BlogEntry['data']는 Zod 스키마에서 추론된 프론트매터 타입과 정렬됩니다. 공용 헬퍼(날짜 포맷, 읽기 시간 표시, OG 태그 생성)를 한 곳에 모을 때 특히 유용합니다.
2.4 스키마 진화와 마이그레이션
필드를 추가할 때는 다음 순서를 권장합니다.
- Zod에 optional 또는 default로 추가 → 기존 파일 전부 통과
- 콘텐츠를 점진적으로 채움
- 안정화 후 필수 필드로 승격(필요 시)
한 번에 필수로 바꾸면 수백 개의 Markdown을 동시에 고쳐야 할 수 있습니다. 점진적 엄격화가 대규모 블로그에서 덜 고통스럽습니다.
3. 다국어 콘텐츠 관리
3.1 폴더 구조 전략
흔한 패턴은 세 가지입니다.
| 방식 | 장점 | 주의 |
|---|---|---|
파일명 접미사 (post-en.md) | 슬러그 분리가 명확 | 동일 주제 매핑을 메타로 관리해야 함 |
하위 디렉터리 (en/, ko/) | 로케일별 운영 분리 | glob 패턴·URL 설계 필요 |
단일 파일 + 필드 (locale: en) | 파일 수 적음 | 동일 슬러그 충돌 시 규칙 필요 |
이 사이트처럼 locale·originalId를 스키마에 두면, 번역본이 원본을 가리키게 하여 목록·스위처 UI를 만들기 쉽습니다.
3.2 originalId로 번역 쌍 연결
예를 들어 한국어 원본 my-post.md와 영문 my-post-en.md가 있을 때, 영문 글에 originalId: 'my-post'를 두면 UI에서 “한국어 보기” 링크를 생성할 수 있습니다. slug 생성 규칙은 프로젝트의 [...slug].astro와 일치시켜야 합니다.
3.3 쿼리 패턴
특정 언어만 가져오려면 getCollection의 필터를 사용합니다.
import { getCollection } from 'astro:content';
const koPosts = await getCollection('blog', ({ data }) => data.locale === 'ko' && !data.draft);
영문 전용 라우트(/en/blog/...)에서는 동일하게 locale === 'en'을 적용합니다. 라우트·필터·스키마의 locale 값이 어긋나지 않도록 팀 규칙을 문서화하는 것이 좋습니다.
4. 동적 라우팅 통합
4.1 [...slug]와 정적 경로 생성
블로그 상세 페이지는 보통 src/pages/blog/[...slug].astro 형태입니다. getStaticPaths에서 컬렉션을 읽어 모든 엔트리에 대한 경로를 생성합니다.
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
post.id는 로더와 파일 경로에 따라 문자열 형태가 정해집니다. URL에 쓰기 좋은 id 규칙(확장자 제거, 중첩 경로 허용 여부)을 초기에 고정하세요.
4.2 커스텀 slug 필드
프론트매터에 slug를 두고 URL을 덮어쓰는 경우, getStaticPaths의 params.slug는 그 필드를 우선해야 합니다. 파일 경로에 ftp 같은 예약어가 있어 문제가 될 때(일부 환경) 유용합니다.
4.3 중첩 문서 트리
문서 사이트는 docs/getting-started/install.md처럼 깊은 트리를 쓰는 경우가 많습니다. post.id가 경로 형태면 사이드바 네비게이션을 같은 id 기준으로 생성할 수 있습니다. 부모-자식 관계는 series·order 같은 필드를 스키마에 추가해 정렬합니다.
5. 검색과 필터링
5.1 빌드 타임 필터
목록 페이지에서 태그·카테고리·난이도별로 나눌 때는 getCollection 두 번째 인자로 조건을 좁힙니다.
const frontend = await getCollection(
'blog',
(e) => e.data.category === 'frontend' && !e.data.draft
);
정렬은 sort로 pubDate 기준 내림차순 등을 적용합니다. 모든 목록이 같은 정렬 규칙을 쓰도록 헬퍼 함수로 빼면 UX가 일관됩니다.
5.2 JSON API로 클라이언트 검색
글 본문 전체를 브라우저에서 검색하려면, 빌드 시 요약·본문 일부를 담은 posts.json을 생성하는 방식이 흔합니다. Astro에서는 src/pages/api/blog/posts.json.ts 같은 엔드포인트에서 getCollection으로 데이터를 만들고 JSON.stringify로 내려줄 수 있습니다(프로젝트가 정적 호스팅이면 빌드 산출물로 포함).
클라이언트 측에서는:
- 간단한 부분 문자열 검색: 구현 쉬움, 한글 형태소에는 한계
- Fuse.js·MiniSearch 등 경량 인덱스: 중간 규모 사이트에 적합
- Pagefind: 빌드 후 인덱싱하는 정적 검색 도구로 Astro와 자주 조합
대규모 문서는 Algolia·Typesense 같은 외부 검색을 고려합니다.
5.3 태그·RSS
태그 페이지는 getCollection 후 flatMap으로 태그 집합을 만들거나, 빌드 스크립트로 태그 인덱스를 사전 계산합니다. RSS는 rss 패키지 등으로 피드를 만들 때 동일한 컬렉션 쿼리를 재사용하면 목록·피드·사이트맵이 서로 어긋나지 않습니다.
6. 성능 최적화
6.1 불필요한 본문 로딩
목록 카드에는 제목·설명·날짜만 필요한 경우가 많습니다. getCollection은 기본적으로 엔트리 메타를 가져오지만, MDX 렌더 비용이 큰 프로젝트에서는 목록용 경량 데이터만 별도 컬렉션으로 쪼개는 패턴도 있습니다(예: blogMeta vs blog).
6.2 이미지
image() 스키마와 getImage를 사용하면 이미지 최적화·반응형 srcset 생성이 쉬워집니다. 히어로 이미지가 큰 글에서는 LCP에 유의해 loading·decoding·우선순위 힌트를 조정합니다.
6.3 MDX 컴포넌트 범위
MDX에서 무거운 컴포넌트를 모두 전역 등록하면 번들이 커질 수 있습니다. 문서별·섹션별로 필요한 컴포넌트만 주입하거나, 일반 Markdown으로 충분한 글은 .md로 유지해 파싱 비용을 줄입니다.
6.4 증분 빌드·CI
모노레포나 대형 콘텐츠에서는 캐시 전략이 중요합니다. 콘텐츠만 바뀌었을 때 전체 파이프라인이 불필요하게 길어지지 않게, CI에서 콘텐츠 해시 기반 캐시를 검토합니다.
7. 실전 블로그·문서 사이트 패턴
7.1 블로그
- 일관된 프론트매터:
title,description,pubDate,tags,draft는 필수에 가깝게 - 시리즈:
series,seriesOrder로 이전/다음 글 네비게이션 - 관련 글: 태그 겹침 점수 또는
relatedPosts수동 큐레이션 - 캐노니컬·hreflang: 다국어일 때 메타 태그로 중복 검색 문제 완화
7.2 문서 사이트
- 버전 디렉터리:
docs/v1/,docs/v2/로 스키마에version필드 - 페이지 타입:
type: 'guide' | 'api' | 'changelog'로 레이아웃 분기 - 목차: 본문 헤딩을 파싱해 ToC 생성(remark 플러그인 또는 MDX export)
7.3 CMS와 병행
Content Collections는 Git 기반 워크플로에 강하고, 비개발자 편집에는 Headless CMS가 나을 수 있습니다. 하이브리드로 CMS → 빌드 시 Markdown 생성하면 스키마 검증 이점은 유지하면서 편집 UX를 개선할 수 있습니다.
8. getEntry·단일 엔트리 패턴
목록이 아니라 하나의 slug만 필요할 때는 getEntry가 명확합니다. 예를 들어 “이전/다음 글” 네비게이션에서 이웃 id만 알고 있을 때입니다.
import { getEntry } from 'astro:content';
const prev = await getEntry('blog', 'some-slug');
if (prev) {
const { Content } = await prev.render();
}
getEntry는 존재하지 않는 id면 undefined에 가까운 동작을 하므로(버전별 시그니처 확인), 항상 분기합니다. 여러 컬렉션을 쓰는 사이트에서는 컬렉션 이름을 제네릭처럼 실수하지 않게 상수로 빼 두면 좋습니다.
9. 렌더링 파이프라인과 remark/rehype
Markdown·MDX는 파싱 전에 remark 플러그인(문법 확장, 수식, 코드 하이라이트)과 rehype 플러그인(HTML 변환, 헤딩 id 부여)을 거칩니다. Astro 설정에서 markdown 또는 MDX 통합 옵션으로 플러그인을 등록하면 모든 컬렉션 글에 일괄 적용됩니다.
실무 팁은 다음과 같습니다.
- 슬러그 안전한 헤딩 id: 한글 제목이 많으면 id 충돌·URL 이슈가 생기므로,
rehype-slug대체·접두 규칙을 팀에서 정합니다. - 외부 링크
rel:rehype-external-links등으로noopener을 강제하면 보안·SEO에 유리합니다. - 코드 블록: Shiki 테마·지원 언어 목록을 제한해 빌드 시간을 줄일 수 있습니다(미지원 언어는 plaintext로 떨어지며 경고가 날 수 있음).
컬렉션 본문만 다른 파이프라인을 쓰고 싶다면, blog는 .md, docs는 .mdx로 분리하거나 MDX에서 export const로 메타를 덧붙이는 식으로 나눕니다.
10. Pagefind·정적 전문 검색
클라이언트에 전체 본문을 내려 검색하는 방식은 글이 많아질수록 부담이 됩니다. Pagefind는 빌드 산출물(HTML)을 대상으로 인덱스를 생성하는 도구로, Astro와 잘 맞습니다. 흐름은 대략 다음과 같습니다.
astro build로 HTML 생성- Pagefind CLI로
dist를 인덱싱 - 검색 페이지에 Pagefind의 자바스크립트 UI 또는 API 연동
이 방식은 서버 없이 전문 검색에 가깝게 가면서, 데이터베이스나 유료 SaaS 없이 운영할 수 있습니다. 단점은 빌드 파이프라인 단계가 하나 늘고, 인덱스 크기가 커질 수 있다는 점입니다. 수천 글 이상이면 샤딩·카테고리별 인덱스를 검토합니다.
11. 트러블슈팅 체크리스트
- 빌드가 “스키마 오류”로 실패: 최근에 추가한 Markdown의 프론트매터 필드명·타입을 확인합니다.
- 타입이
any처럼 보임:content.config.ts가 저장되었는지, 컬렉션 이름 문자열이 오타 없는지 확인합니다. - 경로 404:
getStaticPaths의slug와 실제 파일id불일치를 의심합니다. - 번역 링크 깨짐:
originalId와 파일명 slug 규칙이 동일한지 검증합니다.
12. 정리
Content Collections는 콘텐츠를 데이터로 취급하게 해 주는 Astro의 중심 기능입니다. Zod 스키마로 안전성을 확보하고, CollectionEntry로 도메인 로직을 표현하며, locale·slug·시리즈 메타로 성장하는 블로그·문서에 맞게 확장할 수 있습니다. 검색·필터는 빌드 타임 집계와 클라이언트 인덱스의 균형을 맞추고, 이미지·MDX·API 설계까지 묶어야 사용자 체감 성능이 나옵니다.
이미 이 저장소처럼 content.config.ts에 blog 컬렉션이 정의되어 있다면, 새 글은 스키마를 통과하는 프론트매터만 맞추면 타입과 목록·RSS·태그 페이지에 자동으로 반영됩니다. 팀에서는 스키마 필드를 “공개 API”처럼 취급하고 변경 시 마이그레이션 노트를 남기는 것이 장기적으로 가장 비용이 적습니다.
부록: 최소 페이지 예시 (상세 렌더)
엔트리를 받아 본문을 렌더링할 때는 render를 사용합니다(MDX·Markdown 공통).
---
import { getCollection } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => !data.draft);
const post = posts[0];
const { Content } = await post.render();
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
실제 사이트에서는 레이아웃·목차·광고 슬롯·SEO 컴포넌트를 감싼 BlogPost.astro 레이아웃으로 분리하는 경우가 많습니다. 이 패턴은 마크업 일관성과 Core Web Vitals 측정 포인트 통일에 도움이 됩니다.