Payload CMS - Next.js 안으로 들어가는 헤드리스 CMS
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-postgres | Drizzle | 관계형 스키마, SQL 마이그레이션 |
@payloadcms/db-sqlite | Drizzle | 파일 하나, 로컬·소규모에 적합 |
@payloadcms/db-mongodb | Mongoose | 유연한 스키마, 중첩 구조에 강함 |
// 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가 얹히는 프레임워크입니다.