Payload CMS - Next.js 안으로 들어가는 헤드리스 CMS

PayloadCMS헤드리스CMSNext.jsTypeScript

Payload CMS: 코드로 정의하고 화면으로 관리한다

Payload는 TypeScript 설정 파일 하나로 콘텐츠 구조를 정의하면, 관리자 화면과 타입과 API가 함께 만들어지는 헤드리스 CMS입니다.

가장 큰 특징은 배포 형태입니다. 별도 CMS 서버를 띄우고 API로 통신하는 방식이 아니라, Next.js 애플리케이션 안에 라이브러리처럼 설치됩니다. 관리자 화면은 앱의 한 경로가 되고, 콘텐츠를 읽는 코드는 네트워크를 거치지 않고 함수를 직접 호출할 수 있습니다.


왜 이런 구조가 나왔나

헤드리스 CMS는 보통 이렇게 동작합니다.

[CMS 서비스]  ──API──▶  [내 웹사이트]
 (별도 호스팅)            (Next.js 등)

이 구조는 CMS를 직접 운영하지 않아도 되는 대신 몇 가지 비용이 따라옵니다.

  • 콘텐츠를 가져올 때마다 네트워크 왕복이 생깁니다
  • 콘텐츠 구조가 바뀌면 타입을 수동으로 맞춰야 합니다
  • 데이터가 외부 서비스에 있어 이관이 어렵습니다
  • 요금이 대개 콘텐츠 양·API 호출량에 연동됩니다

Payload는 방향을 반대로 잡았습니다.

[Next.js 앱]
  ├── app/            페이지
  ├── app/(payload)/  관리자 화면
  └── payload.config.ts  콘텐츠 구조 정의
        │
        └──▶ [내 DB] (Postgres / MongoDB)

CMS가 앱 안에 있으므로 왕복이 없고, 설정에서 타입이 생성되며, 데이터는 내 데이터베이스에 있습니다.


콘텐츠 구조를 코드로 정의한다

Payload에서 콘텐츠 종류는 컬렉션(Collection) 으로 정의합니다.

// collections/Posts.ts
import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title',           // 목록에서 제목으로 쓸 필드
    defaultColumns: ['title', 'status', 'publishedAt'],
  },
  access: {
    read: () => true,              // 공개 읽기
    create: ({ req }) => Boolean(req.user),
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
      index: true,
    },
    {
      name: 'status',
      type: 'select',
      defaultValue: 'draft',
      options: [
        { label: '초안', value: 'draft' },
        { label: '공개', value: 'published' },
      ],
    },
    {
      name: 'coverImage',
      type: 'upload',
      relationTo: 'media',         // 미디어 컬렉션 참조
    },
    {
      name: 'content',
      type: 'richText',
    },
    {
      name: 'publishedAt',
      type: 'date',
    },
  ],
}

이 파일 하나에서 아래가 전부 나옵니다.

산출물내용
관리자 화면목록·작성·수정 폼이 필드 정의대로 생성
데이터베이스 스키마테이블·컬럼 생성 및 마이그레이션
TypeScript 타입Post 타입 자동 생성
REST API/api/posts 엔드포인트
GraphQL스키마와 리졸버

필드를 하나 추가하면 다섯 곳이 동시에 따라옵니다. 관리자 화면을 따로 만들지 않아도 되는 점이 실무에서 가장 큰 절약입니다.


네트워크 없이 데이터를 읽는다

Payload가 앱 안에 있으므로, 서버 컴포넌트에서 로컬 API로 직접 조회할 수 있습니다.

// app/blog/[slug]/page.tsx
import { getPayload } from 'payload'
import config from '@payload-config'

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const payload = await getPayload({ config })

  const { docs } = await payload.find({
    collection: 'posts',
    where: {
      slug: { equals: slug },
      status: { equals: 'published' },
    },
    limit: 1,
  })

  const post = docs[0]
  if (!post) return notFound()

  return <article>{/* ... */}</article>
}

payload.find()HTTP 요청이 아니라 함수 호출입니다. 같은 프로세스 안에서 DB를 조회하므로 왕복 지연이 없고, 반환값은 생성된 타입이 붙어 있어 자동 완성이 동작합니다.

외부에서 접근해야 할 때는 같은 컬렉션이 REST와 GraphQL로도 열려 있습니다. 모바일 앱이나 다른 서비스가 붙을 여지를 남겨 둡니다.


접근 제어가 필드 단위까지 내려간다

관리자 화면을 직접 만들 때 가장 지루하고 자주 틀리는 부분이 권한입니다. Payload는 이것을 설정에 함수로 적습니다.

fields: [
  {
    name: 'internalMemo',
    type: 'textarea',
    access: {
      // 편집자 이상만 이 필드를 읽고 쓸 수 있음
      read: ({ req }) => req.user?.role === 'editor' || req.user?.role === 'admin',
      update: ({ req }) => req.user?.role === 'admin',
    },
  },
]

컬렉션 단위뿐 아니라 필드 단위로 걸 수 있고, 조건에 현재 사용자와 문서를 함께 쓸 수 있어 "본인이 작성한 글만 수정" 같은 규칙도 그대로 표현됩니다.

access: {
  update: ({ req, id }) => {
    if (req.user?.role === 'admin') return true
    // admin이 아니면 자기 글만
    return { author: { equals: req.user?.id } }
  },
}

반환값이 true/false 뿐 아니라 조건 객체여도 된다는 점이 핵심입니다. 이 객체가 조회 쿼리에 그대로 합쳐지므로, 목록 화면에서도 자동으로 필터가 적용됩니다.


훅으로 부수 작업을 붙인다

콘텐츠가 저장되는 시점에 다른 일을 해야 할 때가 많습니다.

hooks: {
  beforeChange: [
    ({ data }) => {
      // slug 자동 생성
      if (!data.slug && data.title) {
        data.slug = slugify(data.title)
      }
      return data
    },
  ],
  afterChange: [
    async ({ doc, operation }) => {
      if (operation === 'update' && doc.status === 'published') {
        // 정적 페이지 재생성
        revalidatePath(`/blog/${doc.slug}`)
      }
    },
  ],
}

afterChange에서 revalidatePath를 호출하는 패턴이 Next.js와 특히 잘 맞습니다. 편집자가 관리자에서 글을 고치면 해당 페이지만 즉시 다시 생성됩니다. 전체 빌드를 돌릴 필요가 없습니다.


데이터베이스는 골라 쓴다

Payload는 DB 어댑터를 교체할 수 있습니다.

어댑터기반특징
@payloadcms/db-postgresDrizzle관계형 스키마, SQL 마이그레이션
@payloadcms/db-sqliteDrizzle파일 하나, 로컬·소규모에 적합
@payloadcms/db-mongodbMongoose유연한 스키마, 중첩 구조에 강함
// payload.config.ts
import { postgresAdapter } from '@payloadcms/db-postgres'

export default buildConfig({
  db: postgresAdapter({
    pool: { connectionString: process.env.DATABASE_URL },
  }),
  collections: [Posts, Media, Users],
})

Postgres 어댑터가 내부적으로 Drizzle을 쓰기 때문에, 마이그레이션 파일이 SQL로 남고 버전 관리에 올라갑니다. 스키마 변경 이력을 코드로 추적할 수 있다는 뜻입니다.


어디에 맞고 어디에 안 맞나

잘 맞는 경우

Next.js로 이미 만들고 있는 사이트에 관리 기능이 필요할 때. 별도 서비스를 붙이지 않고 앱 안에서 끝납니다.

콘텐츠 구조가 단순하지 않을 때. 제품마다 필드가 다르거나, 페이지를 블록 조립식으로 만들어야 하는 경우 설정으로 표현하기 좋습니다.

데이터를 직접 들고 있어야 할 때. 내 DB에 저장되므로 이관·백업·조회에 제약이 없습니다.

관리자 화면을 만드는 시간을 줄이고 싶을 때. 목록·검색·정렬·권한·이미지 업로드가 기본으로 나옵니다.

잘 안 맞는 경우

서버를 운영하고 싶지 않을 때. Payload는 실행 중인 Node 프로세스와 DB가 필요합니다. 완전 관리형 SaaS CMS와는 운영 부담이 다릅니다.

콘텐츠가 몇 장 안 되고 거의 안 바뀔 때. 마크다운 파일로 두는 편이 단순합니다.

Next.js가 아닌 스택일 때. REST·GraphQL로 붙일 수는 있지만, 앱 내부 설치라는 최대 장점이 사라집니다.

비개발자가 콘텐츠 구조까지 바꿔야 할 때. 필드 추가가 코드 수정이므로 배포가 필요합니다. 관리자 화면에서 구조를 바꾸는 CMS와는 전제가 다릅니다.


시작하기

npx create-payload-app@latest

기존 Next.js 프로젝트에 얹을 수도 있습니다.

npx create-payload-app@latest --name my-app

설치 후 구조는 대략 이렇게 됩니다.

app/
├── (payload)/
│   ├── admin/[[...segments]]/   관리자 화면
│   └── api/[...slug]/           REST API
├── (frontend)/                  실제 사이트
└── ...
payload.config.ts                콘텐츠 구조 정의

괄호로 묶인 폴더는 Next.js의 라우트 그룹이라 URL에는 나타나지 않습니다. 관리자와 사이트가 한 앱 안에서 레이아웃만 분리되어 공존합니다.


정리

Payload의 선택은 "CMS를 서비스로 쓸 것인가, 라이브러리로 쓸 것인가" 라는 질문에 후자로 답한 것입니다.

그 대가로 서버와 DB를 직접 운영해야 하지만, 대신 네트워크 왕복이 사라지고 타입이 자동으로 맞으며 데이터가 내 손에 남습니다. Next.js로 사이트를 만들면서 콘텐츠를 코드 배포 없이 고칠 수 있게 하는 것이 목표라면 검토해 볼 가치가 있습니다.

관련해서 Drizzle ORM은 Postgres 어댑터의 기반이고, Next.js는 Payload가 얹히는 프레임워크입니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.

비용이 궁금하면 외주 계산기로 먼저 확인해보세요.