iframe 높이 자동 조절: srcdoc과 postMessage로 코드 미리보기 만들기
블로그 코드 미리보기가 비어 나오던 버그를 고치며 Shadow DOM 대신 sandbox iframe을 고른 이유와, iframe 높이를 내용에 맞추는 법(postMessage, ResizeObserver, margin 함정)을 정리했다.
미리보기가 비어 있었다
이 블로그는 글 속에 HTML, CSS, JS 코드를 탭으로 넣으면 그 코드를 실제 화면으로 보여주는 미리보기 컴포넌트가 있다. 그런데 어느 날 보니 미리보기 칸이 전부 비어 있었다. 코드를 열어보니 원인이 두 가지였다.
원인 1: iframe 쪽은 구현이 안 돼 있었다
JS 탭이 있으면 iframe으로 그리도록 나눠 놨는데, 정작 그 부분이 비어 있었다.
const renderPreview = () => {
if (props.useIframe) {
generateCombinedContent();
// iframe 구현은 여기에 추가할 수 있음 ← HTML 문자열만 만들고 아무 데도 안 넣음
} else {
renderWithShadowDOM();
}
};
원인 2: Shadow DOM을 두 번 만들려다 에러
JS 탭이 없으면 Shadow DOM으로 그렸다. 문제는 코드가 화면이 뜬 뒤에 들어온다는 점이었다. 처음 그릴 때 Shadow DOM을 만들고, 코드가 들어와 다시 그리면서 attachShadow를 또 불렀다.
const shadow = previewHost.value.attachShadow({ mode: "open" }); // 두 번째 호출에서 에러
attachShadow는 한 요소에 한 번만 쓸 수 있다. 두 번 부르면 Chromium에서 이런 에러가 난다.
NotSupportedError: Failed to execute 'attachShadow' on 'Element':
Shadow root cannot be created on a host which already hosts a shadow tree.
이미 만든 Shadow DOM은 element.shadowRoot로 꺼내 쓰면 된다. 하지만 고치다 보니 더 근본적인 문제가 보였다.
Shadow DOM 대신 iframe을 고른 이유
미리보기는 "글 속 코드를 독립된 웹 페이지처럼 실행"해야 한다. Shadow DOM은 스타일만 나눌 뿐, 같은 문서 안에 있다.
브라우저에서 직접 확인해 봤다.
| 확인한 것 | Shadow DOM | iframe |
|---|---|---|
| 데모 JS가 querySelector로 요소를 찾는가 | 못 찾는다 (null) | 찾는다 |
| 데모 JS가 블로그 페이지에 영향을 주는가 | 준다 (같은 window) | 안 준다 |
| 데모의 <dialog>, alert() | 블로그 위에서 뜬다 | 미리보기 안에서 뜬다 |
Shadow DOM 안의 요소는 바깥 문서의 document.querySelector("#btn")로 보이지 않는다. 데모 JS는 바깥 문서에서 실행되니 null이 나온다.
데모 코드는 대부분 document.querySelector(...)로 시작한다. Shadow DOM에 넣으면 이 첫 줄부터 null이 나와서 데모가 동작하지 않는다. 그래서 모든 미리보기를 iframe 하나로 통일했다.
srcdoc: 주소 없이 iframe 안에 문서 넣기
iframe에 HTML을 넣는 가장 간단한 방법은 srcdoc 속성이다. 파일이나 주소 없이 HTML 문자열을 그대로 넣으면 그게 iframe 안의 문서가 된다.
const srcdoc = computed(() => {
const close = "</" + "script>";
return `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<style>body { margin: 0; padding: 1.5rem; display: flow-root; }</style>
<style>${props.cssCode}</style>
</head>
<body>
${props.htmlCode}
<script>${BOOTSTRAP}${close}
${props.jsCode ? `<script>${props.jsCode}${close}` : ""}
</body>
</html>`;
});
"</" + "script>"처럼 쪼개 쓴 데는 이유가 있다. Vue 파일의 <script setup> 안에 </script>를 그대로 쓰면, Vue 컴파일러가 거기서 컴포넌트의 script가 끝났다고 읽는다. 문자열을 쪼개면 이 문제를 피할 수 있다.
<iframe
sandbox="allow-scripts allow-modals allow-forms"
:srcdoc="srcdoc"
:style="{ height: `${height}px` }"
></iframe>
sandbox: 필요한 것만 허용한다
sandbox 속성을 넣으면 iframe 안에서 거의 모든 동작이 막히고, 적어 준 것만 허용된다.
| 값 | 허용하는 것 | 넣은 이유 |
|---|---|---|
| allow-scripts | 스크립트 실행 | JS 데모와 높이 알림 스크립트 |
| allow-modals | alert(), confirm() | 데모에서 자주 쓴다 |
| allow-forms | 폼 제출 | 폼 검증, <dialog> 닫기 데모 |
| allow-same-origin | 넣지 않았다 | 아래 참고 |
allow-same-origin은 일부러 뺐다. srcdoc iframe은 부모 페이지와 같은 출처(origin)로 취급되는데, 여기에 allow-scripts와 allow-same-origin을 같이 주면 iframe 안의 스크립트가 부모 페이지에 접근하고, 심지어 자기 sandbox 속성을 지울 수도 있다. 이 둘을 같이 주면 sandbox를 쓰는 의미가 거의 없어진다.
iframe 높이를 내용에 맞추기
여기서부터가 진짜 문제였다. iframe은 내용이 길어져도 높이가 저절로 늘어나지 않는다. 기본 높이(150px)에서 잘리거나, 높이를 크게 잡으면 빈 공간이 생긴다.
부모가 iframe 안을 직접 잴 수 없다
보통은 부모에서 iframe 안의 문서(contentDocument)를 열어 높이를 읽으면 될 것 같다. 그런데 allow-same-origin 없이 sandbox를 걸면 iframe 안은 다른 출처로 취급된다. 실제로 찍어 보면 이렇다.
document.getElementById("f").contentDocument; // null
그래서 방향을 바꿨다. iframe 안에서 자기 높이를 재서 부모에게 알려준다. 창(window) 사이에 메시지를 보내는 postMessage를 쓴다.
// iframe 안에 넣는 스크립트 (BOOTSTRAP)
const report = () =>
parent.postMessage({ codePreviewHeight: document.body.getBoundingClientRect().height }, "*");
new ResizeObserver(report).observe(document.body); // 내용이 바뀌어 크기가 변할 때마다
addEventListener("load", report); // 이미지 등이 다 불러와졌을 때
addEventListener("resize", report); // iframe 폭이 바뀌어 줄바꿈이 달라질 때
ResizeObserver가 핵심이다. 데모에서 버튼을 눌러 내용이 늘어나거나 <details>를 펼쳐도 높이를 다시 알려준다.
부모는 "내 iframe이 보낸 메시지인지" 확인하고 받는다
const onMessage = (e) => {
if (e.source !== frame.value?.contentWindow) return; // 다른 iframe·광고가 보낸 메시지는 무시
const h = e.data?.codePreviewHeight;
if (typeof h === "number") height.value = Math.max(80, Math.ceil(h));
};
onMounted(() => window.addEventListener("message", onMessage));
onBeforeUnmount(() => window.removeEventListener("message", onMessage));
message 이벤트는 페이지 안의 모든 iframe에서 온다. 한 글에 미리보기가 여러 개 있고 광고 iframe도 있으니, e.source로 보낸 창을 확인하지 않으면 다른 미리보기의 높이를 받아 버린다. 출처(e.origin)로 확인하는 방법은 여기서 쓸 수 없다. sandbox iframe의 출처는 "null"이기 때문이다.
함정 1: scrollHeight로 재면 줄어들지 않는다
iframe 안에서 document.documentElement.scrollHeight로 재고 싶어지는데, 이 값은 iframe의 현재 높이보다 작아지지 않는다. 실제로 높이 400px인 iframe에 한 줄짜리 내용을 넣고 재 보면 이렇다.
document.documentElement.scrollHeight; // 400 (iframe 높이 그대로)
document.body.getBoundingClientRect().height; // 실제 내용 높이
scrollHeight로 재면 iframe은 커질 수는 있어도 다시 작아지지 않는다. 그래서 body의 실제 크기를 쟀다.
함정 2: 마진이 body 밖으로 빠져나간다
body 크기를 재도 값이 이상하게 작을 때가 있다. 첫 요소의 위쪽 마진과 마지막 요소의 아래쪽 마진이 body 밖으로 빠져나가기 때문이다(마진 상쇄, margin collapsing). body { margin: 0 }에 위아래 마진 40px인 <p> 하나를 넣고 재 보면 차이가 확실하다.
| body 스타일 | 측정한 높이 |
|---|---|
| 기본 | 18px. 마진 80px이 빠져서 내용이 잘린다 |
| display: flow-root | 98px. 마진까지 포함된다 |
display: flow-root는 body를 독립된 블록 영역으로 만들어서 자식의 마진이 밖으로 새지 않게 한다. 예전에는 overflow: hidden이나 padding-top: 1px 같은 꼼수를 썼는데, 지금은 이 한 줄이면 된다.
그 밖에 막아 둔 것
| 문제 | 처리 |
|---|---|
| 데모에서 폼을 제출하면 iframe이 빈 페이지로 이동한다 | submit 이벤트에서 preventDefault(). 단 <dialog>를 닫는 method="dialog" 폼은 허용 |
| 블로그 페이지의 CSP(Content-Security-Policy)가 데모를 막는다 | srcdoc iframe은 부모 페이지의 CSP를 물려받는다. 이 블로그는 데모의 인라인 스크립트 때문에 'unsafe-inline'을 남겨 뒀다 |
직접 해보기
아래 미리보기가 이 글에서 만든 컴포넌트다. "항목 추가"를 누르면 내용이 늘어나는 만큼 미리보기 높이도 같이 늘어난다.
"처음으로"를 누르면 높이가 다시 줄어든다. scrollHeight로 쟀다면 여기서 줄어들지 않았을 것이다.
정리
| 하고 싶은 것 | 방법 |
|---|---|
| 코드를 독립된 페이지처럼 실행 | Shadow DOM이 아니라 iframe + srcdoc |
| 안전하게 실행 | sandbox를 건다. allow-same-origin은 빼고 |
| 높이 재기 | iframe 안에서 body 크기를 잰다 (getBoundingClientRect) |
| 높이 알리기 | ResizeObserver + parent.postMessage |
| 메시지 받기 | e.source로 보낸 iframe을 확인 |
| 마진이 빠지는 문제 | body에 display: flow-root |
iframe 높이 자동 조절은 "부모가 재면 된다"고 생각하기 쉽지만, sandbox를 거는 순간 부모는 안을 볼 수 없다. 안에서 재고, 메시지로 알리는 방식으로 생각을 바꾸면 sandbox와 높이 조절을 둘 다 가져갈 수 있다.