CMS란?

코딩없이 콘텐츠를 생성, 조회, 수정 및 삭제(CRUD)할 수 있게 해주는 도구입니다.

CMS 없이 직접 파일로 관리한다면,

  • 글 하나를 작성하거나 사소한 오타를 수정하기 위해서도 git push 및 배포를 반복해야 함

CMS로 콘텐츠를 관리한다면,

  • 실제 포스팅 데이터(body)와 실제로 보여지는 템플릿(head)을 분리하여 컨텐츠 관리
  • 웹사이트 개발, 배포와 상관없이 비개발자도 손쉽게 콘텐츠를 관리

CMS vs headless CMS

두 가지의 차이는 내부 동작의 차이에 있습니다.

CMS

  • UI와 데이터가 php페이지에 바로 html 렌더링
  • 사용자에게 보이는 화면과 데이터를 모두 관리

headless CMS

  • 데이터가 UI와 완전히 분리된 곳에 존재
  • 쿼리와 api로 데이터 호출
  • 콘텐츠 하나로 웹, 앱, 다른 플랫폼까지 그대로 재사용 가능

sanity 설정

  1. sanity.io로그인 > Studios & Apps > New project 클릭
sanity.io로그인 > Studios & Apps > New project 화면
sanity.io로그인 > Studios & Apps > New project 클릭

2. 프로젝트 이름 입력 후 Create Project

sanity 프로젝트 제목 입력
sanity 프로젝트 제목 입력

3. sanity cli 다운로드 및 sanity 스튜디오 생성

sanity 스튜디오 생성 및 cli 다운로드 화면
sanity 스튜디오 생성 및 cli 다운로드

4. npm run dev 로 sanity 스튜디오 실행되는지 확인

첫 sanity 스튜디오 로컬 서버 실행
첫 sanity 스튜디오 로컬 서버 실행

5. 도큐먼트 타입 생성

sanity 도큐먼트 타입 생성 코드 화면
sanity 도큐먼트 타입 생성 코드

5. npm run dev sanity 스튜디오 재실행

스튜디오를 실행하면 지정한 도큐먼트 타입에 따라 데이터를 입력할 수 있습니다.

데이터를 모두 입력 후 publish하면 sanity에 콘텐츠를 저장할 수 있습니다.

Next.js에서 Sanity 데이터 불러오기

  1. 로컬에서 sanity 스튜디오 실행

2. 새로운 Next.js 프로젝트 생성 및 sanity사용을 위한 dependency 다운로드

3. Next.js 프로젝트에 sanity 설정 추가

4. 글 목록 화면 - 쿼리를 통해 sanity에 저장된 데이터 fetch 및 렌더링

5. 글 상세 화면 - 포스트 화면에 저장된 상세 데이터 fetch 및 렌더링

이미지 최적화하기

이미지 최적화란?

구분직접 관리할 때Sanity(@sanity/image-url)로 관리할 때
리사이즈업로드 전 직접 리사이즈 필요요청 시점에 CDN이 자동 처리
포맷 변환webp 등으로 직접 변환 필요CDN이 요청 시 자동 변환
사이즈별 관리화면마다 이미지를 여러 개 만들어 저장원본 하나만 업로드, width·height·crop 값만 지정해 그때그때 요청

next/image 기본 최적화 vs CDN 위임

두 방식의 차이는 리사이즈를 누가, 언제 처리하느냐에 있습니다.

next/image 기본 최적화

  • Next.js 서버가 이미지 요청마다 직접 리사이즈 처리
  • 외부 CDN 이미지를 쓰려면 remotePatterns 같은 별도 설정 필요

CDN 위임 (커스텀 로더)

  • 리사이즈 자체를 Sanity CDN에 그대로 위임
  • auto=format 파라미터로 브라우저에 맞는 포맷(webp/avif)을 CDN이 자동 선택
  • hotspot 기준으로 크롭 위치까지 CMS 단에서 미리 지정 가능

Next.js에 이미지 최적화 적용하기

  1. @sanity/image-url dependency 다운로드
npm install @sanity/image-url

2. urlFor 빌더 함수 작성 — 이미지 소스를 URL로 변환

// lib/sanity/image.ts
import { createImageUrlBuilder } from '@sanity/image-url';
import type { SanityImageSource } from '@sanity/image-url';
import { client } from './client';

const builder = createImageUrlBuilder(client);

export function urlFor(source: SanityImageSource) {
  return builder.image(source);
}

3. 커스텀 이미지 로더 작성 — width, quality 값을 CDN 쿼리스트링으로 매핑

// lib/sanity/image-loader.ts
'use client';

interface SanityLoaderArgs {
  src: string;
  width: number;
  quality?: number;
}

export default function sanityImageLoader({ src, width, quality }: SanityLoaderArgs) {
  const url = new URL(src);
  url.searchParams.set('auto', 'format');
  if (!url.searchParams.has('fit')) {
    url.searchParams.set('fit', 'max');
  }
  url.searchParams.set('w', width.toString());
  url.searchParams.set('q', (quality || 75).toString());
  return url.href;
}

4. next/imageloader prop에 커스텀 로더 연결

// components/PostCard.tsx
import Image from 'next/image';
import { urlFor } from '@/lib/sanity/image';
import sanityImageLoader from '@/lib/sanity/image-loader';

<Image
  src={urlFor(post.thumbnail).width(800).height(450).fit('crop').url()}
  loader={sanityImageLoader}
  alt={post.thumbnail.alt ?? post.title}
  fill
/>

5. 화면(썸네일, 목록 카드, 본문 이미지)마다 width·height·fit 값을 다르게 지정해 렌더링

// 포스트 상세 페이지 썸네일 — app/post/[...slug]/page.tsx
src={urlFor(post.thumbnail).width(1200).height(630).fit('crop').url()}

// 목록 카드 썸네일 — components/PostCard.tsx
src={urlFor(post.thumbnail).width(800).height(450).fit('crop').url()}

// 본문 삽입 이미지 — components/portable-text/PostBody.tsx
src={urlFor(value).width(1200).fit('max').url()}

Webhook으로 콘텐츠 자동 재배포하기

이미지까지 최적화했다면, 이제 마지막으로 남는 문제가 하나 있습니다. Sanity Studio에서 콘텐츠를 수정해도 화면에는 바로 반영되지 않는다는 점입니다. Next.js가 빌드 시점에 데이터를 가져와 정적으로 페이지를 생성해두기 때문인데, 이 문제를 해결하는 방법이 Webhook과 ISR(Incremental Static Regeneration) 연동입니다.

왜 콘텐츠가 바로 반영되지 않을까?

Next.js는 성능을 위해 페이지를 빌드 시점에 미리 HTML로 만들어둡니다. 문제는 콘텐츠가 그 이후에 바뀌었을 때입니다.

  • 재배포 없이 방치 — 콘텐츠를 수정해도 캐시된 예전 페이지가 계속 보임
  • 매번 전체 재배포 — 콘텐츠 하나 고칠 때마다 사이트 전체를 다시 빌드해야 해서 비효율적
  • Webhook + ISR 연동 — 콘텐츠가 바뀐 페이지만 감지해서 필요한 부분만 재생성

전체 재배포 vs On-demand ISR

두 방식의 차이는 재생성 범위를 얼마나 좁힐 수 있느냐에 있습니다.

전체 재배포 (Deploy Hook)

  • Sanity에서 발행 이벤트 발생 시 Vercel 전체 재배포 트리거
  • 설정이 간단하지만, 사이트 규모가 커질수록 재배포 시간이 길어짐
  • 트래픽이 많은 시간대에 재배포가 겹치면 순간적으로 부하 발생 가능

On-demand ISR (revalidatePath / revalidateTag)

  • 변경된 콘텐츠에 해당하는 페이지만 캐시 무효화
  • 나머지 페이지는 그대로 캐시 유지 → 재배포 시간과 서버 부하 최소화
  • 페이지 단위(revalidatePath) 또는 태그 단위(revalidateTag)로 세밀하게 제어 가능

Next.js에 Webhook 자동화 적용하기

  1. Sanity 프로젝트 설정 → API → Webhooks에서 콘텐츠 변경 감지 시 호출할 API 라우트 URL 등록
sanity 웹훅 설정 화면

2. Next.js에 Webhook을 받을 API 라우트 작성 (/app/api/revalidate/route.ts)

import { revalidatePath } from 'next/cache';
import { type NextRequest, NextResponse } from 'next/server';
import { parseBody } from 'next-sanity/webhook';

// Sanity 웹훅 Projection 예시: { "_type": _type, "slug": slug.current }
interface WebhookPayload {
  _type: string;
  slug?: string;
}

export async function POST(req: NextRequest) {
  const secret = process.env.SANITY_REVALIDATE_SECRET;
  if (!secret) {
    return NextResponse.json({ message: 'Secret이 설정되지 않았습니다' }, { status: 500 });
  }

  // Sanity가 보낸 요청의 서명을 secret으로 검증
  const { isValidSignature, body } = await parseBody<WebhookPayload>(req, secret);

  if (!isValidSignature) {
    return NextResponse.json({ message: '유효하지 않은 요청입니다' }, { status: 401 });
  }

  if (body?._type !== 'post') {
    return NextResponse.json({ revalidated: false });
  }

  // 홈과 해당 글 상세 페이지만 캐시 무효화
  revalidatePath('/');
  if (body.slug) {
    revalidatePath(`/post/${body.slug}`);
  }

  return NextResponse.json({ revalidated: true });
}
  • 요청 payload에서 어떤 문서(_type, slug)가 변경됐는지 파싱
  • 파싱한 정보를 바탕으로 revalidatePath() 또는 revalidateTag() 호출
  • Sanity Webhook의 시크릿 토큰을 검증 로직에 추가해 무단 요청 차단