Contentful로 헤드리스 CMS 구성하기: Content Model 설계, API, Next.js 통합, 다국어
이 글의 핵심
Contentful을 실무에 쓸 때 필요한 Content Model 설계 패턴, API 활용, Next.js 통합, 이미지 최적화, 다국어, 웹훅 기반 자동 배포를 정리합니다.
들어가며: “콘텐츠와 프레젠테이션을 분리하자”
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을 어떻게 나눌지, REST와 GraphQL API를 언제 쓸지, Next.js에 붙여 ISR·Preview를 구성하는 법, 이미지 최적화와 다국어, 웹훅 기반 자동 배포와 마이그레이션, 그리고 Strapi·Sanity 같은 대안과의 비교까지 정리합니다.
실전 경험에서 배운 교훈
Contentful을 처음 도입할 때 흔히 겪는 실수와 개선 방향입니다.
초기 실수:
- Content Model 과도하게 복잡: 참조가 여러 단계로 중첩 → 쿼리 복잡도 급증
- GraphQL 쿼리 복잡도 제한: 컬렉션 안의 컬렉션을 많이 요청하면 복잡도 한도를 넘어 쿼리가 거부됨
- API 요청 제한: 빌드 시 페이지마다 API를 따로 호출하다 초당 요청 한도(429 Too Many Requests)에 걸림
- 이미지 최적화 미흡: CDN 활용 안 해서 느린 로딩
- 웹훅 미활용: 콘텐츠 변경 시 수동 재배포
개선 후:
- 플랫한 Content Model 설계
- ISR (Incremental Static Regeneration) 활용
- 이미지 최적화 API 사용
- 웹훅으로 자동 재배포
- Preview 모드로 초안 미리보기
이렇게 바꾸면 전체 재빌드 빈도가 줄고, 콘텐츠를 수정하면 웹훅을 통해 배포까지 자동으로 이어집니다.
이 목록의 공통점은 Contentful을 “데이터베이스”처럼 쓰다가 생긴 문제라는 것입니다. Contentful은 편집자가 쓰는 콘텐츠 저장소이자 CDN 뒤에 있는 읽기 전용 API에 가깝습니다. 페이지 요청마다 여러 번 호출하거나, 관계형 DB처럼 깊은 조인을 기대하면 한도에 걸립니다. 빌드 시점이나 캐시 계층에서 한 번 가져와 재사용하고, 콘텐츠 모델은 “화면 하나를 만드는 데 필요한 데이터를 한두 번의 요청으로 가져올 수 있는가”를 기준으로 설계하는 것이 핵심입니다.
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 트리거
가격
Contentful은 무료 플랜과 유료 플랜(팀 규모), 엔터프라이즈 계약으로 나뉩니다. 플랜마다 레코드(엔트리+에셋) 수, 사용자 수, 로케일 수, 환경(environment) 수, 월간 API 호출량이 제한되며, 이름과 가격은 여러 차례 바뀌어 왔습니다. 구체적인 숫자는 도입 시점에 공식 가격 페이지로 확인하는 것이 안전합니다.
비용 판단에서 자주 놓치는 것은 로케일과 레코드가 곱해진다는 점입니다. 다국어 사이트는 로케일 수만큼 콘텐츠를 관리해야 하고, 이미지 하나도 에셋 레코드로 세어집니다. 무료 플랜으로 시작한 프로젝트가 다국어를 추가하거나 편집자가 늘어나는 순간 유료 플랜으로 넘어가야 하는 경우가 많으므로, 1~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)
모델을 설계할 때 가장 중요한 결정은 “무엇을 별도 Content Type으로 뺄 것인가”입니다. 여러 글에서 재사용되고 독립적으로 수정되는 것(작성자, 태그)은 Reference로 분리하고, 그 글에서만 의미 있는 것(본문, 요약)은 필드로 둡니다. Content Type의 API Identifier는 한 번 정하면 바꾸기 어렵습니다. 코드가 blogPost라는 이름으로 쿼리하고 있으므로, 이름을 바꾸려면 새 타입을 만들고 콘텐츠를 옮기는 마이그레이션이 필요합니다. 필드 타입도 마찬가지라서, Short Text로 만든 필드를 나중에 Long Text로 바꿀 수 없어 새 필드를 만들어 옮겨야 하는 경우가 흔합니다.
본문 필드로 Markdown이 담긴 Long Text 대신 Rich Text를 쓰는 선택지도 있습니다. Rich Text는 JSON 구조로 저장되어 본문 안에 다른 엔트리(코드 블록, 광고, 관련 글 카드)를 임베드할 수 있고, @contentful/rich-text-react-renderer로 노드마다 원하는 React 컴포넌트를 렌더링할 수 있습니다. Markdown은 개발자에게 익숙하고 이식성이 좋지만, 비개발자 편집자에게는 Rich Text 편집기가 더 편합니다.
베스트 프랙티스
1. 플랫한 구조 유지
✅ Reference 중첩은 필요한 만큼만 (보통 2~3단계)
❌ 깊은 중첩 (REST include 한도·GraphQL 복잡도 한도에 걸림)
2. 재사용 가능한 컴포넌트
✅ Author, Tag를 별도 Content Type
❌ 모든 필드를 Blog Post에 포함
3. Slug 필드 필수
✅ URL 생성, 라우팅에 사용
❌ ID만 사용 (SEO 불리)
4. 검증 규칙 설정
✅ 필수 필드, 유니크, 정규식
❌ 검증 없이 사용 (데이터 일관성 문제)
API 활용
REST API vs GraphQL
| 특징 | REST API | GraphQL |
|---|---|---|
| 유연성 | 낮음 | 높음 |
| 오버페칭 | 자주 발생 | 없음 |
| 캐싱 | 쉬움 | 복잡함 |
| 타입 안전 | 없음 | 있음 (스키마 기반 코드 생성 가능) |
| 참조 깊이 | include 파라미터 최대 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
};
}
REST API(Content Delivery API)는 참조된 엔트리를 응답의 includes 영역에 한꺼번에 담아 보내고, SDK가 이것을 item.fields.author.fields.name처럼 따라갈 수 있게 연결해 줍니다. 기본 include 깊이는 1~2단계 수준이라, 참조의 참조까지 필요하면 include: 3처럼 늘려야 하고 최대 10입니다. 깊이가 부족하면 author.fields가 undefined로 나오는데, 에러가 나지 않고 값만 비어 있어서 원인을 찾기 어렵습니다. 위 코드의 ?.는 이런 경우에 대비한 것입니다.
fields.file.url은 //images.ctfassets.net/...처럼 프로토콜이 없는 주소입니다. 브라우저의 <img>에서는 동작하지만, Next.js의 Image 컴포넌트나 서버 쪽 fetch에 그대로 넘기면 잘못된 URL로 처리되므로 앞에 https:를 붙여 써야 합니다. 처음 연동할 때 가장 흔히 걸리는 부분입니다. 또 getEntries는 한 번에 최대 1000개까지만 반환하므로(기본 100개), 글이 많아지면 skip으로 페이지를 넘기며 가져와야 합니다.
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
})
}
}
}
}
`;
GraphQL API는 필요한 필드만 한 번에 가져올 수 있고, url(transform: {...})처럼 이미지 변환까지 쿼리 안에서 지정할 수 있다는 점이 편리합니다. 반면 Contentful은 쿼리마다 복잡도를 계산해 한도를 넘으면 TOO_COMPLEX_QUERY 에러로 거부합니다. tagsCollection처럼 컬렉션을 요청할 때는 기본 최대 개수를 기준으로 복잡도가 매겨지므로, tagsCollection(limit: 5)처럼 limit을 명시하면 복잡도가 크게 줄어듭니다. 목록 페이지에서 본문(body)까지 가져오는 것도 불필요한 전송이므로, 목록 쿼리와 상세 쿼리를 나누는 편이 좋습니다. 이 GraphQL 엔드포인트의 필드 이름은 Content Type의 API Identifier에서 자동으로 만들어지므로, graphql-codegen으로 스키마에서 TypeScript 타입을 생성하면 필드 이름 오타를 컴파일 단계에서 잡을 수 있습니다.
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>
);
}
이 예제는 구조를 보여 주기 위해 단순화한 것이라 그대로 쓰면 안 되는 부분이 있습니다. body가 Markdown 필드라면 dangerouslySetInnerHTML에 넣기 전에 Markdown을 HTML로 변환해야 하고(예: remark/rehype), 편집자가 입력한 HTML을 그대로 넣으면 스크립트 삽입(XSS) 위험이 있으므로 sanitize 과정이 필요합니다. Rich Text 필드라면 위에서 말한 렌더러를 씁니다.
Next.js 버전에 따른 차이도 있습니다. Next.js 15부터는 페이지의 params가 Promise라서 const { slug } = await params;로 받아야 하고, 동기 접근은 경고나 에러가 됩니다. 또 App Router에서 SDK의 getEntries는 fetch를 쓰지 않으므로 Next.js의 fetch 캐시가 적용되지 않습니다. 캐시와 재검증은 아래 ISR 설정(revalidate)이나 unstable_cache로 직접 제어해야 합니다. generateStaticParams에서 모든 글을 가져올 때 페이지마다 다시 getBlogPost를 호출하면 빌드 중 API 호출이 글 수만큼 늘어나 요청 한도에 걸리기 쉬우므로, 빌드 규모가 크다면 목록을 한 번 받아 두고 재사용하는 구조를 고려합니다.
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>
);
}
revalidate는 “60초마다 자동으로 다시 만든다”가 아니라, 60초가 지난 뒤 들어온 첫 요청에는 기존 페이지를 보여 주면서 백그라운드에서 새로 만들고, 그 다음 요청부터 새 페이지를 보여 주는 방식(stale-while-revalidate)입니다. 그래서 편집자가 글을 고친 직후 새로고침해도 한 번은 옛 내용이 보일 수 있습니다. 편집 반영을 즉시 하고 싶다면 시간 기반 재검증 대신, 아래 7절처럼 웹훅에서 revalidatePath를 호출하는 on-demand 재검증을 함께 쓰는 것이 좋습니다.
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'
});
}
Preview API는 게시되지 않은 초안까지 돌려주는 별도 엔드포인트라서 토큰도 따로 발급됩니다. 이 토큰이 클라이언트 번들에 들어가면 누구나 초안을 읽을 수 있으므로, 반드시 서버 전용 환경 변수(NEXT_PUBLIC_ 접두어 없이)로 두어야 합니다. Preview API는 CDN 캐시를 거치지 않아 느리고 요청 한도도 따로 있으므로 운영 트래픽에 쓰면 안 됩니다. Next.js 15에서는 draftMode()도 비동기라 (await draftMode()).enable() 형태로 호출해야 합니다. Contentful 웹 앱의 Content preview 설정에 https://사이트/api/preview?secret=...&slug={entry.fields.slug} 형태의 URL을 등록해 두면, 편집자가 엔트리 화면에서 바로 미리보기로 이동할 수 있습니다.
이미지 최적화
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
Contentful의 Images API는 URL 파라미터만으로 리사이징과 포맷 변환을 해 주고, 결과는 CDN에 캐시됩니다. 편집자가 수 MB짜리 원본 사진을 올려도 화면 크기에 맞는 사본을 받을 수 있어, 별도 이미지 처리 서버 없이 이미지 용량 문제를 대부분 해결할 수 있습니다. fit=fill은 지정한 크기에 맞춰 잘라 내고, fit=pad는 비율을 유지하며 여백을 채우는 등 동작이 다르므로 용도에 맞게 고릅니다.
Next.js Image 컴포넌트 통합
// next.config.js
module.exports = {
images: {
// images.domains는 폐기 예정이라 remotePatterns 사용을 권장
remotePatterns: [{ protocol: 'https', hostname: '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은 //로 시작하므로 https:를 붙이고 파라미터 추가
const base = src.startsWith('//') ? `https:${src}` : src;
const imageUrl = `${base}?fm=webp&q=80`;
return (
<Image
src={imageUrl}
alt={alt}
width={width}
height={height}
loading="lazy"
/>
);
}
Next.js Image는 자체 이미지 최적화 서버를 거치므로, Contentful Images API와 함께 쓰면 변환이 두 번 일어납니다. 비용이나 처리 시간을 줄이려면 next.config.js에 커스텀 loader를 지정해 Next.js가 요청하는 너비(width)를 Contentful의 w 파라미터로 바로 넘기는 방식이 효율적입니다. 이렇게 하면 srcset 생성은 Next.js가, 실제 리사이징은 Contentful CDN이 맡습니다.
다국어 지원
로케일 설정
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']);
});
locale 파라미터에 따라 응답 구조 자체가 바뀐다는 점이 중요합니다. 특정 로케일을 지정하면 item.fields.title이 문자열이고, '*'로 모든 로케일을 요청하면 { 'ko-KR': ..., 'en-US': ... } 객체가 됩니다. 두 방식을 섞어 쓰면 [object Object]가 화면에 찍히는 버그가 생깁니다. 번역이 없는 필드는 로케일 설정의 fallback 규칙에 따라 기본 로케일 값이 대신 나오는데, 필드마다 “로컬라이즈 가능” 여부를 켜 두어야 번역할 수 있습니다. 이미지나 슬러그처럼 언어와 무관한 필드는 로컬라이즈하지 않는 편이 관리가 쉽습니다.
Next.js 국제화
// next.config.js (Pages Router 전용 설정 — App Router에서는 지원되지 않음)
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>
);
}
위의 next.config.js i18n 설정은 Pages Router에서만 동작하고, app/[locale]/... 구조의 App Router에서는 무시됩니다(설정하면 경고가 납니다). App Router에서는 예제처럼 [locale] 동적 세그먼트를 두고, 언어 감지와 리다이렉트는 middleware.ts에서 Accept-Language 헤더나 쿠키를 보고 직접 처리합니다. Next.js 로케일(ko)과 Contentful 로케일 코드(ko-KR)의 대응표는 한 곳에 상수로 두어야, 위 예제처럼 삼항 연산자를 곳곳에 흩어 두다가 새 언어를 추가할 때 빠뜨리는 일을 막을 수 있습니다.
웹훅과 자동 배포
웹훅 설정
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 });
}
Contentful 웹훅은 서명이나 비밀 키를 자동으로 붙이지 않으므로, 웹훅 설정의 Headers에 x-contentful-webhook-secret 같은 커스텀 헤더를 직접 추가해야 위 검증 코드가 동작합니다(최근에는 요청 서명 검증 기능도 제공합니다). 이 검증이 없으면 누구나 이 엔드포인트를 호출해 재검증을 반복시킬 수 있습니다. 페이로드 형태도 주의해야 합니다. 웹훅 본문은 Management API 형식이라 fields.slug['ko-KR']처럼 로케일별 객체이고, Unpublish·Delete 이벤트에는 fields가 없습니다. 삭제된 글의 페이지를 지우려면 sys.id로 slug를 찾을 수 있는 매핑이 필요하거나, 목록 페이지 전체를 재검증하는 방식으로 처리합니다. 웹훅이 실패하면 Contentful이 재시도하므로, 같은 이벤트가 두 번 와도 문제없도록 핸들러를 멱등하게 만드는 것이 좋습니다.
마이그레이션과 백업
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();
이 스크립트는 개념을 보여 주는 용도이고, 실제 백업으로는 부족합니다. getEntries는 한 번에 최대 1000개만 돌려주므로 그보다 많으면 skip으로 반복해야 하고, Content Type 정의와 에셋, 로케일 설정까지 함께 보관해야 복원이 가능합니다. 공식 contentful-cli의 contentful space export가 이 모든 것을 JSON으로 내보내고 space import로 되돌릴 수 있어 실무에서는 이쪽을 씁니다. Content Model 변경 자체는 contentful-migration 스크립트로 코드화해 두면, 스테이징 환경(environment)에 먼저 적용해 보고 운영에 같은 변경을 재현할 수 있습니다.
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('마이그레이션 완료!');
}
실제 마이그레이션에서는 몇 가지를 더 처리해야 합니다. WordPress REST API는 기본 10개씩 페이지 단위로 돌려주므로 ?per_page=100&page=N으로 끝까지 반복해야 하고, createEntry로 만든 엔트리는 초안 상태라 entry.publish()를 호출해야 Delivery API에 나타납니다. content.rendered는 HTML이므로 Markdown 필드에 넣으려면 변환(예: turndown)이 필요하고, 본문 속 이미지는 WordPress 서버를 가리키므로 에셋으로 업로드해 URL을 바꿔야 합니다. Management API는 초당 요청 수 제한이 있어 수천 개를 한꺼번에 만들면 429 응답을 받으므로, 요청 사이에 지연을 두거나 재시도 로직을 넣어야 합니다.
대안 CMS 비교
Strapi vs Contentful vs Sanity
| 항목 | Contentful | Strapi | Sanity |
|---|---|---|---|
| 타입 | SaaS | 오픈소스 | SaaS |
| 호스팅 | 관리형 | 직접 호스팅 | 관리형 |
| 가격 | 무료 플랜 + 유료 플랜 | 오픈소스 무료 (호스팅 비용 별도, 유료 클라우드 있음) | 무료 플랜 + 유료 플랜 |
| 커스터마이징 | 제한적 (앱 프레임워크로 확장) | 완전 자유 | 편집 화면(Studio)을 코드로 구성 |
| 설정 난이도 | 쉬움 | 중간 | 중간 |
| 실시간 공동 편집 | 제한적 | 제한적 | 강점 |
| 쿼리 | REST, GraphQL | REST, GraphQL | GROQ(주력), GraphQL |
선택 가이드
Contentful:
✅ 빠른 시작 (계정 생성 후 바로 모델링)
✅ 관리형 (호스팅 걱정 없음)
✅ 안정적 (엔터프라이즈 사용 사례가 많음)
❌ 비용 (유료 플랜으로 넘어가는 순간 부담이 큼)
❌ 커스터마이징 제한
Strapi:
✅ 완전 무료 (오픈소스)
✅ 완전 커스터마이징
✅ 자체 서버 제어
❌ 호스팅 필요
❌ 유지보수 부담
Sanity:
✅ 실시간 협업
✅ GROQ (강력한 쿼리 언어)
✅ Portable Text (구조화 텍스트)
❌ 러닝 커브
❌ 생태계 작음
실전 베스트 프랙티스
Content Model 설계
✅ 재사용 가능한 컴포넌트로 분리
✅ Reference 중첩 최대 3단계
✅ Slug 필드 필수
✅ 검증 규칙 설정
✅ 필드 이름은 camelCase
❌ 모든 필드를 하나의 Content Type에
❌ 5단계 이상 중첩
❌ ID만 사용 (Slug 없음)
API 사용
✅ GraphQL로 필요한 필드만 가져오기
✅ 요청 한도 고려 (초당 요청 수·월간 사용량)
✅ CDN 캐싱 활용 (Delivery API)
✅ 이미지 최적화 파라미터 사용
❌ 모든 필드 가져오기 (오버페칭)
❌ Preview API를 프로덕션에 사용
❌ 원본 이미지 직접 사용
Next.js 통합
✅ ISR로 콘텐츠 자동 갱신
✅ 웹훅으로 재배포 트리거
✅ Preview 모드로 초안 확인
✅ generateStaticParams로 정적 생성
❌ SSR만 사용 (느린 응답)
❌ 수동 재배포
❌ 프로덕션에서 초안 노출
정리 및 결론
Contentful 장단점
장점:
- 빠른 시작 (관리형 서비스)
- 안정적 (엔터프라이즈급)
- 협업 기능 (워크플로우, 버전 관리)
- 강력한 API (REST, GraphQL)
- 이미지 최적화 CDN
단점:
- 비용 (유료 플랜 가격대가 높은 편)
- 커스터마이징 제한
- API 요청 제한
- GraphQL 쿼리 복잡도 제한
사용 시나리오
| 프로젝트 | 추천 CMS |
|---|---|
| 블로그·마케팅 | Contentful, Sanity |
| E-commerce | Strapi (커스터마이징) |
| 대규모 엔터프라이즈 | Contentful Enterprise |
| 스타트업 MVP | Contentful Community |
| 완전 무료 | Strapi |
체크리스트
Contentful 도입 전 확인:
- 예산 (현재 플랜별 가격·한도 확인)
- Content Model 복잡도
- API 요청량 (플랜 한도 내인지)
- 팀 크기 (플랜별 사용자 수 한도)
- 다국어 필요 여부
- 커스터마이징 필요 범위
- 호스팅 자체 관리 가능 여부