
회사 홈페이지를 새로 만들어야 했다. 기존에 쓰던 템플릿 사이트는 수정할 때마다 개발자 손을 거쳐야 했고, 콘텐츠 하나 바꾸는 데 PR을 올려야 하는 상황이 반복됐다. 그래서 이번엔 비개발자도 콘텐츠를 직접 관리할 수 있으면서, 개발자는 코드로 구조를 통제할 수 있는 아키텍처를 찾았다.
결론부터 말하면 PayloadCMS 3.x + Next.js 15 App Router 조합을 골랐다. Headless CMS가 콘텐츠 관리를 담당하고, Next.js가 프론트엔드를 렌더링하는 구조다. 이 글에서는 프로젝트 초기화부터 디자인 시스템 설정, 블록 기반 페이지 빌더 구현까지의 과정을 정리한다.
Headless CMS 후보는 여럿 있었다. Strapi, Sanity, Contentful 등을 비교했는데, PayloadCMS를 선택한 건 몇 가지 이유가 있다.
pnpm generate:types 한 번이면 Payload 타입을 자동 생성해준다.https://도메인/admin으로 접근하면 된다.Next.js 15를 선택한 건 App Router가 안정화됐고, React 19의 Server Components를 제대로 활용할 수 있어서다. Tailwind CSS는 디자인 토큰을 코드로 관리하기에 가장 편했다.
프로젝트 초반에 디자인 토큰부터 잡았다. 색상, 폰트, 타이포그래피 스케일, 그림자, 라운딩 값을 tailwind.config.ts에 한곳에 모아두면 나중에 디자인 변경이 훨씬 수월해진다.
// tailwind.config.ts
colors: {
background: { primary: "#FAFAFA", secondary: "#F5F5F5" },
foreground: { primary: "#1A1A1A", secondary: "#6B6B6B" },
accent: { DEFAULT: "#4A6CF7", hover: "#3B5AE0" },
},
fontFamily: {
sans: ["Pretendard", "-apple-system", "sans-serif"],
},
fontSize: {
hero: ["3rem", { lineHeight: "1.2" }],
h1: ["2.25rem", { lineHeight: "1.3" }],
h2: ["1.75rem", { lineHeight: "1.4" }],
h3: ["1.25rem", { lineHeight: "1.5" }],
},포인트가 몇 가지 있다. background과 foreground를 의미 기반(semantic)으로 나눠서 다크 모드 대응을 쉽게 했다. Pretendard 폰트는 한글 가독성이 좋고 웹폰트 지원이 안정적이다. 타이포그래피 스케일은 hero부터 h3까지 네 단계로 잡아서 페이지 위계를 명확하게 표현한다.
PayloadCMS에서 콘텐츠 구조는 컬렉션(Collection)으로 정의한다. 이번 프로젝트에서는 5개 컬렉션을 만들었다.
Pages 컬렉션이 핵심인데, 여기에 블록 기반 페이지 빌더 패턴을 적용했다. PayloadCMS의 blocks 필드 타입을 활용하면 관리자가 레이아웃 블록을 레고처럼 조합해서 페이지를 만들 수 있다.
정의한 블록은 5개다.
WordPress의 Gutenberg나 Strapi의 Dynamic Zone과 비슷한 개념이다. 차이점은 블록 정의가 코드(TypeScript)로 되어 있어서 타입 안전성을 보장받는다는 점이다.
프론트엔드에서는 blockType 값을 기준으로 블록 컴포넌트를 동적으로 렌더링한다. switch-case로 단순하게 구현했다.
export function RenderBlocks({ blocks }: { blocks: LayoutBlock[] }) {
return blocks.map((block, i) => {
switch (block.blockType) {
case "hero":
return <HeroBlockComponent key={i} block={block} />
case "features":
return <FeaturesBlockComponent key={i} block={block} />
case "content":
return <ContentBlockComponent key={i} block={block} />
case "cta":
return <CTABlockComponent key={i} block={block} />
case "imageText":
return <ImageTextBlockComponent key={i} block={block} />
default:
return null
}
})
}이 패턴이 좋은 건 새 블록을 추가할 때 영향 범위가 명확하다는 거다. 블록 정의 파일 하나, 컴포넌트 하나, switch case 한 줄만 추가하면 된다. 기존 블록에는 전혀 영향을 주지 않는다.
URL 경로에 쓸 slug를 매번 수동으로 입력하는 건 번거롭다. PayloadCMS의 beforeValidate 훅에서 title 값을 kebab-case로 자동 변환하도록 만들었다.
// fields/slug.ts
hooks: {
beforeValidate: [({ value, data }) => {
if (!value && data?.[fieldToUse]) {
return data[fieldToUse]
.toLowerCase()
.replace(/[^a-z0-9가-힣]+/g, "-")
.replace(/^-|-$/g, "")
}
return value
}],
}정규식에서 가-힣 범위가 한글 음절이다. 영문과 숫자, 한글만 남기고 나머지는 하이픈으로 치환한다. 관리자가 slug를 직접 입력하면 그 값을 그대로 사용하고, 비어 있을 때만 title에서 자동 생성한다.
PayloadCMS는 리치 텍스트 에디터로 Lexical을 쓴다. DB에는 Lexical JSON 형식으로 저장되는데, 프론트엔드에서 이걸 React 컴포넌트로 변환해야 한다. 노드 타입별로 매핑하는 시리얼라이저를 직접 구현했다.
heading, paragraph, list, code, link, quote, upload 등의 노드를 각각 대응하는 React 컴포넌트로 렌더링한다. 코드 블록은 code-highlight 노드를 줄 단위로 처리하고, 이미지는 upload 노드의 Media ID를 참조해서 보여준다.
이 초기 설정 단계에서 크게 삽질한 건 없었다. 하지만 나중에 Docker 컨테이너화와 CI/CD를 붙이면서 ESM 호환성 문제가 줄줄이 터졌다. "type": "module" 프로젝트에서 CJS 설정 파일(postcss.config.cjs 같은)을 쓸 때 확장자를 명시적으로 .cjs로 해야 하는 게 대표적이다.
그래서 하나 배운 게 있다면, 초기 구조를 잡을 때 빌드/배포 환경까지 미리 고려해야 한다는 거다. 로컬에서 잘 돌아간다고 끝이 아니다. Docker에서 빌드할 때 DB가 없는 상태에서도 빌드가 통과해야 하고, ESM/CJS 호환성도 처음부터 확인해야 한다.
프로젝트 초기에 디자인 토큰과 컴포넌트 구조를 잘 잡으면 이후 확장이 훨씬 수월하다. Headless CMS + 블록 기반 페이지 빌더는 개발자와 콘텐츠 관리자 모두를 만족시키는 아키텍처다.
다음 글에서는 이 프로젝트를 Docker로 컨테이너화하고 Kubernetes에 배포하는 과정을 다룰 예정이다.