
저희 서비스에서는 이벤트 페이지를 어드민에서 등록하면 사용자 사이트에 노출되는 구조로 운영하고 있습니다. 이벤트 페이지 제작 시 반복되는 개발 공수를 줄이기 위해 드래그&드롭 기반 이벤트 페이지 빌더를 개발했습니다. 현재는 출석체크 이벤트를 대상으로 1차 구현을 완료한 상태이며, 이후 빙고·룰렛 등 다양한 인터랙티브 이벤트로 확장해 나갈 예정입니다.
배경: 이벤트 하나를 만들 때마다 개발자가 투입된다
출석체크, 웹툰 콜라보, 타임어택처럼 인터랙션이 있는 이벤트 페이지는 기획서와 디자인을 받은 개발자가 다음 과정을 직접 처리해야 했습니다.
- 디자인 에셋(배경 이미지, 버튼 이미지 등)을 S3에 업로드
- S3 경로를 참조하는 HTML 코드를 직접 작성
- 버튼 클릭 시 출석체크 API 호출, 공유하기 등의 인터랙션 로직 구현
- 코드를 Vue 컴포넌트 파일로 작성해서 배포
이 과정이 이벤트마다 반복됐습니다. 쌓인 이벤트 페이지용 하드코딩 파일만 18개였고, 출석체크 이벤트만 해도 버전이 다르다는 이유로 비슷한 파일이 4개나 존재했습니다.
한 번은 어드민에서 HTML을 직접 입력해서 프론트에서 렌더링하는 방식도 시도했지만, 결국 개발자가 HTML을 작성해야 한다는 점에서 본질적으로 같은 문제였습니다. 이 방식은 폐기했습니다.
방향 탐색: 어떤 방식으로 만들 것인가
처음에는 GrapesJS 같은 외부 블록 에디터 라이브러리를 활용하는 방안을 검토했습니다. 그런데 몇 가지 문제가 있었습니다.
출석체크, 룰렛처럼 복잡한 인터랙션을 GrapesJS의 컴포넌트 시스템으로 구현하려면 결국 개발팀이 커스텀 컴포넌트를 작성해야 했고, 버튼 클릭 시 공유하기나 출석체크 API 호출 같은 액션 처리도 별도로 연결해야 했습니다. GrapesJS가 뱉어내는 HTML을 프론트에서 그대로 실행하면 XSS 위험도 있었습니다.
그래서 자체 에디터를 만들기로 했습니다.
HTML 대신 JSON을 저장하기로 한 이유
에디터의 결과물을 HTML 문자열로 저장할지 JSON으로 저장할지 고민했습니다. 이전에 HTML 방식이 이미 폐기된 전례가 있기도 했고, JSON으로 결정한 이유는 네 가지였습니다.
- 보안: JSON + 전용 렌더러 방식은 허용된 요소 타입만 렌더링하므로 XSS를 원천 차단할 수 있습니다.
- 재편집: 에디터 내부 상태를
JSON.stringify로 저장하고JSON.parse로 복원하면 수정 시 그대로 열립니다. HTML을 파싱해서 에디터 상태로 복원하는 것보다 훨씬 자연스럽습니다. - 확장성: 현재는 출석체크 이벤트에 필요한 요소 타입만 정의되어
있지만, 빙고·룰렛 같은 새 이벤트 유형을 추가할 때
elements배열에 새type과 config를 정의하기만 하면 됩니다. 에디터와 렌더러에 해당 type 분기만 추가하면 되고, 기존 요소들은 영향을 받지 않습니다. - 유지보수: 구조화된 데이터라 필드 단위 수정·검증이 쉽습니다. 특히 스탬프 캘린더 기간, 이미지 URL 같은 설정값을 다루기에 JSON이 훨씬 편리했습니다.
전체 구조
[어드민] [API 서버] [사용자 사이트]
드래그&드롭 에디터 ──저장──▶ JSON string으로 저장 ──조회──▶ 읽기 전용 렌더러
builderLayout JSON 구조
{
"elements": [
{
"type": "background",
"imageUrl": "https://cdn.example.com/bg.jpg"
},
{
"type": "image",
"imageUrl": "https://cdn.example.com/title.png",
"position": { "x": 10.5, "y": 5 },
"size": { "width": 80 }
},
{
"type": "button",
"imageUrl": "https://cdn.example.com/btn-checkin.png",
"position": { "x": 25, "y": 60.25 },
"action": { "type": "CHECKIN" }
},
{
"type": "stamp-grid",
"position": { "x": 5, "y": 20 },
"size": { "width": 90 },
"config": {
"beginDate": "20260101",
"endDate": "20260131",
"columns": 7,
"stampSets": [{ "activeUrl": "...", "inactiveUrl": "..." }]
}
}
]
}모든 위치(position)와 크기(size)는 배경 이미지 대비 퍼센트(%)로 저장합니다. 소수점을 지원하기 때문에 픽셀 단위에 준하는 정밀한 배치가 가능합니다.
지원 요소 타입
| 요소 | 설명 |
|---|---|
background | 전체 배경 이미지 (최대 1개) |
image | 자유 배치 이미지 |
button | 액션이 연결된 버튼 이미지 |
stamp-grid | 출석 스탬프 캘린더 (최대 1개) |
today-display | 오늘 날짜를 숫자 이미지로 표시 (최대 1개) |
attendance-display | 나의 출석일 수를 숫자 이미지로 표시 (최대 1개) |
버튼 액션 타입은 CHECKIN, GO_TO_URL, SHARE 등으로 정의했습니다.
아래는 에디터 예시 화면입니다. 왼쪽 캔버스에서 요소를 드래그해 배치하고, 오른쪽 패널에서 위치·크기·이미지 등 세부 설정을 조정할 수 있습니다.

구현하면서 마주친 문제들
위치와 크기를 퍼센트로 저장해야 했던 이유
초기에 요소의 위치를 픽셀로 저장했더니 문제가 생겼습니다. 이벤트마다 배경 이미지 크기가 다르고, 같은 이벤트도 모바일(360px)부터 PC(1280px)까지 다양한 화면 너비에서 렌더링됩니다. 픽셀 좌표는 특정 해상도에서만 맞고 다른 해상도에서는 깨졌습니다.
해결 방법은 모든 위치와 크기를 배경 대비 퍼센트(%)로 저장하고, 렌더러에서 실제 컨테이너 크기를 기준으로 픽셀로 변환하는 것이었습니다.
// ResizeObserver로 컨테이너 크기 변화를 감지
const resizeObserver = new ResizeObserver(([entry]) => {
const containerWidth = entry.contentRect.width;
elements.value.forEach((el) => {
el.renderedX = (el.position.x / 100) * containerWidth;
el.renderedY = (el.position.y / 100) * containerHeight;
el.renderedWidth = (el.size.width / 100) * containerWidth;
});
});에디터와 렌더러 모두 ResizeObserver로 컨테이너 크기를 감지해서 실시간으로 재계산합니다. 덕분에 에디터에서 작업한 레이아웃이 실제 사용자 화면에서도 동일하게 보입니다.
스탬프 이미지 다양성 문제
출석체크 이벤트는 보통 캘린더에 스탬프를 찍는 형태의 디자인으로 제작됩니다. 처음에는 스탬프 이미지를 도장을 찍은 상태(활성)/찍지 않은 상태(비활성) 한 쌍으로 고정했습니다. 그런데 이벤트마다 스탬프 디자인이 달랐습니다. 날짜 숫자가 새겨진 스탬프를 쓰는 이벤트는 1~31일에 해당하는 스탬프가 각각 달라야 했습니다.
운영팀이 (활성, 비활성) 쌍을 여러 세트 등록할 수 있도록 구조를 바꿨습니다. 스탬프 세트가 하나면 모든 날짜에 같은 스탬프가 표시되고, 여러 세트면 날짜 순서대로 매핑됩니다.
"stampSets": [
{ "activeUrl": "day1-active.png", "inactiveUrl": "day1-inactive.png" },
{ "activeUrl": "day2-active.png", "inactiveUrl": "day2-inactive.png" }
]에디터와 렌더러 간 위치 불일치
어드민 에디터의 미리보기와 Market Front의 실제 렌더러에서 요소 위치가 미묘하게 달랐습니다. 같은 퍼센트 값인데 왜 다르냐면, 두 컴포넌트의 컨테이너 크기 계산 방식이 달랐기 때문이었습니다.
에디터 미리보기는 고정된 너비를 사용하고, 렌더러는 실제 브라우저 레이아웃 너비를 사용했는데 border, padding이 포함되는지 여부가 달랐습니다. 양쪽 모두 ResizeObserver로 contentRect.width를 기준으로 통일하면서 해결되었습니다.
실행 취소/다시 실행 (Undo/Redo)
에디터에서 요소를 잘못 옮겼을 때 되돌릴 수 있어야 합니다. 단순히 히스토리 스냅샷을 쌓으면 되지만, 텍스트 input을 타이핑할 때마다 히스토리가 쌓이면 UX가 나빠집니다.
input 변경에 debounce를 적용해서 300ms 이내의 연속 입력은 히스토리 하나로 합쳤습니다. 드래그 이동은 drag end 시점에만 히스토리를 저장합니다. Ctrl+Z / Ctrl+Shift+Z 단축키도 연결했습니다.
사용자 사이트 렌더링
사용자 사이트에서는 이벤트의 linkType에 따라 렌더링을 분기합니다. BUILDER 타입이면 읽기 전용 렌더러 컴포넌트를 사용하고, 기존 고정형과 레거시 HTML 방식도 함께 호환합니다.
렌더러 컴포넌트는 elements 배열을 순회하며 각 타입에 맞는 컴포넌트를 렌더링합니다. 버튼 클릭 이벤트는 상위로 emit되고, 부모 페이지가 액션 타입에 따라 처리합니다.
function handleButtonClick({ type, ...payload }) {
switch (type) {
case 'CHECKIN':
await checkin(surveyId);
break;
case 'GO_TO_URL':
router.push(payload.url);
break;
case 'SHARE':
naverModule.nSocialPlugin.share();
break;
}
}로컬 테스트 환경 구축
어드민과 사용자 사이트, 백엔드가 모두 연관된 작업이어서 테스트 환경 구성이 까다로웠습니다.
어드민 에디터에서는 저장 시 실제 API를 호출하는 대신 builderLayout JSON을 로컬 파일로 출력하도록 했습니다. 사용자 사이트에서는 서버 API 라우트로 Mock API를 구성해, 이 파일을 그대로 읽어서 응답하도록 했습니다. 덕분에 백엔드 없이도 에디터에서 만든 레이아웃이 실제 렌더러에서 어떻게 보이는지 바로 확인할 수 있었습니다.
아래는 드래그&드롭 배치, 정렬 가이드라인, Undo/Redo, 미리보기 전환을 순서대로 보여주는 데모입니다.
마치며
이번 작업에서 가장 많이 고민한 것은 운영팀이 얼마나 자유롭게 다룰 수 있으면서, 개발팀 입장에서는 확장하기 쉬운 구조인가였습니다.
HTML 직접 입력은 자유도가 높지만 XSS 위험과 개발 의존이 남고, 외부 에디터 라이브러리는 복잡한 인터랙션 요소를 커스터마이징하기 어려웠습니다. JSON 기반 자체 에디터+렌더러 방식은 허용된 요소 타입만 그리기 때문에 안전하고, 요소 타입을 추가해 나가는 방식으로 점진적으로 확장할 수 있었습니다.
운영팀이 어드민에서 배경 이미지, 버튼, 출석 캘린더를 드래그&드롭으로 배치하고 저장하면 사용자 페이지에 바로 렌더링됩니다. 출석체크 이벤트를 등록하는 데 개발팀이 투입될 필요가 없어졌습니다.
개발 공수 절감 효과
빌더 도입 전에는 이벤트 하나를 만드는 데 개발자가 1~2일을 써야 했습니다. 디자인 에셋 업로드부터 HTML 작성, 인터랙션 구현, 배포까지 매번 같은 과정을 반복했고, 운영팀은 개발팀 일정에 맞춰야 해서 이벤트 오픈이 밀리는 일도 잦았습니다. 도입 후에는 운영팀이 직접 30분에서 1시간이면 이벤트를 등록할 수 있게 되었고, 개발자가 투입될 필요가 사라졌습니다. 월 평균 등록 건수도 2~3건에서 4~5건으로 늘었는데, 이건 개발 병목이 사라지면서 기획 일정이 유연해진 덕분이었습니다. 이벤트마다 하나씩 쌓이던 하드코딩 파일(18개 이상)도 JSON 데이터로 대체되면서 코드베이스가 더 이상 비대해지지 않게 되었습니다. 운영팀에서는 "디자인 시안을 받고 당일에 바로 올릴 수 있어서 기획 일정이 유연해졌다"는 피드백이 있었습니다. 빌더 개발에 약 2주가 걸렸지만, 월 2~3건의 개발 공수를 감안하면 두 달 안에 충분히 회수할 수 있는 투자였습니다.
실제로 에디터를 만드는 것보다 쓸만한 에디터를 만드는 게 훨씬 어려웠습니다. 처음엔 요소를 배치하고 저장하는 핵심 기능 구현에만 집중했는데, 막상 직접 써보니 부족한 점이 계속 눈에 띄었습니다. 사용자 사이트에 실제로 어떻게 보여질지 확인하기 어려워 미리보기를 추가했고, 드래그만으로는 정밀한 배치가 어려워 정렬 가이드를 추가했습니다. 뒤로가기·삭제 같은 편의 기능도 키보드 단축키로 매핑하고 나서야 비로소 쓸만하다는 느낌이 들었습니다.
현재는 출석체크 이벤트를 대상으로 한 1차 구현이며, 다음 단계로는 빙고·룰렛 같은 인터랙티브 이벤트를 BUILDER 타입으로 지원하는 것을 목표로 하고 있습니다. elements 배열에 새 type을 추가하고 에디터·렌더러에 분기를 추가하는 방식으로 점진적으로 확장해 나갈 계획입니다.
Related Posts

통합 로그인 시스템 구축기: 소셜 로그인과 이메일 로그인을 하나로
네이버, 구글 소셜 로그인과 이메일 계정 시스템을 통합하여 일관된 사용자 경험을 제공하고 신규 회원 가입 플로우를 단일화한 과정을 다룹니다.

재사용 가능한 드래그 스크롤 컨테이너 만들기
Vue.js로 PC 환경에서 마우스 드래그 스크롤을 지원하는 재사용 가능한 컴포넌트를 개발한 과정을 다룹니다.

서비스 프론트엔드 성능 최적화 탐구: Nuxt.js 3와 캐싱 전략의 현실
Nuxt.js 3 기반 서비스에서 SWR, Redis, In-Memory 캐싱 등 다양한 성능 최적화 전략을 탐구하고, 실제 인프라 지표를 분석하며 '지금 당장'보다 '적절한 시점'의 중요성을 도출한 결론을 정리합니다.