마더케이몰 리뉴얼 개발 기록 (2) 앱처럼 화면 넘기기: iframe 레이어를 하루에 세 번 갈아엎은 이야기

페이지를 넘겨도 메인이 남아 있게 마더케이몰 모바일 메인은 리뉴얼 뒤 섹션이 열 개가 넘는 긴 페이지가 됐다. 사용자는 메인을 한참 내려가다 상품이나 기획전을 눌러 들어가고, 다시 뒤로 나온다. 카페24 기본 동작에서는 이때마다 메인 전체를 새로 불러온다. 섹션마다 상품 목록을 받아 와서 그리는 구조라, 돌아올 때마다 그 과정을 처음부터 다시 거쳐야 했다. 그래서 앱처럼 메인 위에 새 화면을 한 장 덮고, 뒤로가기를 누르면 그 한 장만 걷어 내는 방식을 원했다. 메인은 뒤에 그대로 살아 있으니 다시 그릴 필요가 없다. 이 글은 그 기능을 만드는 동안 방식을 여러 번 갈아엎은 기록이다. 먼저 만든 것: 레이어 스택 어떤 방식으로 화면을 덮든 공통으로 필요한 것이 있었다. 브라우저 뒤로가기(안드로이드에서는 하드웨어 뒤로 버튼)를 눌렀을 때 페이지가 이동하지 않고 가장 위의 레이어만 닫혀야 한다. 2월 19일에 배너 모달과 페이지 레이어를 같이 관리하는 스택을 먼저 만들었다. var layerStack = []; if (!history.state || !history.state.didyBase) { history.replaceState({ didyBase: true }, ''); } function pushLayer(closeFn) { layerStack.push({ close: closeFn }); history.pushState({ didyLayerCount: layerStack.length }, ''); document.body.classList.add('didy-layer-open'); } function popTopLayer() { if (layerStack.length === 0) return; layerStack.pop().close(); if (layerStack.length === 0) { document.body.classList.remove('didy-layer-open'); } } window.addEventListener('popstate', function () { if (layerStack.length > 0) popTopLayer(); }); 레이어를 열 때마다 history에 상태를 하나 넣고, popstate 가 오면 스택 맨 위의 닫기 함수만 실행한다. 레이어 안의 닫기 버튼도 직접 레이어를 지우지 않고 history.back() 을 부른다. 닫는 경로를 하나로 모아 두지 않으면 history와 스택 개수가 어긋나서, 뒤로가기를 두 번 눌러야 닫히는 일이 생긴다. 이 원칙은 끝까지 유지했다. 시도 1: 모든 링크를 iframe 레이어로 첫 구현은 단순했다. 문서에서 같은 도메인 <a> 클릭을 전부 가로채고, 전체 화면 오버레이 안에 iframe을 만들어 그 주소를 연다(커밋 "모든 링크 아이프레임으로 열리도록 인터셉트처리"). 메인은 뒤에 그대로 있고, 뒤로가기를 누르면 iframe이 슬라이드로 빠진다. 보기에는 원하던 모습이었다. 문제는 카페24 페이지가 자기가 iframe 안에 있다는 걸 모른다는 점이었다. 상세 페이지 상단의 뒤로가기 버튼은 onclick="history.go(-1)" 로 박혀 있어서, iframe 안에서 누르면 iframe의 history만 움직였다. 그래서 iframe이 로드될 때 스크립트를 주입해 이런 버튼을 찾아 부모에게 postMessage 로 "닫아 달라"고 보내게 바꿨다. iframe 안에 카페24 헤더와 레이어 헤더가 두 겹으로 보이는 문제도 있어서 CSS를 주입해 안쪽 헤더를 숨겼다. 그다음은 예외 목록이 계속 늘어나는 단계였다. 장바구니( basket.html )와 마이페이지( myshop ) 주소, 그리고 홈 주소는 레이어에서 빼고 일반 이동으로 돌렸다. 홈을 레이어로 열면 메인 위에 메인이 한 겹 더 쌓일 뿐이다. 헤더의 장바구니와 내 정보 링크도 같은 날 규칙에서 제외했다. 3월 10일: 하루에 세 번 방식을 바꾸다 가장 크게 흔들린 날은 3월 10일이다. 공통 레이아웃과 스티키 메뉴를 정리하던 중이었고, 결제까지 iframe 안에서 열리는 것이 마음에 걸렸다. 결제 흐름에는 PG 화면과 외부 도메인이 섞여 있어서, iframe 안에서 어떻게 동작할지 장담할 수 없었다. 두 번째 방식: View Transition으로 바꾸기 첫 커밋은 "iframe 관련 스타일을 페이지 트랜지션 스타일로 교체"였다. iframe을 아예 버리고 일반 페이지 이동으로 돌아가되, 지원하는 브라우저에서는 document.startViewTransition() 안에서 location.href 를 바꿔 전환 효과만 주는 방식이다. 코드 주석에도 "iframe 대신 일반 페이지 이동 사용 (결제 등 호환성 보장)"이라고 적었다. 결제 호환성은 해결됐지만, 원래 원하던 것을 잃었다. 일반 이동이니 메인은 매번 사라지고, 돌아오면 다시 그려야 한다. 전환 효과는 처음 문제의 원인이 아니었다. 세 번째 방식: fetch로 받아서 오버레이에 넣기 다음 커밋 메시지는 "페이지가 유지되는 오버레이 추가"였다. 메인을 살리면서 iframe도 피하려고, 링크 대상 페이지를 fetch 로 받아 DOMParser 로 파싱하고, #contents 만 떼어 오버레이에 넣었다. 그 페이지 전용 CSS를 복사하고, 스크립트는 모아서 순서대로 다시 실행했다. 이때 결제·주문·로그인 경로( /order/ , /member/ , /exec/ , /payment/ , /pg/ )는 일반 이동으로 보내는 패턴 목록을 처음 만들었다. 그런데 이 방식은 남의 페이지 스크립트를 메인 페이지 전역에서 다시 돌리는 구조다. 이미 로드된 jQuery, 레이아웃 공통 스크립트, 태그 매니저, 소셜 로그인 스크립트를 두 번 실행하지 않으려고 건너뛸 목록을 만들어야 했고, 페이지마다 어떤 스크립트가 무엇을 전제하는지는 알 수 없었다. 오버레이 안의 링크 클릭도 다시 직접 처리해야 했다. 네 번째 방식: iframe으로 돌아가되 결제는 빠져나가게 그날 마지막 커밋은 "결제시에는 아이프레임 미사용으로 변경"이었다. 결론은 iframe으로 돌아가는 것이었다. 스크립트 격리는 iframe이 가장 확실하다. 대신 처음 버전에서 불안했던 결제 문제를 "빠져나가기(breakout)" 규칙으로 막았다. 메인( .didy-wrap 이 있는 페이지)에서만 링크를 가로챈다. 서브 페이지끼리는 일반 이동이다. 클릭 시점에 결제·주문·로그인 패턴이면 가로채지 않는다. iframe이 로드될 때마다 iframe의 현재 주소를 확인한다. 안에서 리다이렉트되어 결제 경로로 들어갔으면 부모 창을 그 주소로 보낸다. iframe 문서에 접근하다 예외가 나면 외부 도메인(PG 등)으로 넘어갔다는 뜻이므로 역시 부모 창을 이동시킨다. iframe 안에서 결제 링크를 누르면 window.top 으로 이동하게 하는 클릭 핸들러를 주입한다. var BREAKOUT_PATTERNS = ['/order/', '/member/', '/exec/', '/payment/', '/pg/']; iframe.addEventListener('load', function () { try { var iDoc = iframe.contentDocument || iframe.contentWindow.document; var currentUrl = iframe.contentWindow.location.href; // 결제/주문 URL이면 메인 윈도우로 이동 if (isBreakoutUrl(currentUrl)) { window.location.href = currentUrl; return; } // 타이틀 추출, 안쪽 헤더/푸터 숨김 CSS 주입, // 결제 링크 클릭 시 window.top으로 보내는 핸들러 주입 ... } catch (e) { // cross-origin (외부 결제 게이트웨이 등) → 메인 윈도우로 이동 window.location.href = fullUrl; } }); 세 번을 돌고 나서 보니, 첫 iframe 버전의 문제는 iframe 자체가 아니라 "어디서 빠져나와야 하는가"를 정하지 않은 데 있었다. 이 기준을 정하고 나서는 방식을 다시 바꾸지 않았다. 이후에 남은 문제들 헤더와 마이쇼핑 바 3월 12일에는 "iframe 예외목록 추가" 커밋으로 GNB, 상단 영역, 헤더, 퀵메뉴 안의 링크를 일반 이동으로 돌렸다. 헤더는 어느 페이지에서든 보이는 전역 메뉴라, 레이어 안쪽 화면과 섞이지 않게 일반 이동으로 두는 편이 맞다고 판단했다. 카테고리 링크만은 레이어를 허용하는 예외의 예외를 두었다. 3월 24일에는 마이쇼핑 바( .cate-myshop )도 같은 목록에 넣었다. 로그인 뒤 뒤로가기 마이페이지는 로그인을 거쳐 들어오는 경우가 많다. 이때 마이페이지 상단의 뒤로가기를 누르면 방금 지나온 로그인 페이지나 비밀번호 확인 페이지로 돌아갔다. 3월 24일에 마이페이지에서 document.referrer 를 보고, 직전 페이지가 로그인·비밀번호 확인·비밀번호 변경·회원정보 수정·약관 동의 중 하나이거나 referrer가 비어 있으면 뒤로가기 버튼을 홈 버튼으로 바꾸도록 했다. 아이콘과 aria-label 까지 홈으로 바꿔서 누르기 전에 어디로 가는지 보이게 했다. 결제 화면에서 뒤로가기를 두 번 3월 17일 결제 페이지를 손보다가 "결제 뒤로가기를 눌렀을 때 두 번 눌러야 이동되는 문제"를 만났다. 주문서 상단 뒤로가기 버튼이 history.back() 을 부르는 구조였다. 같은 시기 주문서에는 최대 할인 쿠폰을 자동으로 고르는 스크립트를 넣고 있었고, 페이지 안에서 쿠폰 레이어가 열리고 닫혔다. history 단계 수를 맞추려 하기보다 버튼이 document.referrer 로 이동하고, 없으면 장바구니로 가게 바꿨다. history 개수에 기대는 버튼은 이 몰처럼 레이어가 많은 화면에서 쉽게 어긋난다는 걸 여기서 다시 확인했다. iframe 안에서 열리는 장바구니 옵션 창 레이어 안의 상품 목록에서 장바구니 버튼을 누르면 카페24 옵션 선택 팝업( #capp-shop-new-product-optionselect )이 iframe 크기에 갇힌 채 떴다. 3월 27일 "아이프레임 장바구니 대응 수정"에서 이 팝업을 화면 전체를 덮는 반투명 배경과 하단 시트 모양으로 강제했다. 카페24가 인라인 스타일로 위치를 잡기 때문에 !important 를 쓸 수밖에 없었다. 레이어 안에서 다르게 보여야 하는 페이지 브랜드 목록처럼 레이어로 자주 열리는 페이지는 스스로 iframe 안인지 확인해( window !== window.top ) mk-in-iframe 클래스를 붙이고, 그 클래스일 때 헤더·띠배너·푸터를 숨기고 여백을 줄였다. 부모가 CSS를 주입하는 방식과 페이지가 스스로 맞추는 방식을 같이 쓰게 된 셈인데, 자체 헤더가 있는 페이지는 부모가 헤더를 숨기지 않도록 분기도 넣었다. 결과 지금 구조에서는 메인에서 상품, 기획전, 브랜드, 카테고리를 열면 메인 위에 레이어가 슬라이드로 올라오고, 뒤로가기 한 번이면 메인의 같은 스크롤 위치로 돌아온다. 결제·주문·로그인은 클릭 시점, iframe 로드 시점, 외부 도메인 진입 시점 세 군데에서 걸러져 항상 최상위 창에서 열린다. 헤더와 마이쇼핑 바는 일반 이동이다. 돌아보며 하루 동안 iframe에서 View Transition으로, 다시 fetch 오버레이로, 다시 iframe으로 돌아왔다. 돌아보면 처음에 문제를 "결제 때문에 iframe이 위험하다"로만 봤기 때문에 방법을 통째로 바꾸는 쪽으로 움직였다. 실제로 필요한 것은 "iframe 안에 두면 안 되는 페이지의 목록과, 그걸 감지하는 지점"이었다. 다시 한다면 방식을 고르기 전에 몰의 페이지를 세 종류로 먼저 나눠 표로 만들겠다. 레이어로 열어도 되는 페이지, 반드시 최상위에서 열려야 하는 페이지, 헤더처럼 레이어 깊이를 늘리면 안 되는 링크다. 또 하나는 예외 목록이 코드 여러 곳에 흩어진 점이다. 클릭 가로채기 조건, breakout 패턴, 페이지별 mk-in-iframe 스타일이 각각 다른 파일에 있다. 새 페이지가 생길 때 세 군데를 같이 봐야 한다는 걸 아는 사람만 안전하게 고칠 수 있는 구조라, 처음부터 한 곳에서 페이지 규칙을 선언하도록 만들었어야 했다. 기간: