마더케이몰 이벤트관리 개발 기록 (1) 왜 만들었나와 클릭·전환 수집 구조

이벤트는 많은데, 어떤 이벤트가 팔았는지는 아무도 몰랐다 마더케이몰은 카페24 위에서 돌아가는 자사몰이다. 기획전, 체험단, 시즌 할인 같은 이벤트가 거의 매주 새로 올라가고, 이벤트 목록 페이지에는 늘 수십 장의 배너가 쌓여 있었다. 문제는 그다음이었다. 이 배너 중 어떤 것이 실제로 클릭을 받고, 클릭한 사람이 주문까지 했는지를 확인할 방법이 없었다. 카페24 기본 통계는 페이지 단위 방문 수 정도만 보여 줬고, "이번 달 이벤트 중 매출에 기여한 상위 열 개"를 뽑아 달라는 요청에는 매번 손으로 추정해서 답할 수밖에 없었다. 그래서 사내 어드민(mkware)의 자사몰 운영설정 모듈 안에 이벤트결과 메뉴를 만들었다. 운영자가 이벤트를 따로 등록하지 않아도 쇼핑몰에 걸린 이벤트를 자동으로 수집하고, 배너별 클릭과 구매 전환, 전환 매출을 쌓아서 기간별로 비교할 수 있게 하는 것이 목표였다. 이 글은 시리즈의 첫 편으로, 왜 이런 구조를 택했는지와 쇼핑몰 앞단에서 부딪힌 일들을 정리한다. 리포트 화면 재작성과 이벤트 페이지 슬라이더 문제는 2편에서 다룬다. 요구사항과 제약 요구사항 자체는 단순했다. 운영자가 이벤트를 이중으로 등록하지 않는다. 카페24에 올린 이벤트가 그대로 집계 대상이 되어야 한다. 이벤트별 클릭 수를 시간대·일자 단위로 본다. 클릭 후 주문이 생기면 그 주문을 이벤트에 귀속시키고 금액까지 남긴다. 오래 방치된 이벤트, 아무도 누르지 않는 이벤트를 골라낼 수 있어야 한다. 제약은 카페24라는 플랫폼에서 나왔다. 서버 코드를 쇼핑몰 안에 둘 수 없고, 손댈 수 있는 것은 스킨 HTML과 거기에 붙이는 JavaScript뿐이다. 이벤트도 별도 엔티티가 아니라 event/detail.html?product_no=... 형태로, 내부적으로는 상품 으로 등록된다. 이벤트 목록에서 넘겨받을 수 있는 정보는 링크 URL과 배너 이미지 경로, 그리고 product_no 정도였다. 주문 정보도 주문완료 페이지에 노출되는 전역 변수와 DOM에서 읽어 와야 했다. 설계: 목록 페이지가 곧 수집기 별도 크롤러를 돌리는 대신, 고객이 이벤트 목록 페이지를 열 때마다 그 페이지가 직접 이벤트 목록을 서버에 알려 주는 방식을 택했다. 흐름은 세 갈래다. 동기화 : 이벤트 목록 페이지에 심은 events.js 가 event/detail.html 로 가는 링크 중 상품 이미지 경로( /web/product/ )를 가진 배너만 모아 events_sync.php 로 보낸다. 서버는 link_url 기준으로 있으면 갱신, 없으면 새로 넣는다. 스마트배너 같은 다른 이미지는 경로 조건으로 걸러 냈다. 클릭 : 같은 배너에 클릭 핸들러를 걸어 events_click.php 로 링크와 이미지 주소를 보낸다. 서버는 누적 클릭 수를 올리고, 클릭 시각·날짜·시간대를 별도 로그 테이블에 한 줄씩 남긴다. 전환 : 클릭할 때 브라우저 localStorage에 클릭 이력을 남겨 두고, 주문완료 페이지에서 가장 최근 클릭을 꺼내 주문번호·결제금액과 함께 events_conversion.php 로 보낸다. 서버 쪽은 PHP와 MySQL로, 이미 다른 자사몰 운영 메뉴가 쓰던 구조를 그대로 따랐다. 쇼핑몰 도메인에서 호출되므로 각 API는 허용된 오리진에만 CORS 헤더를 붙이고, preflight(OPTIONS)는 바로 200으로 돌려준다. 테이블은 이벤트 마스터, 클릭 로그, 전환 로그 세 개다. 클릭 로그에 click_hour , click_date 를 따로 저장한 것은 시간대별·일별 차트를 그릴 때 매번 함수로 잘라 내지 않으려는 의도였다. 전환 귀속은 last-click으로 정했다. 클릭 이력은 최대 20개, 7일까지만 보관한다. 여러 이벤트를 거쳐 주문한 경우 마지막 이벤트에 공을 몰아주는 단순한 모델이지만, 운영자가 원한 질문("어떤 배너를 보고 들어와서 샀나")에는 그것으로 충분했다. function trackConversion(orderData) { var clicks = JSON.parse(localStorage.getItem(CLICK_STORAGE_KEY) || '[]'); if (clicks.length === 0) return; var lastClick = clicks[clicks.length - 1]; // last-click 귀속 fetch(API_BASE + 'events_conversion.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ link_url: lastClick.link_url, image_url: lastClick.image_url, click_timestamp: lastClick.timestamp, order_id: orderData.order_id || '', order_amount: orderData.order_amount || 0 }) }).then(function (r) { return r.json(); }) .then(function (result) { if (result.success) { // 귀속된 클릭은 소진 clicks.pop(); localStorage.setItem(CLICK_STORAGE_KEY, JSON.stringify(clicks)); } }); } 주문완료 페이지는 새로고침될 수 있으므로, 서버의 전환 테이블에는 order_id 에 유니크 키를 걸고 이미 기록된 주문이면 바로 거절한다. 같은 주문이 두 번 매출로 잡히는 일은 이 두 겹(클라이언트에서 소진, 서버에서 유니크)으로 막았다. 구현 과정과 시행착오 1. 같은 이벤트인데 URL이 달랐다 처음에는 link_url을 그대로 키로 썼다. 그런데 스킨에서 읽은 href는 상대 경로( /event/detail.html?... )이고, 이미지 주소는 // 로 시작하는 프로토콜 생략형이 섞여 있었다. 동기화 때 저장한 값과 클릭 때 보낸 값이 문자열로 일치하지 않으니 클릭이 이벤트를 찾지 못하는 경우가 생겼다. 그래서 두 단계로 바꿨다. 동기화 API에서는 상대 경로에 쇼핑몰 주소를 붙이고, // 이미지에는 https: 를 붙여 저장 형태를 하나로 통일했다. 클릭과 전환 API에서는 정확 일치 대신 "저장된 URL이 들어온 값을 포함하거나, 들어온 값이 저장된 URL을 포함하는" LIKE 매칭을 하고, 그래도 못 찾으면 이미지 파일명(basename)으로 한 번 더 찾는다. 배너 이미지를 교체하면 링크는 같고 이미지가 바뀌므로, 동기화 때 이미지 주소는 항상 최신 값으로 덮어쓰도록 했다. // 클릭/전환 공통: link_url 우선, 실패하면 이미지 파일명으로 $stmt = $pdo->prepare(" SELECT id FROM mkmall.mkmall_events WHERE link_url LIKE ? OR link_url LIKE ? OR ? LIKE CONCAT('%', link_url, '%') LIMIT 1"); $stmt->execute(['%' . $link_url . '%', $link_url . '%', $link_url]); $matched = $stmt->fetch(PDO::FETCH_ASSOC); if (!$matched && $image_url !== '') { $base = basename(parse_url($image_url, PHP_URL_PATH)); $stmt = $pdo->prepare("SELECT id FROM mkmall.mkmall_events WHERE image_url LIKE ? OR image_url LIKE ? LIMIT 1"); $stmt->execute(['%' . $image_url . '%', '%' . $base . '%']); $matched = $stmt->fetch(PDO::FETCH_ASSOC); } 이 방식은 매칭률을 올려 줬지만 대가가 있다. LIKE 앞뒤 와일드카드는 인덱스를 타지 못하고, product_no 앞자리가 같은 다른 이벤트에 잘못 붙을 여지도 있다. 클릭 요청에 product_no를 함께 실어 보내고 그것으로 바로 찾게 했어야 했다는 게 지금의 판단이다. 2. "진행중"만으로는 부족했다 목록 페이지가 열릴 때마다 보이는 이벤트는 진행중으로 갱신되지만, 진행중인데 아무도 누르지 않는 이벤트가 꽤 많았다. 운영자가 정말 보고 싶었던 것은 이쪽이었다. 그래서 상태 값을 세 가지(1 진행중, 0 종료, 2 인입없음)로 나누고, 처음 발견된 지 7일이 지났는데 클릭이 0인 이벤트는 어드민 화면을 열 때 자동으로 '인입없음'으로 내리도록 했다. 대시보드에도 진행중/종료/인입없음 비율을 막대로 보여 줘서, 배너 위치를 바꾸거나 내릴 후보를 바로 고를 수 있게 했다. 다만 이 판정은 크론이 아니라 어드민 페이지 진입 시점에 UPDATE가 실행되는 구조라, 아무도 화면을 열지 않으면 상태가 그대로 머문다. 운영 문서에도 "상태가 안 바뀌면 페이지를 한 번 열어 달라"는 항목을 남겨야 했다. 3. 이벤트가 '상품'이라서 생긴 일 카페24에서 이벤트가 상품으로 등록된다는 사실은 수집에는 편했지만, 쇼핑몰 앞단 곳곳에서 부작용을 냈다. 최근 본 상품 목록에 가격 없는 이벤트가 상품처럼 끼어들었고, 검색창의 추천 상품에도 이벤트 페이지가 섞여 나왔다. 둘 다 가격을 파싱해 판매가와 정가가 모두 0이면 목록에서 빼는 식으로 정리했다. 가격 파싱 자체도 카페24 템플릿이 숨김 처리한 요소( displaynone )까지 읽어서 엉뚱한 가격을 잡던 문제를 함께 고쳤다. 이벤트 상세 하단의 관련상품 영역도 한 번 갈아엎었다. 처음에는 이벤트 본문에 들어 있는 iframe의 카테고리 번호를 찾아 그 카테고리 목록을 다시 받아 오는 방식이었는데, 관련상품 리스트가 비어 나오는 이벤트가 있었다. 본문 안 상품 링크에서 product_no를 뽑고, 부족한 만큼만 각 상품 페이지의 JSON-LD를 읽어 이름·이미지·가격을 채우는 방식으로 바꿨다. 검색창의 '추천 이벤트'도 비슷한 길을 걸었다. 처음에는 이벤트 목록 HTML을 받아 배너 이미지의 alt를 제목으로 썼는데, 원래 스킨의 alt는 비어 있어서 목록 템플릿에 상품명을 넣는 수정부터 해야 했다. 결국 HTML을 긁는 대신 메인배너 관리용 JSON API를 읽는 쪽으로 같은 날 다시 바꿨다. 운영자가 어드민에서 관리하는 제목이 그대로 노출되니, 스킨 구조가 바뀌어도 깨지지 않는다. 결과 운영자는 이벤트를 따로 등록하지 않아도 어드민의 이벤트결과 메뉴에서 전체 이벤트 수, 누적 클릭, 구매 전환 건수, 전환 매출을 카드로 확인하고, 이벤트별로 최근 7일·30일·전체 기간의 시간대별·일별 클릭 차트와 전환 건수·금액 차트를 모달로 열어 본다. 자사몰 운영설정 대시보드에는 이벤트 영역 성과 카드와 상태 막대가 붙었고, 기간별 비교는 별도의 이벤트 리포트 화면에서 엑셀로 내려받을 수 있다. 회고 식별자는 처음부터 가장 안정적인 값으로. URL 문자열을 키로 삼은 탓에 정규화와 LIKE 매칭이라는 우회로가 생겼다. 카페24 이벤트에는 product_no라는 확실한 키가 있었다. 스키마 변경을 페이지 진입에 맡기지 말 것. 테이블 생성과 컬럼 추가를 API와 어드민 페이지 진입 시 CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS 로 처리했는데, 초기 코드에는 특정 컬럼이 없으면 테이블을 지우고 다시 만드는 분기까지 있었다. 지금이라면 마이그레이션 스크립트를 분리하고, 운영 계정의 DDL 권한도 처음부터 막아 둔다. 상태 판정은 배치로. '인입없음'처럼 시간에 따라 바뀌는 상태는 화면이 아니라 정해진 시각에 도는 작업이 맡아야 한다. 종료 판정도 같은 배치에서 "일정 기간 목록에 보이지 않은 이벤트"를 기준으로 처리하는 게 맞다. 그래도 "목록 페이지가 곧 수집기"라는 선택은 맞았다. 서버 크롤러 없이, 고객 트래픽이 있는 한 데이터가 알아서 최신으로 유지됐다. 기간: 2026.02 ~ 2026.09 기술: PHP, MySQL, JavaScript, 카페24 스킨, Chart.js, localStorage 관련 프로젝트: 마더케이몰 이벤트관리