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

발행 읽는 시간 약 5 분

  • Payload
  • CMS
  • 헤드리스CMS
  • Next.js
  • TypeScript

프로젝트 소개

별도 서버 없이 Next.js 앱 폴더 안에 설치되는 TypeScript 기반 헤드리스 CMS입니다. 설정 파일로 콘텐츠 구조를 정의하면 관리자 화면과 타입, API가 함께 생성됩니다.

GitHub 데모 보기

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

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

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


왜 이런 구조가 나왔나#

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

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

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

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

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

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

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


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

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

Ts
// 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로 직접 조회할 수 있습니다.

Ts
// 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는 이것을 설정에 함수로 적습니다.

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

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

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

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


훅으로 부수 작업을 붙인다#

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

Ts
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유연한 스키마, 중첩 구조에 강함
Ts
// 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와는 전제가 다릅니다.


시작하기#

Bash
npx create-payload-app@latest

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

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

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

Text
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가 얹히는 프레임워크입니다.

Follow · Ask · Work

이 글이 도움이 됐다면

짧은 작업 기록과 데모는 인스타그램에 올립니다. 만들고 싶은 것이 있다면 프로젝트 의뢰나 협업 제안도 편하게 보내 주세요.

글쓴이

파란여우

직접 만든 AI 앱·제품의 개발·배포를 공개하는 실전형 테크 크리에이터

파란여우 소개 Instagram @bluefox._.hi

이어서 보기

만들고 싶은 것이 있다면 프로젝트 의뢰로 알려 주세요.