마더케이몰 리뉴얼 개발 기록 (3) 장바구니·관심상품·쿠폰, 카페24 기본 기능에 끼어들기

기본 기능 위에 기능을 덧붙인다는 것 마더케이몰 리뉴얼에서 화면 배치보다 시간이 더 든 쪽은 장바구니, 관심상품, 쿠폰처럼 카페24가 이미 갖고 있는 기능을 다른 모양으로 쓰게 만드는 일이었다. 카페24 스킨에서는 서버 코드를 쓸 수 없다. 기능을 바꾸려면 카페24가 렌더링한 페이지와 프론트 함수를 그대로 쓰면서 그 앞이나 뒤에 끼어드는 수밖에 없다. 그런데 이 함수들은 문서화된 API가 아니고, 어떤 페이지 맥락을 전제하는지도 겉으로 드러나지 않는다. 이 글은 그런 작업 중 기록이 가장 많이 남은 세 가지를 정리한 것이다. 장바구니 화면의 관심상품 버튼, 메인 상품 카드의 장바구니 버튼, 상세 페이지의 할인가 계산이다. 관심상품 버튼: 하루에 여섯 번 바뀐 등록 방식 장바구니 화면의 상품마다 하트 버튼을 달기로 했다. 문제는 카페24 장바구니에 원래 붙어 있는 관심상품 동작이 BasketNew.moveWish 라는 점이었다. 이름 그대로 상품을 관심상품으로 "옮기는" 동작이라, 누르면 장바구니에서 상품이 빠진다. 원하는 동작은 장바구니는 그대로 두고 관심상품에 등록만 하는 것이었다. 그래서 버튼 클릭을 capture 단계에서 먼저 잡아 원래 동작을 막고, 등록은 따로 하기로 했다. 등록 방법은 3월 17일 하루 동안 여섯 번 바뀌었다. alert 가로채기. 원래 등록 흐름을 쓰되 window.alert 를 잠시 바꿔 치워, "등록" 또는 "이미" 문구가 뜨면 버튼을 활성 상태로 바꿨다. 등록 성공을 알 방법이 알림창 문구뿐이었기 때문이다. 하지만 원래 onclick이 남아 있으면 장바구니 삭제가 같이 일어났다. 원래 onclick 제거 + XHR POST. 버튼의 onclick 속성을 지우고, 카페24가 다시 그려도 지워지도록 MutationObserver 를 걸었다. 등록은 /exec/front/wishlist/WishListAdd 에 product_no 를 직접 POST했다. 상품번호는 같은 상품 블록의 썸네일 링크에서 정규식으로 뽑았다. add_wishlist 호출. 다음 커밋 "add_wishlist() 함수 미호출 수정"에서 상세 페이지와 같은 방식을 쓰려고 카페24의 add_wishlist() 를 직접 불렀다. 함수가 없을 때를 대비해 숨긴 iframe으로 등록 주소를 여는 폴백도 넣었다. 페이지를 열 때 관심상품 목록 페이지를 받아 이미 등록된 상품의 하트를 켜는 기능도 이때 붙였다. XHR GET + iframe 폴백. "sOptionType 에러 수정" 커밋에서 add_wishlist 를 다시 뺐다. 이 함수는 상세 페이지의 옵션 값( sOptionType )을 찾는데, 장바구니 화면에는 그 값이 없어 에러가 났다. 숨긴 iframe을 target으로 하는 form POST. 등록 요청을 페이지 이동처럼 보내되 결과가 화면에 보이지 않게, 이름 붙인 숨김 iframe을 만들고 form의 target 으로 지정해 제출했다. 숨긴 iframe에 상세 페이지를 열고 그 안의 add_wishlist 호출. 마지막 "장바구니 관심상품 등록 최종 수정"에서 방향을 바꿨다. 등록 요청을 내가 흉내 내는 대신, 상세 페이지를 화면 밖 iframe에 열어 그 페이지 맥락에서 카페24 함수를 그대로 부르게 했다. // 백그라운드에서 iframe으로 상세페이지 로드 후 add_wishlist 호출 var iframe = document.createElement('iframe'); iframe.style.cssText = 'position:fixed;left:-9999px;width:1px;height:1px;opacity:0;'; document.body.appendChild(iframe); iframe.src = '/product/detail.html?product_no=' + pno; iframe.onload = function () { try { var iWin = iframe.contentWindow; iWin.alert = function () {}; iWin.confirm = function () { return true; }; if (typeof iWin.add_wishlist === 'function') { var iBtn = iWin.document.getElementById('actionWish'); iWin.add_wishlist(iBtn, true); } } catch (ex) {} setTimeout(function () { iframe.remove(); }, 3000); }; 상세 페이지 안에서는 add_wishlist 가 필요로 하는 옵션 정보와 버튼 요소( #actionWish )가 모두 있다. 그 안의 알림창과 확인창은 비워 두어 사용자에게 보이지 않게 했다. 버튼은 요청을 보내자마자 활성 상태로 바꾸고, 다음에 장바구니를 열 때는 관심상품 목록 페이지를 읽어 실제 등록 상태로 맞춘다. 이 과정에서 배운 것은 카페24 프론트 함수는 "그 함수가 원래 사는 페이지"에서 부르는 게 가장 안전하다는 점이다. 요청 주소와 파라미터를 흉내 내는 방식은 그 순간에는 동작해도, 함수가 내부에서 참조하는 값이 무엇인지 모르기 때문에 언제 깨질지 알 수 없다. 적립금 표시도 비슷한 이유로 3월 4일에 fetch + DOMParser 방식에서 숨긴 iframe으로 적립금 페이지를 열어 값을 읽는 방식으로 바꿨다. 장바구니 버튼: 52개 파일을 바꿨다가 되돌리다 메인 섹션과 목록 페이지의 상품 카드에 장바구니 버튼을 썸네일 위로 올리는 작업을 하고 있었다. 카페24 목록 템플릿에서는 {$basket_icon} 변수가 장바구니 아이콘 이미지를 내려주는데, 이 이미지에는 카페24가 만든 onclick 이 붙어 있다. 템플릿마다 위치를 맞추려고 인라인 margin-top: -70px 같은 스타일이 여기저기 들어가 있었다. 3월 27일, 이걸 한 번에 정리하려고 "장바구니 버튼 전체 수정" 커밋을 만들었다. 기획전, 소싱, 베스트, 신상품, 검색 결과 등 52개 템플릿에서 {$basket_icon} 을 지우고, 상품번호를 data-pno 로 가진 내 버튼( didy-cart-btn )으로 바꿨다. 클릭하면 JS가 카페24 옵션 선택 함수를 부르는 구조다. 바로 이어서 "버튼 원복" 커밋이 나왔고, 같은 날 두 커밋을 통째로 되돌렸다. 범위가 너무 넓었다. 템플릿마다 상품 블록 구조가 달랐고, 카페24 아이콘이 갖고 있던 onclick 동작을 내 버튼이 그대로 대신하는지 52곳을 운영 화면에서 하나하나 확인할 방법이 없었다. 그 뒤로는 범위를 메인 섹션( _didy/sections )으로 좁혔다. 그래도 3월 31일에 "인덱스 장바구니 미작동 문제"가 나와 같은 날 다섯 번에 걸쳐 고쳤다. 흐름은 이랬다. 내 버튼에서 카페24 함수( CAPP_SHOP_NEW_PRODUCT_OPTIONSELECT.selectOptionCommon , category_add_basket )를 차례로 찾고, 둘 다 없으면 장바구니 주소로 직접 POST하는 폴백을 넣었다. 직접 POST 폴백은 곧 뺐다. 대신 원래 {$basket_icon} 을 display:none 으로 숨겨 다시 넣고, 내 버튼을 누르면 같은 상품의 숨은 아이콘 onclick 을 대신 실행하게 했다. 섹션 HTML의 숨김 처리 방식을 인라인 스타일과 CSS 사이에서 두 번 오갔고, 마지막에는 섹션마다 붙어 있던 클릭 처리 코드를 걷어 내고 문서 전체에 이벤트 위임을 하나만 걸었다("장바구니 에러 처리 완료"). document.body.addEventListener('click', function (e) { var btn = e.target.closest('.didy-cart-btn'); if (!btn) return; e.preventDefault(); e.stopPropagation(); var li = btn.closest('li[id^="anchorBoxId_"], .didy-recom-item, .didy-foryou-item, .didy-ranking-item'); // 1순위: 카페24가 렌더링한 원래 아이콘의 onclick if (li) { var hiddenImg = li.querySelector('.cart img[onclick]'); if (hiddenImg) { hiddenImg.click(); return; } } var pno = btn.getAttribute('data-pno'); if (!pno && li) pno = (li.id || '').replace('anchorBoxId_', ''); if (!pno) return; // 2순위: 카페24 옵션 선택 함수, 3순위: 옵션 선택 페이지를 하단 시트 iframe으로 if (window.CAPP_SHOP_NEW_PRODUCT_OPTIONSELECT) { CAPP_SHOP_NEW_PRODUCT_OPTIONSELECT.selectOptionCommon(Number(pno), 1, 'basket', '1'); } else if (typeof category_add_basket === 'function') { category_add_basket(pno, '1', '1', 'A0000', false, '1', '', 'A', 'F', '0'); } else { openBasketOption(pno); } }); 결국 버튼의 모양과 위치만 내가 만들고, 담는 동작은 카페24가 렌더링한 원래 아이콘에 맡기는 구조로 끝났다. 52개 파일을 한 번에 바꾸려던 시도와 정반대 방향이다. 할인가 계산: 쿠폰은 나중에 온다 상세 페이지 상단에는 정가, 할인율, 최종가를 크게 보여주고, 펼치면 "정상가 → 상품할인 → 추가할인 → 쿠폰할인" 순서로 내역이 나오게 만들었다. 3월 13일 첫 구현은 카페24가 렌더링한 가격 표의 행을 라벨과 ID로 찾아 판매가, 할인판매가, 쿠폰적용가를 읽는 방식이었다. 두 가지가 틀어졌다. 첫째, 쿠폰적용가 행은 페이지 로드 직후에는 비어 있다가 카페24가 AJAX로 쿠폰 정보를 받아 온 뒤에 채워진다. 처음 계산할 때 쿠폰이 없으니 최종가가 쿠폰 적용 전 가격으로 나왔다. 쿠폰 영역에 MutationObserver 를 걸어 두긴 했지만, 변화가 생기면 팝업과 내역 영역만 다시 그리고 최종가는 다시 계산하지 않았다. 둘째, 기준 가격이 섞였다. 처음 코드는 "판매가"를 정상가로 보고 할인율을 계산했는데, 상품에 따라 판매가 위에 "정가" 행이 따로 있었다. 정가가 있는 상품은 할인율이 실제보다 작게 나왔다. "할인율계산 수정" 커밋에서 가격을 네 단계(정가, 판매가, 할인판매가, 쿠폰적용가)로 나눴다. 정가가 있으면 정가를 원가로, 없으면 판매가를 원가로 쓴다. 최종가는 쿠폰적용가, 할인판매가, 판매가, 정가 순으로 값이 있는 첫 번째를 쓴다. 내역도 "정가→판매가"는 상품할인, "판매가→할인판매가"는 추가할인, 그 다음 단계는 쿠폰할인으로 나눠 보여준다. 이어서 "쿠폰이 AJAX로 로드되면 할인가 계산되도록 수정"에서 옵저버가 가격 전체를 다시 계산하게 바꿨다. // Cafe24 쿠폰 AJAX 로드 대응 → 가격 전체 재계산 if (couponSection) { var _couponTimer = null; var couponObs = new MutationObserver(function () { if (_couponTimer) clearTimeout(_couponTimer); _couponTimer = setTimeout(function () { populatePrices(); }, 150); }); couponObs.observe(couponSection, { childList: true, subtree: true }); } 쿠폰 영역은 카페24가 내용을 여러 번에 나눠 바꾸기 때문에, 변화가 올 때마다 바로 계산하면 같은 작업을 여러 번 하게 된다. 150ms 동안 추가 변화가 없을 때 한 번만 계산하도록 묶었다. 연관상품 목록의 할인가가 상세 페이지와 맞지 않던 문제도 같은 날 가격을 가져오는 방식을 고쳐 맞췄다. 결과 장바구니 화면에서 하트를 눌러도 상품이 장바구니에서 빠지지 않고, 다시 열면 등록 상태가 유지된다. 메인 섹션의 장바구니 버튼은 옵션 있는 상품이면 카페24 옵션 선택 창을