Headless CMS + 커머스 통합: 콘텐츠 기반 쇼핑 경험 설계

이커머스

Headless CMS콘텐츠 커머스ContentfulSanityNext.js

이 글은 누구를 위한 것인가

  • 마케팅팀이 개발자 없이 랜딩 페이지와 캠페인을 직접 만들고 싶은 팀
  • 콘텐츠(블로그, 룩북)와 상품을 자연스럽게 연결하고 싶은 이커머스 PM
  • 기존 이커머스 플랫폼에 Headless CMS를 추가하는 방법이 필요한 개발자

들어가며

전통적 이커머스는 상품 카탈로그가 중심이다. 하지만 현대 이커머스에서 콘텐츠는 구매 여정의 핵심이다. "이 재킷과 어울리는 스타일 가이드", "성분을 이해하면 더 잘 쓰는 스킨케어 루틴" — 콘텐츠가 구매를 유도한다.

Headless CMS는 마케팅팀에게 자율성을 주고, 개발팀에게는 기술적 자유를 준다. 두 시스템을 제대로 연결하는 것이 핵심이다.

이 글은 bluefoxdev.kr의 콘텐츠 커머스 전략 가이드 를 참고하고, Headless CMS 통합 구현 관점에서 확장하여 작성했습니다.


1. Headless CMS vs 전통 CMS

[전통 이커머스 CMS]
상품 DB ─── 이커머스 플랫폼(Shopify/자체) ─── 단일 프론트엔드
             (CMS 기능 내장, 제한적)

[Headless 아키텍처]
Contentful/Sanity (콘텐츠)  ─┐
상품 DB / 이커머스 API       ─┼─→ GraphQL/REST ─→ Next.js 프론트엔드
검색 (Algolia)              ─┘                  → 모바일 앱
                                                → 기타 채널

장점:
✅ 마케팅팀이 코드 없이 페이지 구성
✅ 콘텐츠-상품 연결 유연
✅ 멀티채널 동일 콘텐츠 활용
✅ 성능 최적화 (ISR, CDN)

2. Sanity 스키마 설계

// sanity/schemas/landingPage.ts
export const landingPage = {
  name: 'landingPage',
  type: 'document',
  title: '랜딩 페이지',
  fields: [
    { name: 'title', type: 'string', title: '페이지 제목' },
    { name: 'slug', type: 'slug', title: 'URL', options: { source: 'title' } },
    {
      name: 'sections',
      type: 'array',
      title: '섹션',
      of: [
        { type: 'heroSection' },
        { type: 'productGrid' },    // 상품 진열
        { type: 'editorialContent' }, // 룩북/스타일 가이드
        { type: 'bannerSection' },
      ],
    },
  ],
};

// 상품 진열 섹션: CMS에서 상품 선택
export const productGrid = {
  name: 'productGrid',
  type: 'object',
  title: '상품 진열',
  fields: [
    { name: 'heading', type: 'string', title: '제목' },
    {
      name: 'productIds',
      type: 'array',
      title: '상품 ID 목록',
      of: [{ type: 'string' }],
      description: '이커머스 플랫폼의 상품 ID를 입력하세요',
    },
    {
      name: 'layout',
      type: 'string',
      title: '레이아웃',
      options: {
        list: ['2columns', '3columns', '4columns', 'carousel'],
      },
    },
    {
      name: 'badge',
      type: 'string',
      title: '뱃지 (선택)',
      description: 'NEW, BEST, SALE 등',
    },
  ],
};

// 에디토리얼 콘텐츠: 룩북, 스타일 가이드
export const editorialContent = {
  name: 'editorialContent',
  type: 'object',
  title: '에디토리얼',
  fields: [
    { name: 'headline', type: 'string' },
    { name: 'body', type: 'portableText' },
    {
      name: 'featuredProduct',
      type: 'object',
      title: '연관 상품',
      fields: [
        { name: 'productId', type: 'string' },
        { name: 'callToAction', type: 'string', title: '버튼 문구' },
      ],
    },
    {
      name: 'images',
      type: 'array',
      of: [{ type: 'image', options: { hotspot: true } }],
    },
  ],
};

3. Next.js 통합 구현

3.1 데이터 페칭 레이어

// lib/cms.ts - Sanity 클라이언트
import { createClient } from '@sanity/client';
import imageUrlBuilder from '@sanity/image-url';

export const sanityClient = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: 'production',
  apiVersion: '2026-04-23',
  useCdn: true,
});

// lib/commerce.ts - 이커머스 API 클라이언트
async function getProducts(productIds: string[]): Promise<Product[]> {
  if (!productIds.length) return [];

  const params = new URLSearchParams();
  productIds.forEach(id => params.append('ids', id));

  const response = await fetch(
    `${process.env.COMMERCE_API_URL}/products?${params}`,
    { next: { revalidate: 300 } }  // 5분 캐시
  );
  return response.json();
}

3.2 페이지 렌더링 (ISR)

// app/landing/[slug]/page.tsx
import { sanityClient } from '@/lib/cms';
import { getProducts } from '@/lib/commerce';

async function getLandingPage(slug: string) {
  return sanityClient.fetch(`
    *[_type == "landingPage" && slug.current == $slug][0] {
      title,
      sections[] {
        _type,
        ...
      }
    }
  `, { slug });
}

export default async function LandingPage({ params }: { params: { slug: string } }) {
  const page = await getLandingPage(params.slug);
  if (!page) notFound();

  // 페이지 내 모든 상품 ID 수집
  const allProductIds = page.sections
    .filter((s: any) => s._type === 'productGrid')
    .flatMap((s: any) => s.productIds ?? []);

  // 상품 데이터 일괄 페칭
  const products = await getProducts([...new Set(allProductIds)]);
  const productMap = Object.fromEntries(products.map(p => [p.id, p]));

  return (
    <div>
      {page.sections.map((section: any, i: number) => (
        <SectionRenderer
          key={i}
          section={section}
          productMap={productMap}
        />
      ))}
    </div>
  );
}

// ISR 설정
export const revalidate = 3600; // 1시간마다 재생성

3.3 섹션 렌더러

// components/SectionRenderer.tsx
function SectionRenderer({ section, productMap }: SectionRendererProps) {
  switch (section._type) {
    case 'heroSection':
      return <HeroSection data={section} />;

    case 'productGrid':
      const sectionProducts = (section.productIds ?? [])
        .map((id: string) => productMap[id])
        .filter(Boolean);
      return (
        <ProductGrid
          heading={section.heading}
          products={sectionProducts}
          layout={section.layout}
          badge={section.badge}
        />
      );

    case 'editorialContent':
      const featuredProduct = section.featuredProduct?.productId
        ? productMap[section.featuredProduct.productId]
        : null;
      return (
        <EditorialSection
          data={section}
          featuredProduct={featuredProduct}
        />
      );

    default:
      return null;
  }
}

4. Sanity Studio 커스터마이징

// sanity/components/ProductPicker.tsx
// 편집자가 CMS에서 상품을 검색해서 선택하는 UI

import { useState } from 'react';
import { set, unset } from 'sanity';

export function ProductPicker({ value, onChange }: ProductPickerProps) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Product[]>([]);

  const search = async (q: string) => {
    const res = await fetch(`/api/products/search?q=${q}`);
    setResults(await res.json());
  };

  return (
    <div>
      <input
        placeholder="상품명 또는 SKU 검색..."
        value={query}
        onChange={e => {
          setQuery(e.target.value);
          search(e.target.value);
        }}
      />

      {results.map(product => (
        <div
          key={product.id}
          onClick={() => onChange(set([...(value ?? []), product.id]))}
          style={{ cursor: 'pointer', padding: '8px', border: '1px solid #eee' }}
        >
          <img src={product.thumbnail} width={40} />
          {product.name} — {product.price.toLocaleString()}원
          {value?.includes(product.id) && ' ✓'}
        </div>
      ))}

      <div>
        <strong>선택된 상품:</strong>
        {(value ?? []).map((id: string) => (
          <span key={id} onClick={() => onChange(set(value.filter((v: string) => v !== id)))}>
            {id} ✕
          </span>
        ))}
      </div>
    </div>
  );
}

5. 콘텐츠-상품 연결 전략

[콘텐츠 타입별 상품 연결 패턴]

1. 룩북 (Editorial)
   콘텐츠: 스타일 화보 이미지 + 텍스트
   연결: 이미지 내 상품 태그 (핫스팟)
   효과: 스타일을 보고 바로 구매

2. 레시피/가이드 (How-to)
   콘텐츠: 단계별 가이드
   연결: 각 단계에서 필요한 상품 표시
   효과: 필요성 인식 → 즉시 구매

3. 캠페인 랜딩 (Landing)
   콘텐츠: 마케팅 메시지 + 영상
   연결: 메인 상품 + 관련 상품 그리드
   효과: 트래픽 → 전환

4. 시즌 큐레이션 (Collection)
   콘텐츠: 테마 (여름, 캠핑 등)
   연결: 에디터 선택 상품 목록
   효과: 브랜드 세계관 경험

마무리

Headless CMS 도입의 가장 큰 가치는 마케팅팀의 자율성이다. 개발자에게 "랜딩 페이지 하나만 만들어줘"라는 요청이 줄어든다.

구현 복잡도를 고려하면, Shopify 기반이라면 Contentful, 자체 구축 플랫폼이라면 Sanity가 통합하기 더 자연스럽다. 어느 쪽이든 콘텐츠-상품 ID 연결 스키마 설계가 핵심이다.