본문으로 건너뛰기
Previous
Next
Contentful 완벽 가이드 | Headless CMS 실전 활용 — 2026년 최신

Contentful 완벽 가이드 | Headless CMS 실전 활용 — 2026년 최신

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이란?

핵심 개념

ContentfulAPI 기반 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,000321M/월
Team$489/월50,0001053M/월
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 APIGraphQL
유연성낮음높음
오버페칭자주 발생없음
캐싱쉬움복잡함
타입 안전없음있음
쿼리 깊이제한 없음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

항목ContentfulStrapiSanity
타입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-commerceStrapi (커스터마이징)
대규모 엔터프라이즈Contentful Enterprise
스타트업 MVPContentful Community
완전 무료Strapi

체크리스트

Contentful 도입 전 확인:

  • 예산 ($0~$489/월)
  • Content Model 복잡도
  • API 요청량 (1M/월 내)
  • 팀 크기 (Community: 3명)
  • 다국어 필요 여부
  • 커스터마이징 필요 범위
  • 호스팅 자체 관리 가능 여부

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글이 도움이 되셨나요? Contentful Headless CMS 활용과 콘텐츠 관리에 도움이 되었기를 바랍니다!