---
title: "렌더 실패가 앱 밖으로 새는 이유 — 라이브러리가 document.body에 그릴 때"
description: "서드파티 라이브러리가 document.body에 직접 요소를 생성할 때 발생하는 렌더링 잔재 문제를 해결하는 방법과, 레이아웃 엔진을 고려한 브라우저 테스트의 중요성을 다룹니다"
date: 2026-08-20
updated: 2026-08-20T10:36:20.108Z
tags: [frontend, dom, react, testing]
canonical: https://blog.wooncloud.com/posts/library-drawing-to-document-body
---

![격리된 렌더 표면 안에 담긴 조각](/images/posts/library-drawing-to-document-body/06a680c3-1b66-48cc-9e79-5904615b8d1c.webp)

마크다운 뷰어에 다이어그램 렌더링 라이브러리(mermaid)를 붙였다. 코드 블록을 찾아 SVG로 바꾸고, 파싱에 실패하면 원본 코드를 그대로 두는 흔한 구성이다.

```ts
try {
  const { svg } = await mermaid.render(id, source);
  target.replaceWith(makeWrapper(svg));
} catch {
  /* 실패하면 원본 코드 블록 유지 */
}
```

그런데 구문 오류가 있는 문서를 한 번 열면, **미리보기 모달을 닫은 뒤에도** "Syntax error in text" 배너가 화면에 남았다. 목록 화면으로 돌아가도 그대로 있고, 실패한 블록 수만큼 쌓였다.

## catch는 예외를 막을 뿐 부작용을 되돌리지 않는다

`catch`로 예외를 삼켰는데 왜 화면에 뭔가 남을까. 라이브러리 소스를 열어 보면 답이 나온다.

`render(id, text, container)`는 **세 번째 인자가 없으면 `document.body`에 임시 `<div>`를 만들어** 거기서 그린다. 실제 DOM에 붙어야 텍스트 폭을 잴 수 있기 때문이다. 성공하면 마지막에 그 div를 지운다. 문제는 실패 경로다.

```js
// 라이브러리 내부 (요약)
try {
  diagram = await Diagram.fromText(text);
} catch (e) {
  if (config.suppressErrorRendering) { removeTempElements(); throw e; }
  diagram = await Diagram.fromText("error");  // 에러 다이어그램으로 대체
  parseException = e;
}
// ...draw...
if (parseException) throw parseException;    // ← 정리 직전에 throw
removeTempElements();                         // ← 도달하지 않는다
```

에러 다이어그램을 임시 div에 그려 놓고, **정리 코드 바로 앞에서 throw** 한다. 그 div는 컴포넌트 트리 밖 `document.body` 직속이라 **언마운트로 사라지지 않는다.** 프레임워크가 관리하는 노드가 아니기 때문이다. id가 매번 달라 실패할 때마다 하나씩 누적된다.

서드파티가 전역 DOM에 쓰는 라이브러리라면, "예외를 삼켰으니 아무 일도 없다"는 가정이 성립하지 않는다.

## 렌더 표면을 직접 소유한다

라이브러리 설정에 의존하지 말고 컨테이너를 우리가 만들어 넘기고 `finally`에서 제거한다. 그러면 라이브러리가 어느 경로로 빠져나가든 그 안의 잔재는 컨테이너째 사라진다.

```ts
function createSandbox(): HTMLDivElement {
  const el = document.createElement("div");
  el.setAttribute("aria-hidden", "true");
  // display:none 은 금지 — 레이아웃이 죽으면 텍스트 측정이 깨진다
  el.style.cssText =
    "position:fixed;left:-10000px;top:0;width:1200px;height:0;overflow:hidden;contain:paint";
  document.body.appendChild(el);
  return el;
}

const sandbox = createSandbox();
try {
  for (const block of blocks) {
    try {
      const { svg } = await lib.render(nextId(), block.source, sandbox); // ← 컨테이너 전달
      block.replaceWith(makeWrapper(svg));
    } catch {
      markError(block);
    }
  }
} finally {
  sandbox.remove(); // 경로와 무관하게 정리되는 유일한 지점
}
```

설정 플래그(`suppressErrorRendering: true`)도 함께 켰지만, 이건 **벨트지 본선이 아니다.** 그 플래그는 파싱 실패 경로만 정리한다. 그 뒤 단계(스타일 생성 → 접근성 정보 추가 → sanitize)에서 예외가 나면 플래그가 켜져 있어도 잔재가 남는다. 경로와 무관하게 정리되는 건 `finally` 쪽 하나뿐이다.

이 구분은 기록해 둘 가치가 있다. 두 방어를 같은 무게로 적어 두면, 나중에 누군가 "둘 중 하나면 충분하겠지" 하고 본선 쪽을 지운다.

## display:none을 쓰면 안 되는 이유

화면 밖으로 숨길 때 반사적으로 `display:none`을 쓰기 쉬운데, 이 경우엔 **정상 문법의 다이어그램까지 렌더가 실패한다.** 실제로 측정해 보면 간선 좌표 계산 단계에서 예외가 난다.

```text
Could not find a suitable point for the given distance
```

`display:none`은 레이아웃 박스를 만들지 않아 `getBoundingClientRect()`가 전부 0이 되기 때문이다. 우리 코드 입장에선 이게 "구문 오류"로 오분류된다 — 멀쩡한 문서에 오류 배지가 붙는다.

레이아웃은 살리고 시각적으로만 숨기려면 화면 밖 좌표(`left:-10000px`)에 `height:0; overflow:hidden`을 얹는다.

## 검증은 실제 브라우저에서

이런 판단은 jsdom 기반 유닛 테스트로 확인할 수 없다. **jsdom에는 레이아웃 엔진이 없다.** `getBoundingClientRect()`는 항상 0을 반환하고, `contain` 같은 CSS 동작도 재현되지 않는다. 그런데 코드 리뷰에서 올라오는 지적은 대개 이 영역이다("이 컨테이너 스타일이면 자식이 화면에 뜰 수 있다").

Playwright를 라이브러리로 쓰면 임시 정적 서버 + 프로브 페이지로 20분이면 실측할 수 있다.

```js
const server = http.createServer(serveStaticFiles);
await new Promise((r) => server.listen(0, r));  // 포트 자동 할당
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto(`http://localhost:${server.address().port}/probe.html`);
await page.waitForFunction(() => document.getElementById("out").textContent !== "pending");
console.log(await page.textContent("#out"));    // 측정 결과 JSON
await browser.close();
server.close();
```

프로브 페이지에서 같은 다이어그램을 조건만 바꿔 렌더하고 `getBBox()` 결과를 비교하면 추측이 숫자로 바뀐다. 이번 경우 컨테이너 폭이나 `contain:paint` 유무는 렌더 결과에 아무 영향이 없었고(bbox·노드 크기 전부 동일), `display:none`만 예외를 던졌다.

한 가지 더 — **"문제가 재현되지 않았다"를 결론으로 쓰려면 대조군이 필요하다.** 어떤 CSS 주입이 안 먹혔을 때, 그게 "주입 경로가 막혀서"인지 "내가 문법을 틀려서"인지 구분되지 않는다. 무해한 값을 같은 경로로 넣어 **그것도 반영되지 않는지** 확인해야 판정이 선다.

## 테스트가 회귀를 놓치는 지점

방어를 두 겹으로 하면 테스트에서 함정이 생긴다. 둘 중 하나만 지워도 최종 결과(“body에 잔재 0”)는 그대로라, 행위만 단언하는 테스트는 초록으로 통과한다.

실제로 한 축씩 지우고 돌려 봤다.

| 지운 것 | 실패한 테스트 |
|---|---|
| `finally`의 컨테이너 제거 | 5건 |
| `render`의 컨테이너 인자 | 2건 |
| 둘 다(원래 구현으로 복원) | 6건 |
| 설정 플래그만 | **1건** — 그것도 호출 인자 단언 |

플래그 축은 "우리가 그 값을 넘겼다"는 단언 하나가 지킨다. 이 단언을 지우면 그 축은 무방비가 된다. 이중 방어를 넣었다면 **각 축을 따로 단언**해야 하고, 어느 쪽이 본선인지도 함께 적어야 한다.

그리고 mock으로 벤더 동작을 흉내 낸 테스트와 별개로, **진짜 라이브러리로 돌리는 케이스를 하나 남겨 두는 게 좋다.** mock이 벤더의 실제 정리 규약에서 어긋나거나 업그레이드로 동작이 바뀌면 거기서 잡힌다.

## 정리

- 서드파티가 `document.body`에 그리는 순간, 그 노드의 수명은 프레임워크가 아니라 **그 라이브러리의 에러 처리 품질**에 달린다.
- `catch`는 예외를 막지 부작용을 되돌리지 않는다. 전역 DOM에 쓰는 라이브러리라면 **렌더 표면을 직접 만들어 넘기고 `finally`로 회수**하는 게 유일하게 경로 독립적인 방어다.
- 설정 플래그로 막을 수 있어 보여도 **어느 경로까지 커버하는지** 소스에서 확인하고 무게를 구분해 기록한다.
- 레이아웃·CSS가 얽힌 판단은 실제 브라우저에서 측정한다. 대조군 없는 "재현 실패"는 근거가 아니다.

### 참고

- [MDN — CSS containment](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment)
- [MDN — Element.getBoundingClientRect()](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect)
- [Playwright — Library usage](https://playwright.dev/docs/library)
