본문으로 건너뛰기
wooncloud

iframe 안 모달의 딤을 부모 화면 전체로, 조상 z-index:0 이 막는다

iframe 안에서 모달이 뜰 때 부모 화면 전체를 딤 처리하려다 조상의 z-index:0 stacking context 에 막힌 원인과, 딤이 켜진 동안만 풀어 주는 해법을 정리했습니다.

·6 min read·— views··

앱 한가운데 영역에 다른 서비스를 iframe 으로 임베드했는데, iframe 안에서 모달이 뜨면 딤(backdrop)이 iframe 영역만 덮었습니다. 화면 전체가 어두워져야 자연스러운데 말이죠.

iframe 안의 position: fixed 는 iframe 의 뷰포트 기준이라, 자식 문서는 자기 밖을 그릴 수 없습니다.

흔한 해법은 이렇습니다

부모가 대신 딤을 깔아 주면 됩니다.

  1. 자식이 postMessage({type: "modal", open: true}) 로 부모에게 알립니다.
  2. 부모가 position: fixed; inset: 0; z-index: 9000 딤을 body 에 깝니다.
  3. iframe 컨테이너만 position: relative; z-index: 9001 로 딤 위에 올립니다.
  4. 두 딤의 색을 맞추면 한 장처럼 보입니다.

그런데 실제로 해 보니 iframe 까지 딤 아래로 깔려서 모달을 클릭할 수 없었습니다.

왜 9001 이 안 먹을까요?

원인은 조상 레이아웃 컨테이너에 박혀 있던 position: relative; z-index: 0 한 줄이었습니다. 이 한 줄이 stacking context 를 만듭니다.

stacking context 안의 z-index 는 그 컨텍스트 안에서만 비교됩니다. 자식을 9001 로 올려도 바깥에서는 조상의 0 으로 취급되니, body 의 9000 을 넘지 못합니다.

z-index: 0 은 레이아웃 CSS 에 생각보다 흔하게 들어 있어서 놓치기 쉽습니다.

먼저 재고, 딤이 켜진 동안만 풀어 줍니다

추측하지 말고 먼저 재는 게 빠릅니다. iframe 부터 <html> 까지 조상을 올라가며 stacking context 를 만드는 속성을 덤프합니다.

찾아볼 것은 position 과 z-index 조합, flex/grid 자식의 z-index, opacity < 1, transform, filter, mask, contain, isolation, will-change 입니다.

범인을 찾았으면 딤이 켜진 동안만 그 조상의 인라인 z-index 를 auto 로 풀고, 끌 때 원래 인라인 값을 우선순위까지 복원합니다. CSS 파일은 건드리지 않습니다.

let dim = null;
const save = (el, p) => ({ v: el.style.getPropertyValue(p), pr: el.style.getPropertyPriority(p) });
const restore = (el, p, s) => s.v ? el.style.setProperty(p, s.v, s.pr) : el.style.removeProperty(p);
 
function setDim(open) {
  if (!open) {
    if (!dim) return;
    dim.el.remove();
    restore(dim.box, "z-index", dim.boxZ);
    restore(dim.box, "position", dim.boxPos);
    if (dim.anc) restore(dim.anc, "z-index", dim.ancZ);
    dim = null;
    return;
  }
  if (dim) return; // 멱등
  const box = iframe.parentElement;
  const anc = box.closest(".layout"); // z-index:0 으로 stacking context 를 만드는 조상
  const el = Object.assign(document.createElement("div"), { style: "position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,.5)" });
  dim = { el, box, anc, boxZ: save(box, "z-index"), boxPos: save(box, "position"), ancZ: anc && save(anc, "z-index") };
  document.body.append(el);
  box.style.setProperty("position", "relative");
  box.style.setProperty("z-index", "9001");
  if (anc) anc.style.setProperty("z-index", "auto");
}

저장은 off 에서 on 으로 넘어갈 때만 합니다. 모달이 겹치거나 톤이 바뀌어서 다시 불려도 원래 값이 덮이지 않도록 멱등하게 짠 겁니다.

해제 경로는 한 함수로 모읍니다

자식이 open: false 를 못 보내는 경우가 많습니다. 모달이 열린 채로 리로드되거나 화면이 바뀌면 딤이 영영 남죠.

그래서 해제 경로를 한 함수로 모으고 세 군데에서 부릅니다.

  • iframe 이 새 문서로 load 될 때(리로드, src 교체)
  • 부모 화면이 전환될 때
  • 자식이 다시 ready 를 보낼 때

안전장치도 하나 둡니다. 딤을 켠 직후 document.elementFromPoint 로 iframe 의 보이는 영역 여러 지점을 찍어 봅니다. 어디서도 iframe 이 최상단이 아니면 딤을 걷습니다. 레이아웃이 나중에 바뀌어도 "모달을 클릭할 수 없다"는 최악만은 피하려는 용도입니다.

덤, CSP 가 막힌 same-origin iframe 에 메시지 흉내 내기

테스트하다가 막힌 부분입니다. 수신부가 e.source === iframe.contentWindow 를 검사하면, 부모에서 postMessage 를 불러서는 검증이 안 됩니다. e.source 가 부모 창이 되기 때문입니다.

자식 문서의 CSP(script-src 'self')가 eval 을 막으면 자식 쪽에서 코드를 돌리기도 어렵습니다.

이럴 땐 같은 iframe 을 about:blank 로 이동시킵니다. 부모가 연 about:blank 는 부모의 출처를 물려받고, contentWindow(WindowProxy)도 그대로라 e.source 검사를 통과합니다. 그 상태에서 iframe.contentWindow.eval('parent.postMessage(...)') 로 메시지를 보내면 됩니다.

about:blank 는 배경이 투명해서 스크린샷에 부모 딤이 비쳐 보이는데, 정상입니다.

참고