HTML 주석 안에 페이지 빌더를 숨겼다

nugu에는 프로모션(기획전) 페이지가 있다. 쿠폰, 상품, 이미지, 배너가 섞인 세로로 긴 페이지다. 그동안 이 본문은 어드민에서 위즈윅에디터로 HTML을 직접 퍼블리싱해 만들었다. 그래서 HTML을 다룰 줄 아는 웹디자이너분들만 작업할 수 있었다. 문제는 그 일을 할 수 있는 사람이 많지 않다는 데 있었다. 기획전이 늘어날수록 일이 몇 분에게 몰렸다.
메인 교체, 쿠폰 다운로드, 카운트다운처럼 인터랙티브한 요소가 들어가면 웹디자이너가 손댈 수 있는 범위를 넘어가니 매번 FE 개발팀으로 요청이 들어왔다. 그간 요청은 대부분 일회성 기획전을 위해 하드코딩으로 처리됐다. 한 번 쓰고 버려지는 코드가 계속 쌓였다.
이 흐름을 개선하고자 HTML을 직접 퍼블리싱하지 않고 GUI로 편집하는 블록 기반 페이지 빌더를 만들었다. UI는 Figma의 3패널 레이아웃을 참고했다. CMS 에디터를 추가하고 nugu 몰, 어드민 위즈윅에디터를 연결하는 작업이었다.
가장 큰 제약이 설계 결정이 됐다
기획전 본문은 HTML 형태로 DB 텍스트 필드 하나에 저장된다. 어드민에서는 이 필드값을 위즈윅에디터로 편집하고, nugu 몰은 이 필드를 읽어 dangerouslySetInnerHTML을 이용해 그대로 렌더링한다. 이미 수많은 기획전이 이 포맷으로 쌓여 있었다.
처음엔 "CMS에서 추가한 트리 블록 구조를 JSON 필드를 추가해서 관리해야 하나?" 싶었다. 하지만 필드를 새로 만들면 마이그레이션을 해야 했다. nugu 몰 렌더 경로도 둘로 갈라진다. 기존 기획전과 새 기획전의 렌더 방식이 갈리면서 호환성이 깨지는 문제가 발생할 터였다.
그래서 기존 텍스트 필드를 그대로 두고, 블록 구조를 HTML 주석에 직렬화해서 본문에 같이 넣었다.
<!-- BLOCKS:{"blocks":[{"type":"image","src":"..."}, ...]} -->
<div class="...">실제 렌더용 HTML</div>CMS에서 저장할 때 두 가지(일반 HTML, 주석 컴포넌트 블록)를 직렬화해 한 문자열로 만든다. 사람이 보는 실제 HTML, 그리고 그 HTML을 만들어낸 블록 트리 JSON을 담은 주석을 함께 넣는 것이다. 다시 편집할 땐 주석에서 JSON만 꺼내 역직렬화로 블록 트리를 복원한다. nugu 몰은 주석은 제거하고 HTML만 렌더링한다.
덕분에:
- 기존 기획전은 주석이 없으니 예전처럼 HTML로 렌더링된다. 새 기획전은 주석이 있으니 CMS에 설정한 블록으로 렌더링된다. 한 필드 안에서 렌더 방식이 다른 두 가지가 호환되며 함께 저장된다.
- nugu 몰 렌더 파이프라인은 건드리지 않았다. 주석은 정제 과정에서 제거돼 화면에 보이지 않는다.
- 따라서 JSON 관리를 위한 필드 추가나 마이그레이션이 없다.
기존 방식을 깨지 않고 새 기능을 얹는 게 이번 작업에서 가장 신경 쓴 부분이었다.
동적 요소는 주석으로 자리만 잡고, 런타임에 컴포넌트로 치환
기획전엔 정적 HTML로 표현 못 하는 것들이 있다. 쿠폰 다운로드 버튼, 실시간 가격, 카운트다운 같은 것들이다. 이건 저장 시점에 값을 박으면 안 된다. 고객이 페이지를 열 때 계산돼야 한다.
그래서 동적 요소도 똑같이 주석 마커로 저장한다.
<!-- WIDGET: CouponButton {"couponId": 123} -->nugu 몰은 본문을 그릴 때 이 마커를 두 단계로 처리한다.
1단계: 주석 마커를 빈 placeholder div로 치환
<div data-widget="CouponButton" data-props="...">
2단계: 렌더 후 placeholder를 전부 찾아,
이름 → 실제 React 컴포넌트 매핑 테이블에서 컴포넌트를 꺼내
createPortal로 그 div 안에 마운트dangerouslySetInnerHTML로 정적 HTML을 그리되, 그 안의 특정 지점에만 살아있는 React 컴포넌트를 꽂는 방식이다. createPortal을 쓰니 정적 본문과 동적 위젯이 한 DOM 트리 안에서 섞인다.
여기서 한 가지 규칙을 만들었다. 에디터에 새 위젯 프리셋을 추가하면, nugu 몰의 매핑 테이블에도 반드시 등록한다. 허용 목록을 두고, 양쪽에 모두 등록된 컴포넌트만 렌더링되게 했다.
에디터는 별도 창, 통신은 postMessage 핸드셰이크
CMS 에디터는 어드민 안에 추가 페이지를 만들지 않고 위즈윅에디터 툴바에 버튼을 추가해 별도 창으로 띄웠다. CMS 에디터에서 블록들을 설정 후 저장하면 결과 HTML을 부모 창으로 돌려주고 닫힌다.
창 사이 통신은 postMessage로 했다. 단순히 한 번 쏘는 걸로는 안 됐다. 새 창이 아직 마운트되기 전에 부모가 초기 데이터를 보내면, 메시지는 그냥 허공으로 사라진다. 그래서 핸드셰이크를 넣었다.
부모 창: 새 창이 READY를 보낼 때까지 INIT(초기 HTML)를 반복 전송
에디터: 마운트되면 INIT 수신 → 화면 복원 → 부모에게 READY 답신
부모 창: READY 받으면 반복 전송 중단
에디터: 저장 버튼 → CONTENT(결과 HTML) 전송 후 창 닫기postMessage는 휘발성이라 한 번 받은 상태를 어딘가 저장해두지 않으면 새로고침 한 번에 사라진다. 실제로 에디터 창을 새로고침하면 받았던 초기 데이터가 날아가는 문제가 있었다. 그래서 INIT를 받는 즉시 sessionStorage에도 넣어두고, 마운트 시 세션에 남은 게 있으면 그걸로 먼저 복원하게 했다.
미리보기는 실제 렌더 파이프라인을 재사용했다
CMS 에디터에서 편집 중인 기획전이 실제 고객 화면에서 어떻게 보이는지 바로 확인할 수 있어야 했다. 여기서도 "기존 코드 재사용" 원칙을 지켰다.
미리보기를 위한 추가 코드를 새로 작성하지 않았다. 대신 nugu 몰에 미리보기 전용 경로를 하나 열어두고, 실제 기획전 상세 화면을 그리는 컴포넌트에 mock 기획전 객체를 전달해 렌더링했다. 에디터는 편집 중인 HTML을 주기적으로 그 창에 postMessage로 보내고, 미리보기 창은 받은 HTML로 mock 기획전 객체의 본문만 교체해 렌더링했다.
덕분에 미리보기 결과는 "비슷하게 보이는 화면"이 아니라, 실제 고객이 보는 화면을 그대로 재현했다. 미리보기 전용 코드를 따로 두면 언젠가 본 화면과 어긋난다. 같은 코드를 사용하여 문제를 원천적으로 차단했다.
지금 상태와 남은 한계
에디터에서 블록을 쌓고, 동적 위젯을 끼우고, HTML 주석으로 직렬화하고, 별도 창에서 실시간 미리보기를 한다. 저장된 HTML이 고객 화면에서 렌더되는 흐름도 유지했다.
다만 아직 모두 대체하지는 못했다. 이번에 만든 에디터는 FE 개발팀의 기획전 제작 지원 업무를 약 80% 덜어줬다. 하지만 쿠폰·카운트다운처럼 인터랙티브한 기능 블록들은 숙제다. 이런 블록들은 아직 레거시 서버 데이터 모델에 의존해 추가 설계가 필요하다. 이 부분은 BE 팀과 함께 보완해나갈 예정이다.
정리
새 기능을 만들면서 가장 만족한 부분은 "기존 코드 재사용 원칙"을 지켜 호환성을 유지한 점이었다.
- 새 JSON 필드 대신 기존 텍스트 필드 + HTML 주석
- 새 렌더러 대신 기존 nugu 몰 파이프라인 재사용
- 새 미리보기 화면 대신 실제 상세 컴포넌트에 가짜 데이터
새로 작성한 건 포맷을 다루는 얇은 층뿐이었다. 주석 안 JSON을 안전하게 꺼내는 파서, 마커를 포털로 치환하는 훅, 창 사이 핸드셰이크. 기존 구조를 건드리지 않겠단 제약을 받아들였더니, 오히려 그 제약이 설계를 단순하게 만들어줬다.