본문으로 건너뛰기
wooncloud

렌더 실패가 앱 밖으로 새는 이유 — 라이브러리가 document.body에 그릴 때

서드파티 라이브러리가 document.body에 직접 요소를 생성할 때 발생하는 렌더링 잔재 문제를 해결하는 방법과, 레이아웃 엔진을 고려한 브라우저 테스트의 중요성을 다룹니다

·10 min read· views·

격리된 렌더 표면 안에 담긴 조각

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

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를 지운다. 문제는 실패 경로다.

// 라이브러리 내부 (요약)
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에서 제거한다. 그러면 라이브러리가 어느 경로로 빠져나가든 그 안의 잔재는 컨테이너째 사라진다.

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을 쓰기 쉬운데, 이 경우엔 정상 문법의 다이어그램까지 렌더가 실패한다. 실제로 측정해 보면 간선 좌표 계산 단계에서 예외가 난다.

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분이면 실측할 수 있다.

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가 얽힌 판단은 실제 브라우저에서 측정한다. 대조군 없는 "재현 실패"는 근거가 아니다.

참고