Sanity CMS로 콘텐츠 관리하기: Schema 정의, GROQ 쿼리, Next.js 통합, 이미지, 실시간 업데이트

이 글의 핵심

헤드리스 CMS를 붙일 때는 콘텐츠 모델을 어디서 관리하고 프런트엔드가 필요한 필드만 어떻게 가져올지가 먼저 문제가 됩니다. Sanity는 스키마를 코드로 두고 GROQ로 조회하는 방식이라 이 부분이 명확합니다. 설정부터 Next.js 통합까지 따라간 뒤 WordPress·Contentful과의 차이와 무료 플랜 범위도 짚습니다.

이 글의 핵심

Sanity CMS로 콘텐츠를 모델링하고 Next.js에서 가져다 쓰는 흐름을 정리한 글입니다. Schema 정의, GROQ 쿼리, 실시간 업데이트, Next.js 통합까지 예제로 다룹니다.

실무에서 마주치는 문제들

스키마가 고정돼 있어요

WordPress는 기본적으로 “글(post)과 페이지(page)“라는 모델 위에 사용자 정의 필드를 플러그인(ACF 등)으로 덧붙이는 구조입니다. 제품 카탈로그나 다국어 랜딩 페이지처럼 모델이 복잡해지면 필드 정의가 관리 화면 설정에 흩어지고, 개발 환경과 운영 환경의 설정을 맞추기도 어렵습니다. Sanity는 콘텐츠 모델을 TypeScript 코드로 정의하므로 스키마가 Git에서 버전 관리되고 코드 리뷰를 거칩니다.

개발자 경험이 나빠요

전통적인 CMS는 템플릿 엔진과 테마 안에서 화면을 만들어야 해서, React나 Next.js 같은 프런트엔드 스택을 쓰려면 억지로 연결해야 합니다. 헤드리스 CMS는 콘텐츠를 API로만 제공하고 화면은 프런트엔드가 자유롭게 만들기 때문에, 같은 콘텐츠를 웹과 모바일 앱에서 함께 쓸 수도 있습니다.

실시간 협업이 필요해요

여러 편집자가 같은 문서를 열면 전통적인 CMS는 잠금(lock)으로 한 명만 편집하게 하거나 나중에 저장한 쪽이 덮어씁니다. Sanity Studio는 Content Lake라는 호스팅 데이터 저장소에 변경을 실시간으로 반영해, 다른 사람의 커서와 수정 내용이 바로 보입니다.

반면 콘텐츠가 Sanity의 호스팅 저장소에 있다는 것은 데이터 위치와 요금이 Sanity 정책에 묶인다는 뜻이기도 합니다. 자체 DB에 콘텐츠를 두어야 하는 요구사항이 있다면 Payload처럼 직접 호스팅하는 헤드리스 CMS가 대안이 됩니다.


Sanity란?

핵심 특징

Sanity는 구조화된 콘텐츠 플랫폼입니다. 주요 장점:

  • 유연한 스키마: 완전히 커스터마이징
  • GROQ: 강력한 쿼리 언어
  • 실시간: 실시간 업데이트
  • Portable Text: 리치 텍스트
  • 이미지 처리: 자동 최적화

구조를 나눠 보면 세 부분입니다. 편집자가 쓰는 관리 화면인 Sanity Studio(React 앱이라 직접 커스터마이징하고 배포할 수 있음), 콘텐츠가 JSON 문서로 저장되는 Content Lake, 그리고 그 데이터를 조회하는 GROQ API입니다. Studio는 오픈소스라 내 Next.js 앱 안에 /studio 경로로 넣을 수도 있고 sanity deploy로 *.sanity.studio 도메인에 따로 올릴 수도 있습니다.


프로젝트 설정

npm create sanity@latest는 Sanity 계정 로그인, 프로젝트 생성, 데이터셋(production 등) 생성, Studio 코드 생성을 한 번에 진행합니다. 여기서 만들어진 projectId와 데이터셋 이름을 Next.js 쪽 환경 변수에 넣어 연결합니다.

설치

npm create sanity@latest

프로젝트 구조

my-sanity-project/
├── sanity/
│   ├── schemas/
│   │   ├── post.ts
│   │   └── author.ts
│   ├── sanity.config.ts
│   └── sanity.cli.ts
└── app/

sanity.config.ts는 Studio의 설정(어떤 스키마와 플러그인을 쓸지)이고, sanity.cli.ts는 sanity CLI가 배포·데이터셋 관리 명령에서 쓰는 설정입니다. 두 파일이 모두 projectId와 dataset을 갖고 있어서, 한쪽만 바꾸면 Studio는 새 데이터셋을 보는데 CLI 명령은 예전 데이터셋을 건드리는 식의 불일치가 생길 수 있습니다. 환경 변수 하나에서 읽도록 맞춰 두는 편이 안전합니다.


Schema 정의

Post Schema

스키마는 Studio의 편집 화면과 검증 규칙을 정의할 뿐, Content Lake가 저장 시점에 스키마를 강제하지는 않습니다. 즉 API로 직접 문서를 넣으면 스키마에 없는 필드도 들어가고, 스키마에서 필드를 지워도 기존 문서의 값은 그대로 남습니다. 이 점이 관계형 DB의 스키마와 가장 다른 부분입니다.

// sanity/schemas/post.ts
import { defineField, defineType } from 'sanity';
export default defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      title: 'Title',
      type: 'string',
      validation: (Rule) => Rule.required().min(10).max(100),
    }),
    defineField({
      name: 'slug',
      title: 'Slug',
      type: 'slug',
      options: {
        source: 'title',
        maxLength: 96,
      },
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'author',
      title: 'Author',
      type: 'reference',
      to: [{ type: 'author' }],
    }),
    defineField({
      name: 'mainImage',
      title: 'Main image',
      type: 'image',
      options: {
        hotspot: true,
      },
    }),
    defineField({
      name: 'categories',
      title: 'Categories',
      type: 'array',
      of: [{ type: 'reference', to: { type: 'category' } }],
    }),
    defineField({
      name: 'publishedAt',
      title: 'Published at',
      type: 'datetime',
    }),
    defineField({
      name: 'body',
      title: 'Body',
      type: 'blockContent',
    }),
  ],
});

몇 가지 필드를 짚어 보면 이렇습니다. slug 타입은 { _type: 'slug', current: 'my-post' } 형태의 객체로 저장되므로, 쿼리와 프런트엔드에서 slug.current로 접근해야 합니다. options.source: 'title'은 Studio에 “Generate” 버튼을 만들어 제목에서 슬러그를 만들어 주는 기능이며, 제목을 바꿔도 슬러그가 자동으로 바뀌지는 않습니다. reference 타입은 다른 문서의 _id만 { _ref: '...' }로 저장하므로 쿼리에서 ->로 따라가야 실제 내용이 나옵니다. 이미지의 hotspot: true는 편집자가 이미지의 중요한 영역을 지정하게 해서, 나중에 다른 비율로 잘라도 그 영역이 유지되게 합니다.

주의할 점은 categories가 category 타입을, body가 blockContent 타입을 참조한다는 것입니다. 이 두 스키마를 따로 정의해 schemaTypes 배열에 등록하지 않으면 Studio를 띄울 때 Unknown type: blockContent 같은 스키마 오류가 납니다. 또 validation의 Rule.required()는 Studio에서 게시(Publish) 버튼을 막을 뿐이라, 프런트엔드는 필드가 비어 있을 가능성을 항상 방어해야 합니다.


GROQ 쿼리

기본 쿼리

GROQ는 *[필터] | order(...) { 프로젝션 } 구조로 읽으면 쉽습니다. *는 데이터셋의 모든 문서, 대괄호는 조건, 중괄호는 가져올 필드 목록입니다.

// lib/sanity.ts
import { createClient } from '@sanity/client';
export const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  apiVersion: '2024-01-01',
  useCdn: true,
});
// 모든 포스트
const posts = await client.fetch(`*[_type == "post"]`);
// 필터링
const publishedPosts = await client.fetch(`
  *[_type == "post" && publishedAt < now()] | order(publishedAt desc)
`);
// 특정 필드만
const posts = await client.fetch(`
  *[_type == "post"] {
    title,
    slug,
    publishedAt
  }
`);

apiVersion은 날짜 문자열로 API 동작 버전을 고정합니다. 이 값을 빼면 경고와 함께 오래된 기본 버전이 쓰이고, 버전에 따라 GROQ 동작이 달라질 수 있으므로 프로젝트를 시작한 날짜 근처로 명시하는 것이 좋습니다. useCdn: true는 전 세계 캐시된 API CDN에서 응답을 받아 빠르고 요청 비용도 줄지만, 수정한 내용이 반영되기까지 약간의 지연이 있습니다. 게시 직후 바로 확인해야 하는 미리보기나 빌드 타임 조회에서는 useCdn: false를 쓰는 경우가 많습니다.

처음 GROQ를 쓸 때 흔히 겪는 문제는 초안(draft) 문서가 섞여 나오는 것입니다. Studio에서 게시하지 않은 편집 내용은 drafts. 접두사가 붙은 _id로 별도 문서가 되는데, 토큰을 붙여 인증된 요청을 보내면 이 초안까지 결과에 포함되어 같은 글이 두 번 나옵니다. 공개 페이지용 쿼리에는 !(_id in path("drafts.**")) 조건을 넣거나 클라이언트의 perspective: 'published' 옵션을 쓰면 됩니다. 또 첫 번째 예제처럼 프로젝션 없이 *[_type == "post"]를 쓰면 본문 전체가 포함된 모든 필드가 내려와 응답이 커지므로, 목록 화면에서는 필요한 필드만 고르는 습관이 중요합니다.

Join (Reference)

const postsWithAuthor = await client.fetch(`
  *[_type == "post"] {
    title,
    slug,
    author->{
      name,
      image
    }
  }
`);

author->{...}의 ->는 참조를 따라가 연결된 author 문서의 필드를 가져오는 역참조 연산자입니다. SQL의 JOIN과 비슷하지만 쿼리 한 번에 서버에서 처리되므로 N+1 요청 문제가 없습니다. 참조한 저자 문서가 삭제되었거나 아직 게시되지 않았다면 author는 null이 되므로 프런트엔드에서 post.author?.name처럼 방어해야 합니다. image 필드는 이미지 자체가 아니라 에셋 참조 객체라서, 화면에 표시하려면 6장의 urlFor로 URL을 만들어야 합니다.

파라미터

값을 쿼리 문자열에 직접 이어 붙이지 않고 $slug 파라미터로 넘기는 이유는 SQL과 같습니다. 사용자 입력을 템플릿 문자열로 끼워 넣으면 따옴표 하나로 쿼리 구조가 바뀌는 인젝션이 가능해지고, 특수 문자 이스케이프도 직접 처리해야 합니다. [0]은 결과 배열의 첫 요소만 반환하므로 결과가 없으면 null이 됩니다.

const post = await client.fetch(
  `*[_type == "post" && slug.current == $slug][0] {
    title,
    body,
    author->{name}
  }`,
  { slug: 'my-post' }
);

Next.js 통합

포스트 목록

// app/blog/page.tsx
import { client } from '@/lib/sanity';
async function getPosts() {
  return await client.fetch(`
    *[_type == "post"] | order(publishedAt desc) {
      _id,
      title,
      slug,
      publishedAt,
      author->{name}
    }
  `);
}
export default async function BlogPage() {
  const posts = await getPosts();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post._id}>
          <a href={`/blog/${post.slug.current}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  );
}

포스트 상세

// app/blog/[slug]/page.tsx
import { client } from '@/lib/sanity';
import { PortableText } from '@portabletext/react';
async function getPost(slug: string) {
  return await client.fetch(
    `*[_type == "post" && slug.current == $slug][0] {
      title,
      body,
      author->{name, image},
      mainImage
    }`,
    { slug }
  );
}
export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return (
    <article>
      <h1>{post.title}</h1>
      <PortableText value={post.body} />
    </article>
  );
}

두 페이지 모두 서버 컴포넌트에서 직접 client.fetch를 호출하므로 API 토큰 없이 공개 데이터셋을 읽는 한 브라우저로 쿼리가 노출되지 않습니다. 상세 페이지는 슬러그가 없는 URL로 들어오면 post가 null이 되어 post.title에서 Cannot read properties of null 에러가 나므로, 실제로는 if (!post) notFound();를 먼저 넣어야 합니다. Next.js 15 이상에서는 params가 Promise이므로 const { slug } = await params;로 바꿔야 합니다.

캐싱도 고려해야 합니다. 정적으로 생성된 페이지는 Studio에서 글을 고쳐도 다시 빌드하기 전까지 바뀌지 않습니다. export const revalidate = 60처럼 시간 기반 재검증을 두거나, Sanity의 GROQ 기반 Webhook으로 문서가 게시될 때 Next.js의 revalidatePath/revalidateTag를 호출하는 API Route를 부르게 하면 필요한 페이지만 갱신됩니다. 저는 이 캐싱 설정을 빠뜨려 “CMS에서 고쳤는데 사이트에 안 나온다”는 문의를 받는 경우가 헤드리스 CMS 도입 초기에 가장 흔하다고 봅니다. generateStaticParams로 슬러그 목록을 미리 만들면 빌드 시간에 모든 글을 생성할 수 있지만, 글이 수천 개면 빌드 시간이 크게 늘어납니다.

<PortableText />는 Sanity의 리치 텍스트 형식인 Portable Text(블록 배열 JSON)를 React 요소로 바꿔 줍니다. 기본 컴포넌트는 문단·제목·목록 같은 표준 블록만 처리하므로, 본문에 이미지나 코드 블록 같은 사용자 정의 블록을 넣었다면 components prop으로 렌더러를 지정해야 합니다. 지정하지 않으면 해당 블록은 화면에 나오지 않고 콘솔에 unknown type 경고만 남습니다.


이미지 최적화

next-sanity

npm install next-sanity

next-sanity는 Next.js용 클라이언트와 미리보기·Studio 임베딩 도구를 묶은 패키지입니다. 아래 예제의 URL 빌더는 별도 패키지인 @sanity/image-url에서 가져오므로 npm install @sanity/image-url도 함께 설치해야 합니다.

import imageUrlBuilder from '@sanity/image-url';
import { client } from './sanity';
const builder = imageUrlBuilder(client);
export function urlFor(source: any) {
  return builder.image(source);
}
// 사용
<img
  src={urlFor(post.mainImage).width(800).height(400).url()}
  alt={post.title}
/>

Sanity 이미지 CDN은 URL 쿼리 파라미터(?w=800&h=400&fit=crop)로 크기 조정과 자르기를 요청 시점에 처리합니다. urlFor는 이 URL을 만들어 주는 빌더이고, 이미지 필드에 hotspot 정보가 있으면 자를 때 그 영역을 기준으로 삼습니다. .auto('format')을 붙이면 브라우저가 지원할 때 WebP나 AVIF로 변환해 전송합니다.

Next.js의 <Image />와 함께 쓰려면 next.config의 images.remotePatterns에 cdn.sanity.io를 등록해야 합니다. 등록하지 않으면 Invalid src prop ... hostname "cdn.sanity.io" is not configured under images 에러가 납니다. 이미 Sanity CDN이 크기를 조정해 주므로, Next.js 이미지 최적화까지 거치면 같은 일을 두 번 하게 됩니다. 이럴 때는 사용자 정의 loader로 Sanity URL을 직접 쓰거나 unoptimized를 지정하는 방법이 있습니다.


실시간 업데이트

client.listen()은 쿼리에 해당하는 문서가 바뀔 때마다 변경 이벤트를 스트리밍으로 받는 API입니다. 받을 수 있는 이벤트는 쿼리 결과 전체가 아니라 문서 단위의 변경(mutation)이라, 아래 예제의 “업데이트 로직” 자리에서 update.documentId와 update.result(변경 후 문서)를 보고 상태 배열에서 해당 항목을 교체·추가·삭제해야 합니다.

'use client';
import { useEffect, useState } from 'react';
import { client } from '@/lib/sanity';
export default function RealtimePosts({ initialPosts }) {
  const [posts, setPosts] = useState(initialPosts);
  useEffect(() => {
    const subscription = client
      .listen(`*[_type == "post"]`)
      .subscribe((update) => {
        if (update.type === 'mutation') {
          setPosts((prev) => {
            // 업데이트 로직
            return [...prev];
          });
        }
      });
    return () => subscription.unsubscribe();
  }, []);
  return (
    <ul>
      {posts.map((post) => (
        <li key={post._id}>{post.title}</li>
      ))}
    </ul>
  );
}

이 코드를 그대로 쓸 때 주의할 점이 몇 가지 있습니다. 첫째, listen은 GROQ의 필터만 지원하고 order나 프로젝션은 적용되지 않으므로, 정렬과 필드 선택은 클라이언트에서 다시 해야 합니다. 둘째, 브라우저에서 Sanity API를 호출하려면 Sanity 관리 화면의 API 설정에서 사이트 도메인을 CORS 허용 목록에 추가해야 합니다. 빠뜨리면 브라우저 콘솔에 CORS 에러가 나고 구독이 시작되지 않습니다. 셋째, 브라우저 클라이언트에 읽기 토큰을 넣으면 그 토큰이 모든 방문자에게 노출되므로, 비공개 데이터셋이나 초안을 실시간으로 보여줘야 한다면 서버를 거쳐야 합니다.

방문자가 많은 공개 페이지마다 실시간 연결을 여는 것은 동시 연결 수가 방문자 수만큼 늘어난다는 뜻이라 대부분 과합니다. 편집자 미리보기처럼 실시간이 꼭 필요한 곳에만 쓰고, 공개 페이지는 앞에서 설명한 Webhook 기반 재검증으로 처리하는 구성이 일반적입니다. 최근 next-sanity는 Live Content API를 감싼 defineLive 같은 도구도 제공하므로, 새로 구현한다면 직접 listen을 다루기 전에 그쪽을 먼저 검토할 만합니다.


정리 및 체크리스트

핵심 요약

  • Sanity: Headless CMS
  • 유연한 스키마: 완전히 커스터마이징
  • GROQ: 강력한 쿼리 언어
  • 실시간: 실시간 업데이트
  • Portable Text: 리치 텍스트
  • 이미지 처리: 자동 최적화

구현 체크리스트

  • Sanity 프로젝트 생성
  • Schema 정의
  • GROQ 쿼리 작성
  • Next.js 통합
  • 이미지 최적화
  • 실시간 업데이트 구현
  • 배포

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. WordPress와 비교하면 어떤가요?

A. Sanity는 콘텐츠 모델을 코드로 관리하고 프런트엔드를 자유롭게 고를 수 있다는 점에서 유연합니다. 대신 화면은 직접 개발해야 합니다. WordPress는 테마와 플러그인 생태계가 방대해 개발자 없이도 사이트를 운영할 수 있다는 점이 강점입니다.

Q. Contentful과 비교하면 어떤가요?

A. 둘 다 호스팅형 헤드리스 CMS입니다. Sanity는 스키마를 코드로 두고 Studio를 커스터마이징할 수 있으며 GROQ로 조회합니다. Contentful은 웹 UI에서 콘텐츠 모델을 정의하고 REST·GraphQL API를 제공합니다. 편집 화면을 직접 손보고 싶다면 Sanity, 설정 위주로 빠르게 운영하고 싶다면 Contentful 쪽이 편한 경우가 많습니다.

Q. 무료로 사용할 수 있나요?

A. 무료 플랜이 있습니다. 사용자 수, 문서 수, API 요청량 한도는 요금제 개편에 따라 바뀌므로 공식 가격 페이지에서 현재 조건을 확인하는 것이 정확합니다.

Q. 배포 후 수정한 콘텐츠가 사이트에 반영되지 않는 문제를 피하려면?

A. API CDN 캐시 지연, 초안 문서 노출, CORS 설정, 캐시 재검증 경로를 배포 전에 점검해야 운영 중 “수정이 반영되지 않는다”는 문제를 피할 수 있습니다.