JSON 노드 트리로 페이지 빌더 만들기
페이지 빌더 아키텍처 패턴
페이지 빌더는 비개발자가 UI를 구성할 수 있게 해주는 도구로, 내부적으로는 UI 구조를 데이터로 표현하고 이를 렌더링하는 구조다. 대표적인 아키텍처 패턴은 세 가지다.
| 패턴 | 대표 서비스 | 데이터 구조 | 특징 |
|---|---|---|---|
| 블록 기반 | WordPress Gutenberg | HTML 주석 마커 + 속성 JSON | HTML 직렬화, 서버 렌더링 중심 |
| 컴포넌트 기반 | Builder.io, Framer | 컴포넌트 타입 + props JSON | React 컴포넌트와 1:1 매핑 |
| 노드 트리 기반 | Notion, 커스텀 빌더 | {element, props, style, children} 트리 | 재귀적 렌더링, 높은 유연성 |
WordPress Gutenberg는 블록을 HTML 주석(<!-- wp:paragraph -->)으로 직렬화하여 어디서든 렌더링할 수 있는 이식성을 확보했다. Builder.io는 JSON 기반 컴포넌트 트리를 사용하되, 프레임워크별 SDK(@builder.io/react)로 각 환경에 맞게 렌더링한다. Notion은 블록을 중첩 가능한 트리 구조로 표현하여, 텍스트·이미지·토글·코드 등 모든 요소를 동일한 인터페이스로 처리한다.
공통적으로, UI를 JSON(또는 유사 구조)으로 직렬화하여 DB에 저장하고, 런타임에 해석하여 렌더링한다는 핵심 아이디어를 공유한다. 이 글에서 소개하는 노드 트리 방식은 React.createElement(type, props, children)의 구조를 그대로 JSON으로 옮긴 것으로, React의 Virtual DOM과 본질적으로 같은 트리를 DB에 영속화하는 접근이다.
배경
멀티테넌트 팝업 스토어 플랫폼에서 "자유형" 테넌트 타입을 구현해야 했다. 고정형은 미리 정의된 레이아웃에 콘텐츠를 채우는 방식이라 유연성이 제한적이었다. 자유형은 관리자가 드래그앤드롭으로 페이지를 자유롭게 구성할 수 있어야 했다.
v1.0의 한계
처음에는 컴포넌트 타입 기반(switch-case) 렌더링을 사용했다.
// v1.0: 컴포넌트 타입 기반
switch (component.type) {
case "hero-banner": return <HeroBanner {...props} />;
case "product-grid": return <ProductGrid {...props} />;
// 새 컴포넌트 추가 → 코드 수정 필요
}새로운 컴포넌트를 추가할 때마다 코드를 수정해야 하고, 중첩 구조를 표현할 수 없었다. 완전히 다른 접근이 필요했다.
v2.0: React.createElement에서 영감
React.createElement(type, props, children) 구조에서 힌트를 얻었다. 모든 UI를 {element, props, style, children} 구조의 노드 트리로 표현하면, config만으로 어떤 UI든 만들 수 있다.
데이터 구조: UINode
interface UINode {
id: string; // 노드 고유 ID
element: ElementType; // 요소 타입 (div, h1, product-list 등)
text?: NodeText; // 텍스트 콘텐츠 (다국어)
props?: NodeProps; // 요소 속성
style?: NodeStyle; // 스타일 (Tailwind + 인라인)
children?: UINode[]; // 자식 노드
}
interface NodeText {
text: string; // 기본 텍스트 (한국어)
i18n?: {
en?: string; // 영어
jp?: string; // 일본어
};
}
interface NodeStyle {
inline?: Record<string, string>; // CSS 인라인 스타일
className?: string; // Tailwind 클래스
}실제 데이터는 이렇게 생겼다.
{
"rootNode": {
"id": "root",
"element": "div",
"style": { "className": "min-h-screen" },
"children": [
{
"id": "header",
"element": "header",
"style": { "className": "flex items-center justify-between p-4" },
"children": [
{ "id": "logo", "element": "logo", "props": { "size": "medium" } },
{ "id": "lang", "element": "language-selector" }
]
},
{
"id": "title",
"element": "h1",
"text": {
"text": "환영합니다",
"i18n": { "en": "Welcome", "jp": "ようこそ" }
},
"style": { "className": "text-4xl font-bold text-center py-16" }
}
]
},
"metadata": { "version": "2.0" }
}허용 요소와 특수 컴포넌트
모든 HTML 요소를 허용하면 보안 문제가 생긴다. 화이트리스트 방식으로 허용 요소를 제한했다.
HTML 요소: div, span, section, header, footer, h1~h6, p, a, button, img, ul, ol, li 등
특수 컴포넌트: 비즈니스 로직이 필요한 요소는 별도 컴포넌트로 구현했다.
| 컴포넌트 | 설명 | 주요 props |
|---|---|---|
product-list | 상품 목록 그리드 | maxItems, columns, displayId |
logo | 테넌트 로고 | size, fileId |
language-selector | 언어 전환 | scale |
cart-button | 장바구니 버튼 | showBadge |
signup-form | 회원가입 폼 | requireVerification |
특수 컴포넌트는 렌더러에서 별도로 처리한다.
// components/node-renderer/special-components.tsx
function renderSpecialComponent(node: UINode) {
switch (node.element) {
case "product-list":
return <ProductListComponent {...node.props} />;
case "logo":
return <LogoComponent {...node.props} />;
// ...
}
}렌더러: 재귀적 노드 렌더링
노드 렌더러의 핵심은 재귀다. 루트 노드부터 시작해서 자식 노드를 재귀적으로 렌더링한다.
function NodeRenderer({ node, depth = 0 }: { node: UINode; depth?: number }) {
// 깊이 제한 (보안)
if (depth > MAX_DEPTH) return null;
// 특수 컴포넌트 처리
if (isSpecialComponent(node.element)) {
return renderSpecialComponent(node);
}
// 스타일 정제
const style = sanitizeNodeStyle(node.style);
const textContent = getLocalizedText(node.text);
// HTML 요소 렌더링
return React.createElement(
node.element,
{
className: style.className,
style: style.inline,
},
textContent,
node.children?.map((child) => <NodeRenderer key={child.id} node={child} depth={depth + 1} />)
);
}v1.0 → v2.0 호환
FlexiblePageRenderer에서 버전을 자동 감지하여 렌더러를 분기한다.
function isNodeLayoutConfig(config: unknown): config is NodeLayoutConfig {
return config?.metadata?.version === "2.0" && config?.rootNode != null;
}
// v2.0이면 노드 렌더러, v1.0이면 레거시 렌더러
if (isNodeLayoutConfig(layoutConfig)) {
return <RootNodeRenderer config={layoutConfig} />;
}보안: 화이트리스트 기반 검증
사용자가 입력한 JSON이 렌더링되므로, 보안은 필수다.
검증 항목
| 항목 | 방법 |
|---|---|
| 요소 타입 | ALLOWED_HTML_ELEMENTS 화이트리스트 |
| CSS 속성 | ALLOWED_CSS_PROPERTIES 화이트리스트 |
| CSS 값 | javascript:, expression(, url(data:) 차단 |
| Tailwind 클래스 | 영문/숫자/하이픈/콜론만 허용 |
| href | javascript:, data:, vbscript: 프로토콜 차단 |
| 트리 깊이 | MAX_DEPTH = 10 |
정제 함수
// 인라인 스타일 정제
function sanitizeInlineStyle(style: Record<string, string>) {
const sanitized: Record<string, string> = {};
for (const [key, value] of Object.entries(style)) {
const camelKey = toCamelCase(key); // kebab-case → camelCase
if (isAllowedCSSProperty(camelKey) && !hasDangerousValue(value)) {
sanitized[camelKey] = value;
}
}
return sanitized;
}CSS 속성명은 JSON에서 background-color 같은 kebab-case로 저장되지만, React는 backgroundColor 같은 camelCase를 요구한다. 정제 과정에서 자동 변환한다.
트리 조작 유틸리티
에디터에서 노드를 추가/삭제/이동할 때 사용하는 유틸리티다. 불변성(immutability)을 유지하면서 트리를 조작한다.
// 노드 찾기
const node = findNodeById(rootNode, "node-id");
const parent = findParentNode(rootNode, "node-id");
// 노드 수정 (불변성 유지 — 새 트리 반환)
const newRoot = updateNode(rootNode, "node-id", {
text: { text: "새 텍스트" },
});
// 노드 추가/삭제/이동
const newRoot = addChildNode(rootNode, "parent-id", newNode, index);
const newRoot = removeNode(rootNode, "node-id");
const newRoot = moveNode(rootNode, "node-id", "new-parent-id", newIndex);
// 노드 복제 (새 ID 생성)
const cloned = cloneNode(node);모든 함수가 원본 트리를 변경하지 않고 새 트리를 반환하므로, React 상태 관리와 자연스럽게 연결된다.
비주얼 빌더 (WYSIWYG 에디터)
관리자가 사용하는 비주얼 빌더는 크게 3영역으로 구성된다.
┌──────────────────────────────────────────────┐
│ [페이지 제목] [활성화 토글] [저장] [템플릿] │ ← 상단 헤더
├──────────────┬───────────────────────────────┤
│ │ │
│ 속성 편집 │ 편집 캔버스 │
│ 패널 │ (WYSIWYG) │
│ │ │
│ - 텍스트 │ 노드 클릭 → 선택 │
│ - Props │ 호버 → 오버레이 │
│ - 스타일 │ 드래그 → 이동 │
│ │ │
├──────────────┴───────────────────────────────┤
│ 뷰 모드 전환: 데스크톱 / 태블릿 / 모바일 │
└──────────────────────────────────────────────┘- 편집 캔버스: 실제 렌더링 결과를 보면서 편집. 노드 클릭 시 선택 오버레이 표시
- 속성 패널: 선택된 노드의 텍스트(한/영/일), Props, 스타일 편집
- 뷰 모드: 데스크톱/태블릿/모바일 너비로 전환하여 반응형 확인
샘플 템플릿
빈 페이지에서 시작하기 어려우므로, 페이지 타입별 샘플 템플릿을 제공한다.
const templates = getTemplatesForPageType("HERO");
// → [{ id: "hero-default", name: "기본 시작 페이지" }, { id: "empty", name: "빈 페이지" }]
const config = createFromTemplate("hero-default");HERO, SIGNUP, PRODUCT_LIST, OPTION_SELECT 페이지에 각각 기본 템플릿이 있다. 관리자는 템플릿을 선택하고, 그 위에서 커스터마이징하면 된다.
다국어 처리
노드의 텍스트 콘텐츠는 기본 한국어 + i18n 번역을 함께 저장한다.
{
"text": {
"text": "환영합니다",
"i18n": { "en": "Welcome", "jp": "ようこそ" }
}
}렌더링 시 LanguageContext의 현재 언어에 따라 적절한 텍스트를 선택한다. 에디터에서는 한/영/일 3개 입력 필드를 동시에 보여주어 번역을 편리하게 입력할 수 있다.
마무리
이 시스템의 핵심 아이디어는 단순하다. UI를 JSON 트리로 표현하고, 재귀적으로 렌더링한다. React.createElement가 하는 일과 본질적으로 같지만, JSON으로 직렬화할 수 있어 DB에 저장하고 에디터로 편집할 수 있다.
보안(화이트리스트), 불변 트리 조작, 특수 컴포넌트 확장 포인트 등의 설계가 시스템을 실용적으로 만들어주었다. 특히 v1.0에서 v2.0으로의 전환 과정에서 자동 버전 감지를 통한 하위 호환성 유지가 유용했다.