
블로그에 기술 글을 쓰려면 코드 블록이 필수다. 근데 PayloadCMS 3.x의 Lexical 에디터는 코드 블록을 기본으로 안 준다. 직접 만들어야 한다. Live Preview도 붙이고 싶었고.
결론부터 말하면 8개 커밋 중 6개가 fix:였다. 삽질의 본질은 PayloadCMS Lexical이 가진 4개 레이어 구조를 한번에 파악하기 어렵다는 데 있다.
PayloadCMS에는 관리자 패널에서 글을 편집하면서 프론트엔드 미리보기를 실시간으로 볼 수 있는 Live Preview 기능이 있다. payload.config.ts에 설정 몇 줄이면 되는 거라 간단해 보였는데 바로 벽에 부딪혔다.
@payloadcms/live-preview-react 패키지 버전이 Payload 코어 버전과 안 맞으면 런타임에서 터진다. 에러 메시지도 뭐가 문제인지 감을 잡기 힘든 류였다. 3.74.0으로 다운그레이드하니까 해결됐다. 프론트엔드 쪽에는 RefreshRouteOnSave 클라이언트 컴포넌트를 넣어서 저장할 때마다 라우트를 새로고침하게 했다.
코드 블록 하나 추가하는 건데 생각보다 손이 많이 간다. PayloadCMS의 Lexical 에디터에서 커스텀 기능을 넣으려면 4개 레이어를 전부 건드려야 한다.
createServerFeature로 서버 측 Feature를 만든다. 여기서 CodeNode와 CodeHighlightNode를 등록하고 클라이언트 Feature 경로를 지정한다.
const CodeBlockFeature = createServerFeature({
key: 'codeBlock',
feature: {
ClientFeature: './features/codeBlock/client#CodeBlockFeatureClient',
nodes: [
createNode({ node: CodeNode }),
createNode({ node: CodeHighlightNode }),
],
},
})서버에 노드를 등록했으니 끝인 줄 알았는데 아니다. 에디터에서 실제로 코드 블록을 삽입하려면 클라이언트 Feature가 별도로 필요하다. 슬래시 커맨드(/code)로 코드 블록을 넣을 수 있게 하고 registerCodeHighlighting으로 구문 강조까지 붙였다.
export const CodeBlockFeatureClient = createClientFeature({
nodes: [CodeNode, CodeHighlightNode],
plugins: [{ Component: CodeBlockPlugin, position: 'normal' }],
slashMenu: {
groups: [{
items: [{
key: 'codeBlock',
keywords: ['code', 'codeblock', '코드'],
label: () => 'Code Block',
onSelect: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
if (selection) $setBlocksType(selection, () => $createCodeNode())
})
},
}],
}],
},
})이게 빠뜨리기 쉬운 함정이다. Lexical Feature를 추가하거나 수정하면 반드시 pnpm generate:importmap을 실행해야 한다. 안 하면 에디터가 새로 등록한 노드를 인식 못해서 아무런 에러 없이 그냥 안 된다. 이것 때문에 한참 헤맸다.
에디터에서 입력은 됐는데 프론트엔드에서 렌더링이 안 된다? Lexical JSON을 React 컴포넌트로 변환하는 시리얼라이저(serialize-lexical.tsx)에 새 노드 타입을 추가해야 하기 때문이다. code 노드, code-highlight 노드, linebreak 노드를 각각 처리하는 case를 추가했다.
여기서 한 가지 주의할 점. 코드 블록 안의 텍스트는 일반 text 타입이 아니라 code-highlight 타입이다. 시리얼라이저에서 text만 처리하고 있으면 코드 블록 내용이 통째로 사라진다.
시리얼라이저를 만들면서 가장 황당했던 버그가 있다. heading 렌더링에서 Lexical이 넘겨주는 node.tag 값이 이미 "h2" 형태인 줄 모르고 앞에 h를 한 번 더 붙였다.
// 버그: "hh2" HTML 태그가 생성됨
const Tag = `h${node.tag}` // node.tag = "h2" → "hh2"
// 수정: node.tag를 그대로 사용
const Tag = node.tag as keyof React.JSX.IntrinsicElements브라우저 개발자 도구에서 <hh2>라는 정체불명의 태그를 발견하고서야 원인을 파악했다. Lexical 공식 문서에는 heading 노드의 tag 필드가 어떤 형태인지 명확히 안 나와 있었다. 소스 코드를 직접 열어봐야 알 수 있는 부분이었다.
PayloadCMS Lexical 에디터는 확실히 강력하다. Meta의 Lexical 프레임워크를 그대로 쓰기 때문에 확장성이 좋다. 하지만 커스텀 Feature 하나 넣으려면 알아야 할 게 많다.
하나만 빠뜨려도 어딘가에서 조용히 실패한다. 에러 메시지도 친절하지 않다.
이번 작업에서 가장 크게 느낀 건 프레임워크 확장 포인트가 문서에 잘 안 나와 있을 때 소스 코드를 직접 읽는 게 가장 빠르다는 거다. PayloadCMS Lexical 관련 코드는 node_modules/@payloadcms/richtext-lexical 안에 있고 구조가 비교적 깔끔해서 읽을 만하다.
Live Preview까지 붙이고 나니 관리자 패널에서 글 편집하면서 바로 결과를 확인할 수 있게 됐다. 삽질은 길었지만 블로그 운영 워크플로우가 확실히 나아졌다.