Payload CMS 3.0: Next.js 위에 올라간 코드 우선 CMS, 컬렉션·필드, 접근 제어, 훅, 다국어·초안

이 글의 핵심

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 생태계에 맞춰 다시 구성한 메이저 릴리스입니다. 2.x의 Admin은 react-router 기반 단일 페이지 앱이었고 Express 서버 위에서 동작했지만, 3.0의 Admin은 Next.js App Router와 React Server Components 위에서 동작합니다. 그 결과 프런트엔드와 CMS를 하나의 Next.js 앱으로 배포할 수 있고, Vercel 같은 서버리스 플랫폼과도 잘 맞습니다.

이 글은 2.x 대비 변경점, 컬렉션·필드 설계, 접근 제어와 훅, 다국어와 초안, Admin 커스터마이징, Next.js 통합을 차례로 다루고 블로그 CMS 구성 순서로 마무리합니다. TypeScript와 Next.js App Router에 익숙하다는 전제입니다.


”코드 우선”의 의미

헤드리스 CMS는 스키마·관계·권한을 어디에 두느냐로 운영 방식이 갈립니다. 대시보드에서 클릭으로 스키마를 만드는 CMS와 달리, Payload는 이를 payload.config.ts와 컬렉션 정의 파일에 코드로 작성합니다. 그래서 스키마 변경을 Git으로 리뷰하고 되돌릴 수 있고, 타입 생성·테스트·CI를 애플리케이션 코드와 같은 파이프라인에서 다룰 수 있습니다.

3.0에서도 이 전제는 그대로이고, 바뀐 것은 코드가 어디서 실행되고 번들되느냐입니다. 도메인 모델과 API는 2.x와 거의 같지만, Admin 번들 방식, 환경 변수 규칙, 요청 객체 형태처럼 플랫폼과 맞닿은 부분은 마이그레이션 때 집중적으로 점검해야 합니다.


2.x 대비 주요 변경사항

공식 마이그레이션 가이드의 요지는 코어 로직은 같지만 HTTP 계층과 Admin 구현이 크게 바뀌었다는 것입니다.

Admin과 Next.js App Router

Admin 패널이 Next.js App Router 기반으로 다시 작성되었습니다. 라우팅·번들·서버 렌더링은 Next.js가 맡고, Payload는 설정·데이터·권한에 집중합니다. 프로젝트에는 app/(payload) 라우트 그룹이 생기고, 그 안의 Admin·API 라우트 파일은 템플릿이 만들어 줍니다. 프런트엔드는 같은 앱의 다른 라우트 그룹(예: app/(frontend))에 둡니다.

번들러 설정 제거와 패키지 버전 맞추기

2.x에서 Admin을 빌드하던 admin.bundler 설정과 @payloadcms/bundler-webpack·@payloadcms/bundler-vite 패키지는 더 이상 쓰지 않으므로 제거합니다. 대신 Next.js가 Admin을 번들합니다.

3.0에서는 payload 코어와 @payloadcms/* 어댑터·플러그인을 같은 버전으로 맞춰야 합니다. 코어만 올리고 DB 어댑터는 이전 버전으로 두면 내부 API가 맞지 않아 쉽게 깨집니다.

secret과 환경 변수

2.x에서 payload.init()에 넘기던 secret은 3.0에서 buildConfig의 secret 속성으로 옮겨 갑니다. 보통 PAYLOAD_SECRET 환경 변수에서 읽습니다.

브라우저에 노출할 환경 변수 접두어도 바뀌었습니다. 2.x의 PAYLOAD_PUBLIC_*는 더 이상 클라이언트에 자동 노출되지 않고, Next.js 규칙인 NEXT_PUBLIC_*를 따릅니다. Admin 커스텀 컴포넌트에서 환경 변수를 읽는다면 이름을 모두 바꿔야 합니다.

req 객체: Express Request에서 Web Request로

커스텀 엔드포인트, 접근 제어, 훅에서 받는 req가 Express Request가 아니라 Web 표준 Request를 확장한 객체로 바뀌었습니다. 헤더는 req.headers['content-type']이 아니라 req.headers.get('content-type')으로 읽어야 하고, 요청 본문도 이미 파싱된 req.body가 아니라 await req.json() 같은 방식으로 읽습니다(문서의 addDataAndFileToRequest 헬퍼 참고). Express 습관이 남은 코드는 대부분 이 부분에서 런타임 오류를 냅니다.

Admin 커스텀 컴포넌트와 스타일

커스텀 컴포넌트는 React 컴포넌트를 직접 import해 설정에 넣는 대신, '/components/Logo#Logo'처럼 파일 경로 문자열로 등록하고 Payload가 import map을 생성하는 방식으로 바뀌었습니다. 설정 파일이 서버와 클라이언트 양쪽에서 무거운 React 코드를 끌어오지 않게 하기 위한 변화입니다.

설정에 직접 지정하던 전역 admin.css·admin.scss 경로는 없어졌고, 템플릿의 app/(payload)/custom.scss에 Admin 스타일을 모읍니다.

이미지 처리와 sharp

업로드 컬렉션에서 imageSizes, resizeOptions, formatOptions, 포커스 포인트 같은 기능을 쓰려면 이미지 처리기가 필요합니다. 3.0에서는 sharp를 직접 설치하고 buildConfig({ sharp })로 넘겨야 하며, 넘기지 않으면 리사이즈가 동작하지 않습니다. 서버리스 환경에서는 sharp의 네이티브 바이너리가 배포 대상 플랫폼과 맞는지도 확인해야 합니다.

데이터베이스 마이그레이션

MongoDB나 Postgres 어댑터를 쓰던 프로젝트는 공식 안내에 따라 데이터 마이그레이션을 실행해야 합니다. 특히 Postgres 어댑터는 2.x 베타에서 3.0으로 오면서 관계 테이블 구조가 바뀌어 전용 마이그레이션이 제공됩니다. 코드만 올렸는데 데이터가 맞지 않는 문제는 대부분 이 단계를 빠뜨려서 생깁니다.

새로 추가된 기능

3.0 릴리스에서 강조된 기능으로는 역참조 목록을 보여 주는 Join 필드, 필요한 필드만 가져오는 select 쿼리, 백그라운드 작업 큐(Jobs), SQLite 어댑터, Vercel Postgres 어댑터, 실시간 미리보기 등이 있습니다. 모두 처음부터 도입할 필요는 없고 필요한 것만 골라 쓰면 됩니다.


프로젝트 시작

npx create-payload-app@latest

create-payload-app이 만든 package.json을 기준으로 Next.js, React, Payload 패키지 버전을 맞추는 것이 가장 안전합니다. 2.x에서 넘어오는 경우 Express, nodemon, 이전 번들러 패키지를 정리하고 payload, @payloadcms/next, @payloadcms/ui, 사용하는 DB 어댑터를 설치합니다. 모노레포라면 워크스페이스 전체에서 버전이 갈리지 않도록 고정합니다.


설정 파일

3.0에서도 설정의 중심은 buildConfig입니다. DB 어댑터, 리치 텍스트 에디터, 컬렉션과 글로벌, 플러그인을 한곳에 모읍니다.

// payload.config.ts
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import sharp from 'sharp'
import path from 'path'
import { fileURLToPath } from 'url'
import { Posts } from './collections/Posts'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

export default buildConfig({
  secret: process.env.PAYLOAD_SECRET || '',
  serverURL: process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000',
  db: mongooseAdapter({ url: process.env.DATABASE_URI || '' }),
  editor: lexicalEditor(),
  collections: [Posts],
  sharp,
  typescript: {
    outputFile: path.resolve(dirname, 'payload-types.ts'),
  },
})

secret이 빈 문자열이면 JWT 서명이 안전하지 않으므로, 실제 배포에서는 값이 없을 때 시작 단계에서 실패하게 만드는 편이 좋습니다. typescript.outputFile을 지정하면 컬렉션 정의로부터 타입이 생성되어(payload generate:types) 프런트엔드와 서버 코드 모두 필드 이름 오타를 컴파일 단계에서 잡을 수 있습니다. 스키마를 바꾸고 타입 재생성을 잊는 일이 잦으므로, CI에서 생성 결과와 커밋된 파일이 같은지 확인하는 단계를 두면 좋습니다.


컬렉션과 필드

컬렉션의 역할

컬렉션은 RDB의 테이블이나 MongoDB의 컬렉션에 대응하는 콘텐츠 타입입니다. slug, fields, access, hooks, versions를 묶어 한 도메인 단위로 설계합니다. 블로그라면 posts, categories, authors와 업로드 컬렉션인 media로 나누는 구성이 흔합니다.

자주 쓰는 필드 타입

필드용도
text, textarea제목, 요약, SEO 설명
richText본문. 3.x는 Lexical 에디터가 기본
relationship다른 문서 참조(글 → 카테고리·저자)
upload이미지·파일 참조
select, radio, checkbox상태, 템플릿 종류, 플래그
array, blocks반복 섹션, 페이지 빌더
group, tabs, row편집 화면 구성
join다른 컬렉션에서 이 문서를 참조하는 문서 목록(역참조)

blocks는 편집자에게 자유도가 높은 대신 프런트엔드가 블록 종류마다 렌더러를 가져야 하고 쿼리도 복잡해집니다. 처음에는 고정 필드나 단순한 array로 시작하고, 필요가 분명해질 때 blocks로 넓히는 편이 유지보수에 유리합니다.

관계 필드는 기본적으로 단방향 참조입니다. 카테고리 페이지에서 그 카테고리에 속한 글 목록을 보여 주고 싶다면, 예전에는 별도 쿼리를 작성해야 했지만 3.0에서는 categories 컬렉션에 join 필드를 두면 Admin과 API 응답에서 역참조 목록을 함께 받을 수 있습니다. 대신 조인 대상 컬렉션의 해당 필드에 인덱스가 있어야 목록이 커져도 느려지지 않습니다.

예시: posts 컬렉션

// collections/Posts.ts
import type { CollectionConfig } from 'payload'
import { formatSlug } from '../hooks/formatSlug'
import { publishedOrLoggedIn } from '../access/publishedOrLoggedIn'

export const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'slug', 'publishedAt', 'updatedAt'],
  },
  access: {
    read: publishedOrLoggedIn,
  },
  versions: {
    drafts: true,
  },
  fields: [
    { name: 'title', type: 'text', required: true, localized: true },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
      index: true,
      hooks: { beforeValidate: [formatSlug] },
    },
    { name: 'excerpt', type: 'textarea', localized: true },
    { name: 'content', type: 'richText', localized: true },
    { name: 'category', type: 'relationship', relationTo: 'categories' },
    { name: 'author', type: 'relationship', relationTo: 'users' },
    {
      name: 'publishedAt',
      type: 'date',
      index: true,
      admin: { date: { pickerAppearance: 'dayAndTime' } },
    },
  ],
}

어떤 필드에 인덱스를 둘지는 조회 패턴으로 정합니다. 목록 페이지에서 publishedAt 정렬과 카테고리 필터를 자주 쓴다면 두 필드에 인덱스를 두고, 상세 페이지를 slug로 조회한다면 slug는 unique 인덱스가 필요합니다.


접근 제어

Payload의 권한은 코드로 명시합니다. 컬렉션의 access 객체에 read, create, update, delete 함수를 두고, req.user와 요청 내용을 기준으로 판단합니다. 접근 함수는 true/false뿐 아니라 쿼리 제약(Where 객체)을 반환할 수 있다는 점이 핵심입니다. 쿼리 제약을 반환하면 Payload가 그 조건을 모든 조회에 자동으로 합쳐, 조건에 맞는 문서만 보이게 합니다.

초안은 비공개, 게시본만 공개

// access/publishedOrLoggedIn.ts
import type { Access } from 'payload'

export const publishedOrLoggedIn: Access = ({ req: { user } }) => {
  if (user) return true // 로그인한 편집자는 초안 포함 전체 조회
  return { _status: { equals: 'published' } } // 나머지는 게시된 문서만
}

흔히 하는 실수는 read 함수에서 doc을 받아 doc._status를 검사하는 것입니다. 목록 조회(find)에서는 read 함수가 개별 문서 없이 한 번만 호출되므로 doc이 없고, if (!doc) return true 같은 분기를 두면 목록 API로 초안이 그대로 노출됩니다. 문서별 판단이 필요한 경우라도 쿼리 제약을 반환하는 방식이 목록과 단건 조회 모두에 일관되게 적용됩니다.

작성자 본인만 수정하게 하려면 update에서 { author: { equals: user.id } }를 반환하고, 특정 필드만 숨기려면 필드 단위 access를 씁니다. 필드 단위 접근 함수는 쿼리 제약이 아니라 불리언만 반환할 수 있습니다.

접근 제어는 보안의 마지막 방어선이지만, 공개 API라면 요청 속도 제한, 봇 차단, 캐시 헤더 같은 주변 대책도 함께 두어야 합니다.


훅

훅은 문서 생명주기에 끼어드는 확장 지점입니다. beforeValidate, beforeChange, afterChange, afterRead, afterDelete 등이 있고, 슬러그 자동 생성, 파생 필드 계산, 검색 인덱스 동기화, 캐시 무효화, 감사 로그에 씁니다.

같은 저장이 Admin, REST API, Local API, 데이터 임포트처럼 여러 경로에서 일어나므로, 훅의 부수 효과는 여러 번 실행되어도 결과가 같도록(멱등하게) 만들어야 합니다. afterChange에서 무거운 외부 API를 기다리면 저장 응답이 그만큼 늦어지므로, 오래 걸리는 작업은 Jobs 큐로 넘깁니다. 훅에서 예외를 던지면 저장 전체가 실패하므로, 규칙 위반은 편집자가 이해할 수 있는 메시지로 던집니다. afterChange 안에서 같은 컬렉션을 다시 update하면 훅이 다시 실행되어 무한 루프가 생길 수 있으므로 context 플래그로 재진입을 막아야 합니다.

예시: 제목에서 슬러그 생성

// hooks/formatSlug.ts
import type { FieldHook } from 'payload'

export 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('NFKC')
    .toLowerCase()
    .replace(/[^\p{L}\p{N}\s-]/gu, '') // 한글 등 유니코드 문자와 숫자는 유지
    .trim()
    .replace(/\s+/g, '-')
}

슬러그 예제에서 흔히 쓰는 /[^\w\s-]/g는 \w가 ASCII 영문자·숫자만 뜻하므로 한글 제목을 넣으면 슬러그가 빈 문자열이 됩니다. 한국어 콘텐츠라면 위처럼 유니코드 속성 이스케이프를 쓰거나, 영문 슬러그를 편집자가 직접 입력하게 하는 편이 안전합니다. 제목이 localized: true라면 data.title이 현재 로케일 값이라는 점도 기억해 두세요.


다국어와 초안

Localization

buildConfig의 localization에서 로케일 목록, 기본 로케일, 폴백 여부를 정하고, 필드에 localized: true를 주면 로케일별 값이 따로 저장됩니다. 블로그라면 제목과 본문은 로컬라이즈하고, 슬러그를 로케일별로 다르게 할지 같은 URL 전략을 먼저 정해야 합니다. 조회할 때는 locale 파라미터로 원하는 언어를 지정하며, 폴백을 켜면 번역이 없는 필드는 기본 로케일 값으로 채워집니다. hreflang과 canonical은 프런트엔드에서 맞춰야 합니다.

Versions와 Drafts

versions: { drafts: true }를 켜면 문서에 _status 필드(draft/published)가 생기고, Admin에 초안 저장과 게시 버튼이 나뉩니다. 편집자가 초안으로 저장한 변경은 버전 기록에만 쌓이고, 게시해야 공개 데이터에 반영됩니다. API에서 최신 초안을 보려면 draft: true 옵션으로 조회합니다.

공개 사이트에서 초안이 보이지 않게 하는 일은 앞의 접근 제어로 처리하되, 서버 코드에서 쓰는 Local API는 기본값이 overrideAccess: true라 접근 제어를 건너뛴다는 점을 반드시 기억해야 합니다. 공개 페이지의 Local API 호출에는 _status 조건을 직접 넣거나 overrideAccess: false와 user를 함께 넘겨야 합니다.


Admin 커스터마이징

3.0 Admin은 Next.js 위에서 동작하므로 커스터마이징도 컴포넌트 교체, 프로바이더 주입, 스타일 덮어쓰기 중심입니다. admin.components로 로고·아이콘·네비게이션·대시보드 같은 부분 UI를 프로젝트 컴포넌트로 바꾸고, providers로 전역 컨텍스트를 주입하고, beforeLogin·beforeDashboard 같은 슬롯에 안내 문구나 내부 도구 링크를 넣을 수 있습니다. 필드 정의의 admin.components로 특정 필드의 입력 UI만 React 컴포넌트로 바꿀 수도 있습니다. 앞서 설명했듯 3.0에서는 이 컴포넌트들을 경로 문자열로 등록하고, 등록 후 payload generate:importmap으로 import map을 갱신합니다.

커스터마이징을 많이 할수록 업그레이드 비용도 커집니다. 팀이 작다면 스타일과 문구, 꼭 필요한 컴포넌트만 바꾸고, 복잡한 편집 요구는 필드 구조와 에디터 설정으로 해결하는 편이 장기적으로 유리합니다.


Next.js 통합

하나의 앱 안의 Payload

3.0에서는 Next.js 앱 안에 Payload의 Admin과 API 라우트가 함께 있으므로, 프런트엔드 페이지에서 HTTP를 거치지 않고 Local API로 바로 데이터를 가져올 수 있습니다. Local API도 훅과 필드 변환을 그대로 거치므로 DB에 직접 붙는 것보다 일관성이 있습니다.

// app/(frontend)/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',
    // Local API는 기본적으로 access를 건너뛰므로 게시 조건을 직접 지정
    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}</li>
        ))}
      </ul>
    </main>
  )
}

getPayload는 초기화된 인스턴스를 캐시하므로 요청마다 호출해도 됩니다. @payload-config는 템플릿의 tsconfig.json에 정의된 경로 별칭입니다. depth는 관계 필드를 몇 단계까지 실제 문서로 펼칠지 정하는데, 목록 페이지에서 깊게 펼치면 응답이 커지므로 카드 UI에 필요한 만큼만 지정합니다. 3.0의 select 옵션으로 필요한 필드만 가져오면 응답을 더 줄일 수 있습니다. 생성된 타입 덕분에 post.title은 별도 단언 없이 문자열로 추론됩니다.

캐시 무효화

Next.js에서 페이지를 정적으로 생성하거나 데이터를 캐시한다면, Payload에서 문서가 바뀔 때 해당 캐시를 무효화해야 합니다. 같은 앱 안이므로 컬렉션의 afterChange 훅에서 next/cache의 revalidatePath나 revalidateTag를 직접 호출할 수 있습니다. 실시간성이 덜 중요하다면 일정 시간마다 재생성하는 방식으로 단순하게 갈 수도 있습니다.

인증과 CORS

콘텐츠를 다른 도메인의 프런트엔드에서 소비한다면 buildConfig의 cors와 csrf 허용 출처, 쿠키의 SameSite 설정을 함께 점검해야 합니다. 회원 기능이 있는 사이트라면 NextAuth 같은 별도 인증과 Payload의 users 컬렉션을 어떻게 연결할지(같은 사용자 테이블을 쓸지, 동기화할지)가 아키텍처의 핵심 결정이 됩니다.


블로그 CMS 구성 순서

새 프로젝트라면 create-payload-app으로 시작해 DB와 환경 변수를 연결한 뒤, posts, categories, media(업로드), users로 컬렉션을 나누고 처음에는 필수 필드만 둡니다. 그다음 versions.drafts로 초안 흐름을 켜고, 공개 읽기 범위를 쿼리 제약 기반 접근 제어로 정합니다. 슬러그나 읽기 시간 같은 파생 값은 훅으로 채우고, Next.js App Router에 목록·상세 페이지를 만들어 Local API로 데이터를 가져옵니다. SEO 플러그인, 사이트맵, RSS는 그다음에 필요에 따라 추가합니다.

운영 단계에서는 DB 백업과 함께 업로드 파일 저장소(S3 등)도 백업 대상에 넣어야 합니다. 업로드 파일을 서버 로컬 디스크에 두면 서버리스나 다중 인스턴스 배포에서 파일이 사라지거나 인스턴스마다 달라지므로, 클라우드 스토리지 어댑터를 쓰는 것이 기본입니다. Postgres 어댑터를 쓴다면 스키마 변경 시 payload migrate:create로 마이그레이션 파일을 만들고 배포 파이프라인에서 payload migrate를 실행하는 순서를 정해 두어야 합니다. Admin 경로는 기본값(/admin)을 바꾸거나 접근을 제한하는 방안도 조직 정책에 맞춰 검토합니다.


트러블슈팅

클라이언트 컴포넌트에서 환경 변수가 undefined라면 NEXT_PUBLIC_ 접두어가 붙었는지, 빌드 시점에 값이 주입되었는지 확인합니다. Next.js는 이 값을 빌드 결과물에 문자열로 박아 넣으므로, 배포 후 환경 변수만 바꾸면 반영되지 않습니다. 커스텀 엔드포인트에서 헤더나 본문을 읽다가 오류가 난다면 Express 방식의 접근이 남아 있지 않은지 봅니다. 이미지 리사이즈가 되지 않는다면 sharp를 설치하고 buildConfig에 넘겼는지, 배포 플랫폼용 바이너리가 설치되었는지 확인합니다. 커스텀 Admin 컴포넌트가 보이지 않는다면 import map을 다시 생성했는지 확인합니다. 타입이 실제 스키마와 다르다면 payload-types.ts를 다시 생성합니다.


참고


같이 보면 좋은 글