Payload CMS 3.0 완벽 가이드 — 코드 우선 헤드리스 CMS·Next.js·타입세이프
이 글의 핵심
Payload 3.0은 Admin을 Next.js App Router·RSC 위에 올리고, 설정·스키마·비즈니스 로직을 TypeScript 코드로 유지하는 코드 우선 헤드리스 CMS입니다. 이 글에서는 2.x 대비 변경점, 컬렉션·필드, access·hooks, localization·draft, Admin 확장, Next.js 통합 패턴, 블로그 CMS 예시까지 한 흐름으로 다룹니다.
이 글의 핵심
Payload CMS 3.0은 “콘텐츠 모델과 권한·훅을 코드로 소유한다”는 철학을 유지하면서, 운영·번들·배포 경험을 Next.js 생태계에 맞춰 재구성한 메이저 릴리스입니다. Admin 패널이 React Router 단일 페이지에서 벗어나 Next.js App Router와 React Server Components를 전제로 하며, Payload 코어와 HTTP·렌더링 계층이 분리되어 Node 환경에서의 이식성과 서버리스 배포(예: Vercel)와의 궁합이 좋아졌습니다.
이 글에서는 2.x 대비 주요 변경사항, 컬렉션·필드 타입 설계, Access Control·Hooks, 다국어(i18n)와 버전·초안(Draft)·게시, Admin UI 커스터마이징, Next.js 통합 패턴, 마지막으로 실전 블로그형 CMS를 예시로 묶어 설명합니다. 독자는 TypeScript·Next.js App Router 개념, REST·헤드리스 API 경험이 있으면 설명의 밀도를 온전히 활용할 수 있습니다.
1. Payload 3.0이 말하는 “코드 우선”이란
헤드리스 CMS는 보통 스키마(필드)·관계·권한을 어디에 두느냐로 운영 방식이 갈립니다. Payload는 이를 저장소에 숨기지 않고 payload.config.ts와 컬렉션 정의에 명시적으로 코딩합니다. 그 결과 Git으로 리뷰·롤백이 가능하고, 타입 생성·테스트·CI까지 같은 파이프라인으로 가져갈 수 있습니다.
3.0에서는 이런 “코드가 진실의 원천”이라는 전제는 유지되면서, 어디서 실행·번들되느냐가 바뀌었습니다. 즉 도메인 모델과 API 계약은 유사하지만, Admin 번들 방식·환경 변수 규칙·요청 객체 형태 등 플랫폼 경계에 해당하는 부분은 마이그레이션 시 집중 점검이 필요합니다.
2. Payload 2.x 대비 3.0 주요 변경사항
공식 마이그레이션 가이드의 요지는 “코어 로직은 동일하지만 HTTP 계층과 Admin 구현이 크게 바뀌었다”는 것입니다. 실무에서 자주 마주치는 포인트만 압축해 정리합니다.
2.1 Admin과 Next.js App Router
Admin 패널이 Next.js App Router 기반으로 재작성되었습니다. 이는 단순한 UI 교체가 아니라, 서버 컴포넌트·라우팅·번들을 Next가 담당하고 Payload는 설정·데이터·권한에 집중하는 구조로 나아간 변화입니다. 그래서 3.0 프로젝트는 흔히 단일 리포지토리 안에 프론트·CMS·API가 함께 존재합니다.
2.2 admin.bundler 제거와 패키지 정렬
2.x에서 쓰이던 webpack/vite 번들러 패키지는 더 이상 Admin을 위해 쓰이지 않습니다. admin.bundler 설정을 삭제하고, @payloadcms/bundler-webpack·@payloadcms/bundler-vite는 제거하는 것이 마이그레이션의 한 축입니다. 대신 Next.js가 Admin을 번들합니다.
3.0에서는 payload 코어와 @payloadcms/* 어댑터·플러그인 버전을 동기화해야 합니다. 2.x 시절처럼 코어만 최신으로 올리고 DB 어댑터는 오래된 버전을 유지하는 식의 조합이 깨지기 쉽습니다.
2.3 secret은 설정 파일로
이전에는 payload.init() 등 초기화 코드 쪽에 두던 시크릿이, 3.0에서는 buildConfig의 secret으로 옮겨지는 것이 일반적입니다. 배포 환경 변수(PAYLOAD_SECRET)와 1:1로 연결해 관리합니다.
2.4 환경 변수: 클라이언트는 NEXT_PUBLIC
2.x에서 익숙했던 PAYLOAD_PUBLIC_* 접두사는 클라이언트에서 기대한 방식으로 쓰이지 않습니다. 브라우저에서 필요한 값은 NEXT_PUBLIC_ 규칙을 따릅니다. Admin 커스텀 컴포넌트나 클라이언트 훅에서 환경 값을 읽을 때 이 차이를 반드시 반영해야 합니다.
2.5 req 객체: Express Request → Web Request 스타일
커스텀 엔드포인트·접근 제어·훅에서 쓰는 req가 Express의 Request가 아니라 Web Request 계열에 가깝게 정리되었습니다. 예를 들어 헤더 접근은 req.headers.get('content-type')처럼 맵 형태가 아닌 API를 사용하는 편이 안전합니다. 기존 Express 습관이 남아 있으면 마이그레이션 시 여기서 런타임 오류가 자주 납니다.
2.6 스타일: admin.css / admin.scss 제거
설정에 직접 넣던 글로벌 CSS/SCSS 경로 프로퍼티가 제거되었습니다. 대부분은 템플릿이 제공하는 (payload)/custom.scss 등에 스타일을 모으거나, admin.components.providers로 스타일을 주입하는 방식으로 옮깁니다. Tailwind를 Admin에 섞는 경우에도 이 경로를 통해 정리하는 경우가 많습니다.
2.7 이미지 처리와 sharp
업로드 컬렉션에서 formatOptions·imageSizes·resizeOptions 등을 쓰면 이미지 처리기가 필요합니다. 2.x에서는 의존성이 끌려왔을 수 있지만 3.0에서는 필요할 때 명시적으로 sharp를 설치하고 buildConfig에 넘기는 패턴이 문서화되어 있습니다. 운영 환경(특히 서버리스)에서 네이티브 바이너리 이슈가 없는지도 함께 확인합니다.
2.8 데이터베이스 마이그레이션
MongoDB·Postgres 어댑터를 쓰는 기존 프로젝트는 스키마 마이그레이션을 공식 안내에 따라 실행해야 합니다. “코드만 올렸는데 DB가 안 맞는다”는 이슈는 대개 이 단계 누락에서 비롯됩니다.
2.9 신규·강화된 기능(개략)
릴리스 노트·블로그에서 강조되는 방향으로는 Join 필드(양방향 관계 표현)、필드 단위 select 쿼리、작업 큐(Jobs)、SQLite 어댑터、Vercel Postgres 같은 서버리스 친화 DB 옵션、Live Preview 등이 있습니다. 이 중 필요한 것만 골라 도입하면 초기 학습 부담을 줄일 수 있습니다.
3. 프로젝트 부트스트랩과 의존성
가장 빠른 시작은 공식 create-payload-app입니다. 생성된 package.json의 peer·dev·dependencies를 기준으로 Next.js·React·Payload 패키지 세트를 한 번에 맞추는 것이 안전합니다.
npx create-payload-app@latest
2.x에서 넘어온 경우 마이그레이션 가이드에 나온 대로 Express·nodemon·구 번들러 패키지를 정리하고, payload·@payloadcms/ui·@payloadcms/next 및 사용 중인 DB 어댑터를 설치합니다. 모노레포라면 워크스페이스 루트에서 버전이 갈리지 않게 고정하는 것이 좋습니다.
4. 설정 파일과 최소 구조 이해
3.0에서도 중심은 buildConfig입니다. 데이터베이스 어댑터, 에디터(예: Lexical), 컬렉션·글로벌, 플러그인, 서버 URL 등을 한곳에 모읍니다. 아래는 개념 설명용으로 필드를 최소만 둔 예시입니다.
// payload.config.ts — 개념 예시(프로젝트에 맞게 조정)
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { fileURLToPath } from 'url'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
secret: process.env.PAYLOAD_SECRET || '',
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000',
db: mongooseAdapter({ url: process.env.DATABASE_URI || '' }),
editor: lexicalEditor(),
collections: [],
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})
typescript.outputFile을 켜두면 컬렉션 스키마로부터 타입이 생성되어, 프론트·서버 모두에서 필드 오타를 줄이는 데 도움이 됩니다. 팀 단위 프로젝트에서는 이 파일을 CI 산출물처럼 취급하는 편이 좋습니다.
5. 컬렉션(Collection)과 필드(Field) 타입
5.1 컬렉션의 역할
컬렉션은 RDB의 테이블이나 Mongo의 컬렉션에 대응하는 콘텐츠 타입입니다. slug·fields·access·hooks·versions 등을 묶어 한 도메인 단위로 설계합니다. 블로그라면 posts, categories, authors, 미디어는 media 업로드 컬렉션으로 나누는 식이 흔합니다.
5.2 자주 쓰는 필드 타입
실무에서 반복되는 필드는 다음과 같습니다.
text/textarea: 제목, 요약, SEO 설명 등 짧은 문자열.richText: 본문. 3.x에서는 Lexical 기반 에디터 구성이 일반적입니다. 마크다운을 고집한다면 변환 파이프라인을 별도로 두는 팀도 있습니다.relationship: 다른 문서와의 연결. 블로그 글 → 카테고리·저자 연결이 대표적입니다.upload: 이미지·파일. 썸네일·반응형 크기(imageSizes)는 스토리지·CDN 전략과 함께 설계합니다.select/radio/checkbox: 노출 상태, 템플릿 키, 플래그성 옵션.array/blocks: 반복 섹션, 유연한 페이지 빌더. 에디터 부담과 쿼리 복잡도의 트레이드오프가 있으므로, 초기에는array를 단순하게 시작하는 편이 안전합니다.group/tabs: 편집 화면 가독성. 필드가 늘수록 관리자 경험을 위해 중요해집니다.slug: URL 친화 식별자.title에서 자동 생성하려면 훅으로 동기화합니다.
5.3 관계와 Join(개념)
관계 필드는 단방향 참조를 기본으로 이해하면 됩니다. 양방향으로 “역참조 목록”을 보여주고 싶다는 요구는 3.0에서 Join 필드로 다루는 시나리오가 문서화되어 있습니다. 다만 쿼리 비용·인덱스·캐시 무효화를 함께 설계해야 운영 단계에서 발목을 잡지 않습니다.
5.4 예시: posts 컬렉션 스케치
아래는 필드 구성 예시입니다. 프로젝트의 실제 슬러그·관계 필수 여부·에디터 설정에 맞게 조정해야 합니다.
// collections/Posts.ts — 예시
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'publishedAt', 'updatedAt'],
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true, index: true },
{
name: 'excerpt',
type: 'textarea',
},
// richText는 프로젝트에서 lexicalEditor 등으로 연결
// { name: 'content', type: 'richText', required: true },
{
name: 'category',
type: 'relationship',
relationTo: 'categories',
hasMany: false,
},
{
name: 'author',
type: 'relationship',
relationTo: 'authors',
hasMany: false,
},
{
name: 'publishedAt',
type: 'date',
admin: { date: { pickerAppearance: 'dayAndTime' } },
},
],
}
이 스케치에서 어떤 필드를 인덱스할지는 조회 패턴에 따라 결정합니다. 목록 페이지에서 날짜·카테고리별 필터를 자주 쓴다면 해당 필드에 인덱스·복합 인덱스를 검토합니다.
6. Access Control(접근 제어)
Payload의 권한은 역할을 코드로 명시한다는 점에서 강력합니다. access 객체에 read·create·update·delete 등을 함수로 두고, req.user·컬렉션 컨텍스트·문서 내용을 기준으로 판단합니다.
6.1 패턴
- 공개 읽기, 쓰기는 관리자만: 블로그 글의
read는draft가 아닐 때만 공개,create는 로그인 사용자만. - 작성자 본인만 수정: 문서의
author필드와req.user를 비교. - 필드 단위:
field.access로 특정 필드만 운영자에게 보이게 할 수 있습니다.
6.2 예시: 초안은 비공개
// access 예시 — 프로젝트의 User 컬렉션 slug에 맞게 수정
import type { Access } from 'payload'
export const isLoggedIn: Access = ({ req: { user } }) => Boolean(user)
export const publicReadPublished: Access = ({ req: { user }, doc }) => {
if (!doc) return true
if (doc._status === 'published') return true
return Boolean(user)
}
접근 제어는 보안의 최종 방어선이지만, 퍼블릭 API와 함께 쓸 때는 Rate limit·봇 대응·캐시 헤더 같은 주변 대책도 함께 두어야 합니다. “접근 제어만 믿고 민감 필드를 넣지는 말 것”이 실무 원칙입니다.
7. Hooks
훅은 문서 생명주기에 개입하는 확장점입니다. 대표적으로 beforeValidate·beforeChange·afterChange·afterRead 등이 있으며, 슬러그 자동 생성·집계 필드 갱신·외부 검색 인덱스 동기화·감사 로그에 쓰입니다.
7.1 설계 시 유의점
- 재진입과 멱등성: 같은 저장이 여러 경로(Admin, API, 임포트)에서 일어날 수 있습니다. 훅은 부수 효과를 최소화하거나 멱등하게 만드는 편이 안전합니다.
- 성능:
afterChange에서 무거운 외부 API를 호출하면 저장 지연이 커집니다. 필요하면 Jobs 큐나 백그라운드 워커로 넘깁니다. - 오류 처리: 훅에서 예외를 던지면 저장이 실패합니다. 비즈니스 규칙 위반은 명확한 메시지로 돌려주는 것이 좋습니다.
7.2 예시: slug 자동 채우기(개념)
// hooks 예시 — 필드 정의 안에 넣거나 collection hooks로 분리 가능
import type { FieldHook } from 'payload'
const formatSlug: FieldHook = ({ value, originalDoc, data }) => {
if (typeof value === 'string' && value.length > 0) return value
const fromTitle = (data?.title || originalDoc?.title) as string | undefined
if (!fromTitle) return value
return fromTitle
.normalize('NFKD')
.replace(/[^\w\s-]/g, '')
.trim()
.replace(/\s+/g, '-')
.toLowerCase()
}
// 필드 쪽: { name: 'slug', type: 'text', hooks: { beforeValidate: [formatSlug] } }
8. 다국어(Localization)와 Draft / Publish
8.1 Localization
localization 설정으로 로케일 목록·기본 로케일·폴백을 정의하고, 필드에 localized: true를 주면 로케일별 값을 저장합니다. 블로그라면 제목·본문은 로컬라이즈, 슬러그는 로케일별로 다를지 같은 URL 전략을 먼저 합의하는 것이 좋습니다. SEO 관점에서는 hreflang·canonical을 프론트에서 맞춰야 합니다.
8.2 Versions·Drafts
versions: { drafts: true }를 켜면 초안과 게시본을 나누어 다루는 흐름이 가능해집니다. 편집 중에는 초안만 갱신하고, 게시 액션에서 공개 버전을 확정하는 패턴이 흔합니다. 퍼블릭 사이트의 쿼리에서는 _status === 'published' 조건을 빼먹지 않도록 프론트·API 레이어 모두에서 검증합니다.
9. Admin UI 커스터마이징
3.0 Admin은 Next.js 위에서 동작하므로, 커스터마이징도 컴포넌트 교체·프로바이더 주입·스타일 오버레이 중심으로 생각합니다.
admin.components: 로고, 네비게이션, 대시보드 등 부분 UI를 프로젝트 컴포넌트로 교체.providers: 전역 컨텍스트·테마·스타일시트 로딩.beforeLogin·beforeDashboard등: 온보딩·내부 도구 링크 삽입.- 필드
admin.components: 특정 필드의 입력 UI를 React로 교체.
커스터마이징은 강력하지만 업그레이드 비용도 커집니다. 팀 규모가 작다면 스타일·문구·최소 컴포넌트만 건드리고, 복잡한 편집 UI는 에디터 설정과 필드 구조로 해결하는 편이 유지보수에 유리한 경우가 많습니다.
10. Next.js 통합
10.1 단일 앱 안의 Payload
3.0의 큰 그림은 Next.js 앱 내부에 Payload 라우트를 두고, 프론트와 동일한 배포 단위로 운영하는 것입니다. App Router의 Route Handler·서버 컴포넌트에서 Local API를 호출해 빌드 타임·런타임 모두에서 데이터를 가져올 수 있습니다.
10.2 서버에서 Payload 인스턴스 얻기
문서·템플릿에 따라 getPayload 등의 헬퍼로 캐시된 Payload 인스턴스를 얻는 패턴이 소개됩니다. 서버 컴포넌트에서 직접 DB에 붙는 대신 Local API를 쓰면 접근 제어·훅·필드 변환을 한 번에 태울 수 있다는 이점이 있습니다.
// app/(site)/posts/page.tsx — 개념 예시
import { getPayload } from 'payload'
import config from '@payload-config'
export default async function PostsPage() {
const payload = await getPayload({ config })
const posts = await payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
sort: '-publishedAt',
limit: 20,
depth: 1,
})
return (
<main>
<h1>Posts</h1>
<ul>
{posts.docs.map((post) => (
<li key={post.id}>{post.title as string}</li>
))}
</ul>
</main>
)
}
depth는 관계 필드를 얼마나 따라 펼칠지를 정합니다. 목록 페이지에서 과도한 depth는 응답을 무겁게 만들므로, 카드 UI에 필요한 최소만 선택합니다.
10.3 캐싱과 재검증
Next.js 15 계열에서는 fetch 캐싱 정책과 태그 기반 재검증이 중요합니다. Payload에서 문서가 바뀔 때 프론트 캐시를 무효화하려면 afterChange 훅에서 재검증 API를 호출하거나, 시간 기반 ISR로 타협합니다. 이 부분은 트래픽·실시간성 요구에 따라 설계가 갈립니다.
10.4 미들웨어·인증·CORS
헤드리스 소비가 별도 도메인이라면 CORS·쿠키 SameSite를 점검합니다. 회원제 블로그라면 NextAuth 등과 Payload 유저를 어떻게 맞출지(동일 DB·동기화·SSO)가 아키텍처 핵심이 됩니다.
11. 실전: 블로그형 CMS 구축 절차
아래는 신규 프로젝트를 가정한 권장 순서입니다.
create-payload-app으로 스캐폴딩하고 DB·환경 변수를 연결합니다.- 컬렉션을 쪼갭니다:
posts·categories·authors·media. 처음부터 빌더형blocks로 가지 말고 필수 필드만. versions.drafts로 초안 흐름을 잡고, 퍼블릭 라우트에서는 게시된 문서만 노출합니다.- Access Control으로 읽기 공개 범위를 명확히 합니다.
- 훅으로 슬러그·요약·읽기 시간 등 파생 값을 채웁니다.
- Next.js App Router에 목록·상세 페이지를 만들고, Local API로 데이터를 가져옵니다.
- SEO 플러그인·사이트맵·RSS가 필요하면 단계적으로 추가합니다.
11.1 운영 체크리스트
- 백업: DB 스냅샷·업로드 스토리지( S3 등 ) 수명 주기.
- 마이그레이션: 스키마 변경 시 운영 반영 순서.
- 관측: 에러 로그·느린 쿼리·업로드 실패율.
- 보안: 관리자 URL 노출 최소화, 2FA·IP 제한 등 조직 정책 반영.
12. 트러블슈팅 메모
- 환경 변수가 클라이언트에 안 보임:
NEXT_PUBLIC_규칙과 빌드 시점 주입을 확인합니다. - 헤더 접근 오류: Express 스타일 인덱싱 대신 Web API 스타일을 사용합니다.
- 이미지 리사이즈 실패:
sharp설치·런타임 호환성을 확인합니다. - 타입 불일치:
payload-types.ts생성이 최신인지, CI에서 재생성을 강제합니다.
13. 정리
Payload 3.0은 코드 우선 헤드리스 CMS라는 본질은 유지한 채, Next.js·App Router·서버 컴포넌트라는 현대적 런타임에 맞게 배포와 Admin 경험을 재정렬한 릴리스입니다. 컬렉션·필드로 도메인을 고정하고, Access·Hooks로 규칙을 강제하며, Localization·Drafts로 운영 흐름을 반영한 뒤, Next.js에서 Local API로 안전하게 소비하는 구조를 익히면 블로그부터 마케팅 사이트까지 한 코드베이스로 확장하기 수월합니다.
참고
- Payload 공식 문서: https://payloadcms.com/docs
- 2.x → 3.x 마이그레이션 개요: https://github.com/payloadcms/payload/blob/main/docs/migration-guide/overview.mdx