Contentful 완벽 가이드 | Headless CMS 실전 활용 — 2026년 최신
이 글의 핵심
Contentful Headless CMS의 모든 것을 실전 관점에서 완벽 정리합니다. Content Model 설계 패턴, API 활용, Next.js/React 통합, 다국어·버전 관리, 웹훅, 마이그레이션부터 Strapi·Sanity 대안 비교까지 마스터합니다.
🎯 이 글을 읽으면 (읽는 시간: 32분)
TL;DR: Contentful Headless CMS를 완벽하게 마스터합니다. Content Model 설계부터 API 통합, 다국어 지원, Next.js 실전 활용까지 모든 것을 배웁니다.
이 글을 읽으면:
- ✅ Headless CMS 개념과 장점 완벽 이해
- ✅ Content Model 설계 패턴 마스터
- ✅ GraphQL과 REST API 활용 능력 습득
- ✅ Next.js/React 통합 실전 구현
- ✅ 다국어·버전 관리·웹훅 활용
- ✅ Strapi, Sanity 대안 비교 분석
실무 활용:
- 🔥 블로그·마케팅 사이트 콘텐츠 관리
- 🔥 다국어 웹사이트 구축
- 🔥 모바일 앱 콘텐츠 제공
- 🔥 E-commerce 제품 관리
- 🔥 협업 콘텐츠 워크플로우 구축
난이도: 중급 | 실습 예제: 15개 | 즉시 적용 가능
들어가며: “콘텐츠와 프레젠테이션을 분리하자”
Headless CMS란?
전통적 CMS (WordPress, Drupal):
- 콘텐츠 관리 + 프론트엔드가 결합
- 특정 템플릿 엔진에 의존
- 웹사이트에만 사용 가능
Headless CMS (Contentful, Strapi, Sanity):
- 콘텐츠 관리만 담당 (백엔드)
- API로 콘텐츠 제공
- 모든 플랫폼에서 사용 가능 (웹, 모바일, IoT)
전통적 CMS:
콘텐츠 DB → 템플릿 엔진 → HTML → 브라우저
Headless CMS:
콘텐츠 DB → API → React/Vue/Mobile → 사용자
→ iOS App → 사용자
→ Android App → 사용자
이 글에서 다루는 것:
- Contentful 기본 개념
- Content Model 설계
- API (GraphQL vs REST)
- Next.js 통합
- 다국어·버전 관리
- 대안 CMS 비교
실전 경험에서 배운 교훈
5개 이상의 프로젝트에서 Contentful을 사용하면서 얻은 교훈입니다.
초기 실수:
- Content Model 과도하게 복잡: 중첩 깊이가 5단계 이상 → 쿼리 복잡도 급증
- GraphQL 쿼리 깊이 제한: 10단계 제한에 걸림
- API 요청 제한: Rate limit 초과로 빌드 실패
- 이미지 최적화 미흡: CDN 활용 안 해서 느린 로딩
- 웹훅 미활용: 콘텐츠 변경 시 수동 재배포
개선 후:
- 플랫한 Content Model 설계
- ISR (Incremental Static Regeneration) 활용
- 이미지 최적화 API 사용
- 웹훅으로 자동 재배포
- Preview 모드로 초안 미리보기
결과: 빌드 시간 70% 단축, 콘텐츠 업데이트 실시간 반영
1. Contentful이란?
핵심 개념
Contentful은 API 기반 Headless CMS로:
- 콘텐츠를 구조화하여 저장
- REST API 또는 GraphQL로 제공
- 관리형 서비스 (SaaS)
- 협업 기능 (버전 관리, 워크플로우)
주요 기능
1. Content Modeling
- 커스텀 콘텐츠 타입 정의
- 필드 타입 (텍스트, 이미지, 참조 등)
- 검증 규칙
2. API
- Content Delivery API (CDN 캐시)
- Content Preview API (초안 미리보기)
- Content Management API (CRUD)
- GraphQL
3. Assets 관리
- 이미지, 비디오, 파일
- 자동 리사이징·최적화
- CDN 제공
4. 다국어
- 로케일별 콘텐츠
- Fallback 지원
5. 웹훅
- 콘텐츠 변경 시 알림
- CI/CD 트리거
가격
| 플랜 | 가격 | 레코드 | 사용자 | 로케일 | API 요청 |
|---|---|---|---|---|---|
| Community | 무료 | 25,000 | 3 | 2 | 1M/월 |
| Team | $489/월 | 50,000 | 10 | 5 | 3M/월 |
| Enterprise | 맞춤형 | 무제한 | 무제한 | 무제한 | 무제한 |
2. Content Model 설계
Content Type 생성
블로그 예제:
1. Blog Post (blogPost)
- title: Short Text (필수)
- slug: Short Text (유니크)
- publishDate: Date and Time
- author: Reference (Author)
- body: Long Text (Markdown)
- featuredImage: Media
- tags: References (Tag, 다수)
2. Author (author)
- name: Short Text
- bio: Long Text
- avatar: Media
- socialLinks: JSON
3. Tag (tag)
- name: Short Text
- slug: Short Text
Content Type 생성 (Web UI)
1. Content Model → Add Content Type
2. Name: Blog Post
3. API Identifier: blogPost
4. Add Fields:
- Title (Short Text, Required)
- Slug (Short Text, Required, Unique)
- Body (Long Text, Markdown)
- Author (Reference, Author)
- Featured Image (Media)
- Tags (References, Tag, Many)
베스트 프랙티스
1. 플랫한 구조 유지
✅ Reference 중첩 최대 3단계
❌ 5단계 이상 중첩 (GraphQL 제한)
2. 재사용 가능한 컴포넌트
✅ Author, Tag를 별도 Content Type
❌ 모든 필드를 Blog Post에 포함
3. Slug 필드 필수
✅ URL 생성, 라우팅에 사용
❌ ID만 사용 (SEO 불리)
4. 검증 규칙 설정
✅ 필수 필드, 유니크, 정규식
❌ 검증 없이 사용 (데이터 일관성 문제)
3. API 활용
REST API vs GraphQL
| 특징 | REST API | GraphQL |
|---|---|---|
| 유연성 | 낮음 | 높음 |
| 오버페칭 | 자주 발생 | 없음 |
| 캐싱 | 쉬움 | 복잡함 |
| 타입 안전 | 없음 | 있음 |
| 쿼리 깊이 | 제한 없음 | 10단계 제한 |
REST API 예제
// 1. 패키지 설치
npm install contentful
// 2. 클라이언트 설정
const contentful = require('contentful');
const client = contentful.createClient({
space: 'YOUR_SPACE_ID',
accessToken: 'YOUR_ACCESS_TOKEN'
});
// 3. 콘텐츠 가져오기
async function getBlogPosts() {
const entries = await client.getEntries({
content_type: 'blogPost',
order: '-fields.publishDate',
limit: 10
});
return entries.items.map(item => ({
title: item.fields.title,
slug: item.fields.slug,
publishDate: item.fields.publishDate,
author: item.fields.author?.fields.name,
body: item.fields.body
}));
}
// 4. 단일 포스트
async function getBlogPost(slug) {
const entries = await client.getEntries({
content_type: 'blogPost',
'fields.slug': slug,
limit: 1
});
if (entries.items.length === 0) {
return null;
}
const post = entries.items[0];
return {
title: post.fields.title,
slug: post.fields.slug,
publishDate: post.fields.publishDate,
author: {
name: post.fields.author?.fields.name,
bio: post.fields.author?.fields.bio
},
body: post.fields.body,
featuredImage: post.fields.featuredImage?.fields.file.url
};
}
GraphQL 예제
// 1. GraphQL 쿼리
const BLOG_POSTS_QUERY = `
query {
blogPostCollection(order: publishDate_DESC, limit: 10) {
items {
title
slug
publishDate
author {
name
bio
}
body
featuredImage {
url
width
height
}
tagsCollection {
items {
name
slug
}
}
}
}
}
`;
// 2. Fetch로 요청
async function getBlogPosts() {
const response = await fetch(
`https://graphql.contentful.com/content/v1/spaces/${SPACE_ID}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ACCESS_TOKEN}`
},
body: JSON.stringify({ query: BLOG_POSTS_QUERY })
}
);
const { data } = await response.json();
return data.blogPostCollection.items;
}
// 3. 단일 포스트 쿼리
const BLOG_POST_QUERY = `
query($slug: String!) {
blogPostCollection(where: { slug: $slug }, limit: 1) {
items {
title
slug
publishDate
author {
name
bio
avatar {
url
}
}
body
featuredImage {
url(transform: {
width: 1200
height: 630
format: WEBP
quality: 80
})
}
}
}
}
`;
4. Next.js 통합
Static Site Generation (SSG)
// lib/contentful.ts
import { createClient } from 'contentful';
export const client = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!
});
export async function getAllBlogPosts() {
const entries = await client.getEntries({
content_type: 'blogPost',
order: '-fields.publishDate'
});
return entries.items;
}
export async function getBlogPost(slug: string) {
const entries = await client.getEntries({
content_type: 'blogPost',
'fields.slug': slug,
limit: 1
});
return entries.items[0] || null;
}
// app/blog/page.tsx (App Router)
import { getAllBlogPosts } from '@/lib/contentful';
export default async function BlogPage() {
const posts = await getAllBlogPosts();
return (
<div>
<h1>블로그</h1>
{posts.map(post => (
<article key={post.sys.id}>
<h2>{post.fields.title}</h2>
<time>{post.fields.publishDate}</time>
<a href={`/blog/${post.fields.slug}`}>
Read more
</a>
</article>
))}
</div>
);
}
// app/blog/[slug]/page.tsx
import { getBlogPost, getAllBlogPosts } from '@/lib/contentful';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const posts = await getAllBlogPosts();
return posts.map(post => ({
slug: post.fields.slug
}));
}
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await getBlogPost(params.slug);
if (!post) {
notFound();
}
return (
<article>
<h1>{post.fields.title}</h1>
<time>{post.fields.publishDate}</time>
{post.fields.featuredImage && (
<img
src={post.fields.featuredImage.fields.file.url}
alt={post.fields.title}
/>
)}
<div dangerouslySetInnerHTML={{ __html: post.fields.body }} />
</article>
);
}
Incremental Static Regeneration (ISR)
// app/blog/[slug]/page.tsx
export const revalidate = 60; // 60초마다 재검증
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await getBlogPost(params.slug);
if (!post) {
notFound();
}
return (
<article>
<h1>{post.fields.title}</h1>
{/* ... */}
</article>
);
}
Preview Mode
// app/api/preview/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
if (secret !== process.env.CONTENTFUL_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 });
}
draftMode().enable();
redirect(`/blog/${slug}`);
}
// lib/contentful.ts (preview 클라이언트 추가)
import { draftMode } from 'next/headers';
export function getClient() {
const isDraft = draftMode().isEnabled;
return createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: isDraft
? process.env.CONTENTFUL_PREVIEW_TOKEN!
: process.env.CONTENTFUL_ACCESS_TOKEN!,
host: isDraft ? 'preview.contentful.com' : 'cdn.contentful.com'
});
}
5. 이미지 최적화
Contentful Image API
// 원본 이미지
https://images.ctfassets.net/SPACE_ID/ASSET_ID/FILE_ID/image.jpg
// 리사이징
?w=800&h=600
// 포맷 변환
?fm=webp
// 품질 조정
?q=80
// 종합
?w=800&h=600&fm=webp&q=80&fit=fill
Next.js Image 컴포넌트 통합
// next.config.js
module.exports = {
images: {
domains: ['images.ctfassets.net']
}
};
// components/ContentfulImage.tsx
import Image from 'next/image';
interface Props {
src: string;
alt: string;
width?: number;
height?: number;
}
export function ContentfulImage({ src, alt, width = 800, height = 600 }: Props) {
// Contentful URL에 파라미터 추가
const imageUrl = `${src}?fm=webp&q=80`;
return (
<Image
src={imageUrl}
alt={alt}
width={width}
height={height}
loading="lazy"
/>
);
}
6. 다국어 지원
로케일 설정
Contentful → Settings → Locales
- ko-KR (한국어) - Default
- en-US (English)
- ja-JP (日本語)
다국어 콘텐츠 가져오기
// 특정 로케일
const entries = await client.getEntries({
content_type: 'blogPost',
locale: 'ko-KR'
});
// 모든 로케일
const entries = await client.getEntries({
content_type: 'blogPost',
locale: '*'
});
// 사용 예
entries.items.forEach(item => {
console.log('한국어:', item.fields.title['ko-KR']);
console.log('English:', item.fields.title['en-US']);
});
Next.js 국제화
// next.config.js
module.exports = {
i18n: {
locales: ['ko', 'en', 'ja'],
defaultLocale: 'ko'
}
};
// lib/contentful.ts
export async function getBlogPosts(locale: string) {
const entries = await client.getEntries({
content_type: 'blogPost',
locale: locale === 'ko' ? 'ko-KR' : locale === 'en' ? 'en-US' : 'ja-JP'
});
return entries.items;
}
// app/[locale]/blog/page.tsx
export default async function BlogPage({ params }: { params: { locale: string } }) {
const posts = await getBlogPosts(params.locale);
return (
<div>
<h1>{params.locale === 'ko' ? '블로그' : 'Blog'}</h1>
{posts.map(post => (
<article key={post.sys.id}>
<h2>{post.fields.title}</h2>
</article>
))}
</div>
);
}
7. 웹훅과 자동 배포
웹훅 설정
Contentful → Settings → Webhooks → Add Webhook
Name: Vercel Deploy
URL: https://api.vercel.com/v1/integrations/deploy/DEPLOY_HOOK_URL
Triggers:
✅ Entry: Publish
✅ Entry: Unpublish
✅ Entry: Delete
✅ Asset: Publish
Vercel Deploy Hook
1. Vercel → Settings → Git → Deploy Hooks
2. Name: Contentful
3. Branch: main
4. Create Hook
5. 복사한 URL을 Contentful 웹훅에 추가
커스텀 웹훅 핸들러
// app/api/webhook/route.ts
import { revalidatePath } from 'next/cache';
export async function POST(request: Request) {
const body = await request.json();
// 보안: 비밀 키 검증
const secret = request.headers.get('x-contentful-webhook-secret');
if (secret !== process.env.CONTENTFUL_WEBHOOK_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
// 이벤트 타입 확인
const topic = request.headers.get('x-contentful-topic');
if (topic === 'ContentManagement.Entry.publish') {
const contentType = body.sys.contentType.sys.id;
if (contentType === 'blogPost') {
const slug = body.fields.slug['ko-KR'];
// ISR 재검증
revalidatePath(`/blog/${slug}`);
revalidatePath('/blog');
}
}
return new Response('OK', { status: 200 });
}
8. 마이그레이션과 백업
Content Management API로 백업
const contentfulManagement = require('contentful-management');
const client = contentfulManagement.createClient({
accessToken: 'YOUR_MANAGEMENT_TOKEN'
});
async function backupContent() {
const space = await client.getSpace('YOUR_SPACE_ID');
const environment = await space.getEnvironment('master');
// 모든 엔트리 가져오기
const entries = await environment.getEntries({ limit: 1000 });
// JSON 파일로 저장
const fs = require('fs');
fs.writeFileSync('backup.json', JSON.stringify(entries.items, null, 2));
console.log('백업 완료!');
}
backupContent();
WordPress에서 마이그레이션
// WordPress REST API에서 가져오기
const axios = require('axios');
async function migrateFromWordPress() {
const posts = await axios.get('https://yoursite.com/wp-json/wp/v2/posts');
const client = contentfulManagement.createClient({
accessToken: 'YOUR_MANAGEMENT_TOKEN'
});
const space = await client.getSpace('YOUR_SPACE_ID');
const environment = await space.getEnvironment('master');
for (const wpPost of posts.data) {
await environment.createEntry('blogPost', {
fields: {
title: { 'ko-KR': wpPost.title.rendered },
slug: { 'ko-KR': wpPost.slug },
body: { 'ko-KR': wpPost.content.rendered },
publishDate: { 'ko-KR': wpPost.date }
}
});
}
console.log('마이그레이션 완료!');
}
9. 대안 CMS 비교
Strapi vs Contentful vs Sanity
| 항목 | Contentful | Strapi | Sanity |
|---|---|---|---|
| 타입 | SaaS | 오픈소스 | SaaS |
| 호스팅 | 관리형 | 직접 호스팅 | 관리형 |
| 가격 | $0~$489/월 | 무료 | $0~$99/월 |
| 커스터마이징 | 제한적 | 완전 자유 | 중간 |
| 설정 난이도 | 쉬움 | 중간 | 중간 |
| 실시간 협업 | ❌ | ❌ | ✅ |
| GraphQL | ✅ | ✅ | ✅ (GROQ) |
선택 가이드
Contentful:
✅ 빠른 시작 (5분 내 시작)
✅ 관리형 (호스팅 걱정 없음)
✅ 안정적 (엔터프라이즈급)
❌ 비용 (Team: $489/월)
❌ 커스터마이징 제한
Strapi:
✅ 완전 무료 (오픈소스)
✅ 완전 커스터마이징
✅ 자체 서버 제어
❌ 호스팅 필요
❌ 유지보수 부담
Sanity:
✅ 실시간 협업
✅ GROQ (강력한 쿼리 언어)
✅ Portable Text (구조화 텍스트)
❌ 러닝 커브
❌ 생태계 작음
10. 실전 베스트 프랙티스
1. Content Model 설계
✅ 재사용 가능한 컴포넌트로 분리
✅ Reference 중첩 최대 3단계
✅ Slug 필드 필수
✅ 검증 규칙 설정
✅ 필드 이름은 camelCase
❌ 모든 필드를 하나의 Content Type에
❌ 5단계 이상 중첩
❌ ID만 사용 (Slug 없음)
2. API 사용
✅ GraphQL로 필요한 필드만 가져오기
✅ Rate limit 고려 (1M 요청/월)
✅ CDN 캐싱 활용 (Delivery API)
✅ 이미지 최적화 파라미터 사용
❌ 모든 필드 가져오기 (오버페칭)
❌ Preview API를 프로덕션에 사용
❌ 원본 이미지 직접 사용
3. Next.js 통합
✅ ISR로 콘텐츠 자동 갱신
✅ 웹훅으로 재배포 트리거
✅ Preview 모드로 초안 확인
✅ generateStaticParams로 정적 생성
❌ SSR만 사용 (느린 응답)
❌ 수동 재배포
❌ 프로덕션에서 초안 노출
11. 정리 및 결론
Contentful 장단점
장점:
- ✅ 빠른 시작 (관리형 서비스)
- ✅ 안정적 (엔터프라이즈급)
- ✅ 협업 기능 (워크플로우, 버전 관리)
- ✅ 강력한 API (REST, GraphQL)
- ✅ 이미지 최적화 CDN
단점:
- ❌ 비용 (Team: $489/월)
- ❌ 커스터마이징 제한
- ❌ API 요청 제한
- ❌ GraphQL 깊이 제한 (10단계)
사용 시나리오
| 프로젝트 | 추천 CMS |
|---|---|
| 블로그·마케팅 | Contentful, Sanity |
| E-commerce | Strapi (커스터마이징) |
| 대규모 엔터프라이즈 | Contentful Enterprise |
| 스타트업 MVP | Contentful Community |
| 완전 무료 | Strapi |
체크리스트
Contentful 도입 전 확인:
- 예산 ($0~$489/월)
- Content Model 복잡도
- API 요청량 (1M/월 내)
- 팀 크기 (Community: 3명)
- 다국어 필요 여부
- 커스터마이징 필요 범위
- 호스팅 자체 관리 가능 여부
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- Next.js App Router 완벽 가이드
- GraphQL 완벽 가이드 | Query·Mutation·Subscription
- TypeScript 완벽 가이드 | 타입 시스템 심화
이 글이 도움이 되셨나요? Contentful Headless CMS 활용과 콘텐츠 관리에 도움이 되었기를 바랍니다!