DOM 캡처 개선

DOM 캡처 개선

들어가며

이번에 echo 서비스에 Snap 기능을 담당하여 개발하게 되었습니다. Snap은 사용자가 헤더의 카메라 아이콘을 누르면 지금 보고 있는 화면이 그대로 캡처되는 기능입니다. 글로만 설명하는 대신 화면 스크린샷을 함께 보내주는 것이니 캡처 결과물은 무엇이 잘못되었는지 보여주는 설명이자 증거인 셈입니다.

그래서 이 기능은 속도와 정확도를 일정 기준 이상으로 끌어올려야 했습니다. 첫 번째로 속도, 목표는 1초 내외였습니다. 제보하려는 흐름을 캡처가 막으면 안 되기 때문입니다. 다른 하나는 정확도로, 제보자가 본 화면과 스크린샷이 같아야 합니다. 다르면 증거로서 의미가 없습니다.

처음 개선 작업을 시작할 땐, 속도만 개선하면 문제가 해결될 줄 알았습니다. 속도 개선을 하며 다양한 스크린샷을 찍는 과정에서 정확도 문제가 있음을 파악하였습니다. 이 글은 그 두 단계를 각각 어떻게 풀었는지와, 그 결과 지금 어떤 파이프라인이 돌고 있는지를 정리한 것입니다. 결과부터 적자면 이렇습니다.

화면

노드 수

현재

클론 좌표 대조(정확도)

단순 목록 화면

1,476

436~457ms

996개 중 0개

다이어그램 화면

4,745

1,186ms

4,281개 중 0개

개선 전 3.3초였던 캡처가 지금은 가벼운 화면에서 0.45초, 노드가 3배인 무거운 화면에서도 1.2초입니다. 그리고 정확도 측면도 개선되어 무거운 화면에서 4,281개 요소의 좌표가 실제 화면과 완전히 일치하게 되었습니다.

DOM 캡처의 한계

문제의 근원은 snap이 실제 스크린샷을 찍는 것이 아니었다는 것이었습니다. html-to-image 같은 라이브러리는 웹 페이지가 실제로 그려진 픽셀에는 접근할 수 없어 실제로는 아래와 같이 동작합니다

① 화면의 DOM 트리를 통째로 복제

② 각 요소에 지금 적용된 CSS 값을 인라인 스타일로 하나하나 적용

③ 그 결과를 SVG의 <foreignObject> 안에 주입

④ 그 SVG를 <img>로 로드해 canvas에 그림

즉, 브라우저에게 같은 HTML을 한 번 더 그리게 시키는 것에 가깝습니다. 문제는 ④ 단계의 <img> 안 SVG가 원래 페이지와 완전히 격리된 것이라는 점입니다. 보안상의 이유로 그렇게 설계되어 있는데, 이 격리에는 세 가지 성질이 있고 각각이 나중에 실제 버그로 돌아왔습니다.

  • 부모 문서의 웹폰트에 접근할 수 없습니다. 임베드하지 않으면 시스템 폰트로 대체되어 글자 폭이 달라지고 레이아웃이 밀립니다.

  • 네트워크가 차단됩니다. 이미지 URL 을 그대로 두면 가져올 수 없으므로, 캡처 전에 전부 data URI(파일 내용을 문자열로 바꿔 넣은 형태)로 만들어 두어야 합니다.

  • 스크립팅이 꺼져 있습니다. JavaScript 실행 여부에 따라 동작이 달라지는 요소가 캡처 안에서만 다르게 렌더됩니다.

개발 과정 1 — 느린 원인을 찾고, 라이브러리 네 개를 붙여봤습니다

처음 받은 요청은 "스크린샷 생성이 다소 오래 걸리는 것 같다"였습니다. 실제로 재보니 다이얼로그는 즉시 열리지만 스크린샷이 준비될 때까지 3초 이상 스피너가 돌았고, 그동안 메인 스레드가 통째로 멈춰 클릭조차 처리되지 않았습니다. 브라우저의 JavaScript는 한 번에 한 가지 일만 처리하는 단일 스레드 구조라, 캡처가 그 스레드를 붙잡고 있으면 화면이 얼어붙은 것처럼 보입니다.

구간별 병목이 일어나는 부분을 탐색

처음에는 화면의 요소가 많아 시간이 오래 소모되는 것이라고 생각했고 어느 단계가 얼마나 걸리는지부터 나눠 재기로 했습니다. 측정 결과, 하나는 직렬화된 SVG 크기였습니다. 분석 결과 노드 하나당 약 40KB의 인라인 스타일이 붙었다는 결론에 다다랐습니다. 다른 하나는 cssText로, 이 값이 비어 있으면 라이브러리가 스타일을 한 번에 복사하는 빠른 경로 대신 프로퍼티를 하나씩 옮기는 경로를 탄다는 것을 소스에서 확인했습니다.

실제 목록을 뽑아보니 이 앱의 computed value 프로퍼티는 약 1000개였고, 그중 절반이 CSS 커스텀 프로퍼티였습니다. 테마 색상이나 아이콘 경로를 담는 -- 로 시작하는 변수들입니다. 그런데 computed value는 변수가 이미 치환된 최종 값으로 나오기 때문에, 복제본에 변수 자체를 다시 심을 이유가 전혀 없었습니다. 여기에 시각과 무관한 것까지 걷어내 약 100개가량을 남겼습니다.

// utils/inlineStyleProperties.ts — 인라인할 CSS 프로퍼티 127개
export const INLINE_STYLE_PROPERTIES = [
  'display', 'position', 'top', 'right', 'bottom', 'left', /* ... */

  // 'box-sizing' 은 반드시 있어야 합니다. Chrome의 computed width 는 그 요소의
  // box-sizing 기준값이라, 빼면 클론이 padding+border 만큼 넓어집니다.
  'width', 'height', 'box-sizing', /* ... */

  // ::before / ::after 규칙도 이 목록으로 만들어집니다.
  // 빠지면 의사요소(구분선·체크박스·화살표)가 통째로 사라집니다.
  'content',

  // 아이콘 대부분이 mask 로 렌더됩니다. 빠지면 아이콘이 전부 사라집니다.
  'mask-image', 'mask-size', '-webkit-mask-image', /* ... */
];

코드 1. 목록에서 빠져도 에러는 나지 않고 스크린샷만 조용히 어긋납니다. 그래서 이유를 주석으로 남겨두었습니다.

이 목록에서 가장 아찔했던 것은 content였습니다. 빠뜨렸다면 ::before / ::after로 그려지는 요소가 전부 빈 채로 렌더됐을 텐데, 이걸 테스트로 잡은 게 아니라 라이브러리 소스를 읽다가 우연히 발견했습니다. 목록을 만들면서 무엇이 필요한지 검증할 방법을 같이 마련해두지 않은 것이 이 시점의 가장 큰 허점이었고, 나중에 다른 라이브러리를 검토하게 된 직접적인 이유이기도 합니다.

네 방안을 각각 구현해서 비교했습니다

AI로 추출한 목록은 그 자체로 위험합니다. Snap이 포함되는 곳은 여러 에피소드에서 활용될 것을 전제하는 공유 라이브러리인데 목록은 제가 일부 화면만 보고 정한 것이라, 다른 팀 앱이 목록에 없는 프로퍼티를 쓰면 에러 없이 스크린샷만 어긋납니다. 버그 제보 도구에서 조용히 틀린 결과는 느린 결과보다 나쁘다고 판단해서, 큐레이션이 필요 없는 대안을 테스트해 보았습니다.

방안

방식

가벼운 화면

무거운 화면

증가율

A안

html-to-image + AI로 추출한 css 프로퍼티

378–388ms

828–865ms

2.2×

B안

modern-screenshot + 런타임 생성 css 프로퍼티

575–863ms

1,870–2,045ms

2.8×

C안

snapdom + 클래스 dedup

1,235–1,985ms

5,186ms (첫 회 26초)

4.2×

D안

html2canvas — CSS 를 JS 로 해석해 직접 그림

516–610ms

(측정 조건 상이)

이 표에서 결정을 가른 것은 마지막 열이었습니다. 노드가 4.3배 늘 때 증가율 자체가 달랐습니다. 노드당 읽고 쓰는 프로퍼티가 A안은 약 100개 , B안은 약 450개여서 화면이 무거워질수록 격차가 벌어지는 것은 당연한 결과였습니다. 만일 가벼운 화면만 비교했다면 B안을 선택했을 것입니다.

  • B안(modern-screenshot) — 라이브러리만 교체했을 때는 개선이 0이었습니다. 태그별 기본값과 비교해 다른 것만 인라인하는 방식이라 결과물은 작아지지만, 무엇이 다른지 알려면 결국 전체를 읽어야 하기 때문입니다. 목록을 런타임에 생성해 넘기니 806ms 가 됐지만 무거운 화면에서는 2초를 넘겼습니다.

  • C안(snapdom) — 같은 스타일을 클래스로 묶는 방식이라 마크업이 가장 작았고, 렌더 충실도는 셋 중 유일하게 완벽했습니다. 그러나 폰트 서브셋만 넣을 방법이 공개 옵션에는 없어서 앱 폰트 전체를 함께 임베드하게 되고, 그 결과 무거운 화면에서 첫 캡처가 26초였습니다.

  • D안(html2canvas) — 일반적으로 많이 사용하는 라이브러리여서 테스트해 보았습니다. 계열 자체가 달라(CSS를 JS로 해석해 canvas에 직접 그림) 폰트 임베드가 아예 불필요한 점이 매력이었지만, 이 앱 아이콘 대부분이 쓰는 mask-image 지원이 번들에 아예 없어 헤더·툴바 아이콘 7개가 전부 검은 사각형으로 찍혔습니다. 2022년이 마지막 릴리스라 최신 CSS 함수를 만나면 예외를 던지고 죽기도 했습니다.

왜 원래 쓰던 라이브러리를 그대로 뒀는가

결론은 A안 유지였습니다. 1초 요구를 무거운 화면에서 만족하는 것이 A안뿐이었고, snap-view가 어느 화면에 들어갈지 알 수 없는 이상 측정한 화면에서 겨우 통과하는 것으로는 부족하다고 봤습니다.

B안을 지지하던 논거는 "큐레이션이 없으니 모르는 CSS에서 더 안전하다"였는데, 화이트리스트를 만들 때 보지 않은 화면에서 두 캡처를 픽셀 단위로 비교해보니 10%가량 차이가 발생함을 확인했습니다. 처음에는 역시 A안이 무언가를 놓쳤구나 싶었는데, 실제 화면 좌표와 하나씩 대조해보니 둘 다 같은 방식으로 틀려 있었습니다. 차이는 어느 쪽이 더 정확해서가 아니라 두 라이브러리가 같은 CSS를 조금씩 다르게 그리기 때문이었습니다. 확인되지 않은 이점을 위해 2.2배 느린 것을 선택할 수 없다고 판단했습니다. 다만 이것이 "A안이 더 안전하다"는 증명은 아닙니다. 두 화면에서 확인되지 않았다는 것뿐이라, 이 약점은 지금도 그대로 남아 있습니다.

D안 실험에서는 값비싼 교훈도 하나 얻었습니다. html2canvas는 실패하면 페이지에 iframe을 남기는데, querySelectorAll('*')로는 그 내부가 보이지 않습니다. 5개가 쌓인 상태에서 A안을 다시 재니 마크업이 66MB, 캡처가 4,960ms로 부풀었습니다. 기준선과 13배 어긋난 것이 유일한 단서였고, 그 수치를 그대로 믿었다면 "A안은 이 화면에서 5초 걸린다"는 완전히 틀린 문서가 나왔을 것입니다.

개발 과정 ② — 빨라진 다음에 캡처본이 화면과 다르다는 걸 알았습니다

속도를 잡고 나서 캡처본과 실제 화면을 나란히 놓고 보니 네 가지가 어긋나 있었습니다. 그리고 넷 다 원인이 처음 짐작과 달랐습니다. 특히 2·3·4번은 전부 폰트 문제라고 추측했는데, 실제는 폰트와는 무관했습니다.

#

증상

진짜 원인

성격

1

ag-grid 체크박스·정렬 화살표가 통째로 없음

의사요소 배경은 라이브러리의 리소스 인라인 경로에 안 걸림

리소스

2

글자가 커지고 밀려 보임

pixelRatio: 1 이 CSS 픽셀 기준 (사용자 줌 90%)

배율

3

헤더 브레드크럼이 두 줄로 접힘

소수점 반올림 0.007px 로 flex-wrap 이 뒤집힘

레이아웃

4

헤더 아래에 빈 띠가 생김

<noscript>가 캡처 안에서만 되살아남

레이아웃

사라진 아이콘

행 선택 체크박스와 컬럼 정렬 화살표가 캡처에서만 통째로 빠졌습니다. 반면 헤더의 카메라·알림 아이콘은 멀쩡했습니다. 같은 아이콘인데 왜 갈리는지 몰라서 라이브러리 소스를 뒤졌더니, 리소스를 data URI로 바꾸는 곳이 두 군데뿐이었습니다. 요소 자신의 배경을 읽는 경로와 <img> 태그를 읽는 경로입니다. 그런데 의사요소 규칙은 <style> 텍스트로 만들어져 붙기 때문에 그 어디에도 걸리지 않았습니다. 문제의 CSS는 호스트 앱의 SCSS에 있어서 저희가 고칠 수도 없었습니다.

실마리는 엉뚱한 곳에 있었습니다. 폰트를 넣는 용도로만 쓰던 fontEmbedCSS 옵션의 소스를 보니, 받은 문자열을 그대로 <style> 로 만들어 복제본 맨 앞에 꽂는 동작이었습니다. 이름은 폰트인데 실제로는 임의 CSS 주입 지점이었던 것입니다.

const cssText = options.fontEmbedCSS != null ? options.fontEmbedCSS : ...
if (cssText) {
  const styleNode = document.createElement('style')
  styleNode.appendChild(document.createTextNode(cssText))
  clonedNode.insertBefore(styleNode, clonedNode.firstChild)
}

코드 2. 이 한 줄을 확인한 덕분에 "고칠 경로가 없다"고 적어두었던 문제가 풀렸습니다.

그래서 캡처 직전에 전 요소의 의사요소를 훑어 url() 을 쓰는 것만 골라내고, 해당 이미지를 data URI로 바꾼 뒤, 대상 요소에 마커 속성을 붙여 그 마커를 겨냥한 CSS 규칙을 만들어 넘겼습니다. 두 가지가 필수였습니다. !important는 라이브러리가 만든 규칙이 더 나중에 삽입되어 문서 순서로 이기기 때문이고, 선언이 같은 요소끼리 마커 번호를 공유하게 한 것은 그러지 않으면 ag-grid의 각 행이 동일한 체크박스 이미지 데이터를 행 수만큼 복사하여 SVG가 부풀기 때문입니다.

여기서 전제 하나가 뒤집혔습니다. 전 요소를 훑는 것보다 스타일시트를 파싱하는 편이 쌀 것 같아 그쪽을 먼저 만들었는데, 실측은 스타일시트 스캔 방식이 전 요소 순회 방식 보다 훨씬 느렸습니다. getComputedStyle이 생각보다 쌌고, 스타일시트를 훑는 쪽이 비쌌던 것입니다. 직접 순회로 바꾸니 코드도 짧아졌고, 캡처 시간은 348ms에서 332ms로 오히려 줄었습니다.

화면보다 컸던 캡처본

여러 화면에서 캡처를 하다 보니 헤더에서 글자가 커지고 옆으로 밀려 보이는 현상이 존재하였습니다. 레이아웃이 깨진 것처럼 보였지만 실제로는 배율 문제였습니다. 화면은 CSS 픽셀 하나당 devicePixelRatio 만큼의 실제 픽셀로 그려지는데, 옵션이 pixelRatio: 1 로 고정되어 있었습니다. 저는 브라우저 줌을 90% 로 쓰고 있었고 그러면 DPR이 0.9가 되어 캡처가 1.112배(1÷0.9) 커졌던 것입니다.

const capturePixelRatio = (): number =>
  Math.min(window.devicePixelRatio || 1, 2);

코드 3. DPR 2 환경에서 캡처 시간은 거의 그대로였고, 늘어난 것은 용량뿐이었습니다(120KB → 316KB).

헤더 아래 빈 띠

캡처에서만 헤더 바로 아래에 24px짜리 빈 띠가 생기고 그 아래 콘텐츠가 전부 밀렸습니다. 그런데 복제본의 레이아웃 좌표를 재보니 완전히 일치했습니다. DOM 단계에서 여백이 없다면 남은 것은 래스터화 단계뿐이므로, 캡처 이미지의 픽셀을 직접 스캔해 "잉크가 있는 y 구간"을 뽑았습니다. 헤더만 제자리이고 나머지가 통째로 24px 내려가 있었습니다. 누적이 아니라 한 번 밀린 것이니, 흐름 맨 앞에서 무언가가 한 줄만큼 자리를 차지하고 있다는 뜻이었습니다.

범인은 <noscript> 였습니다. HTML 명세상 "이걸 숨긴다"는 브라우저 기본 규칙은 스크립팅이 켜져 있을 때만 적용됩니다. 그런데 앞에서 적었듯 <img> 안의 SVG는 스크립팅이 꺼진 컨텍스트라, 캡처 안에서만 이 태그가 되살아나 한 줄을 차지합니다. 헤더가 멀쩡한 이유는 position: fixed 로 흐름 밖이기 때문이고, 그래서 "헤더 아래 빈 띠"처럼 보였던 것입니다. Chrome의 버그가 아니라 명세에 따른 동작이었습니다.

const EXCLUDED_TAG_NAMES = new Set(['NOSCRIPT', 'SCRIPT']);

filter: (node) => {
  if (!(node instanceof Element)) return true;
  if (EXCLUDED_TAG_NAMES.has(node.tagName)) return false;
  return !EXCLUDED_CLASS_NAMES.some((name) => node.classList?.contains(name));
},

코드 6. 수정 후에는 탭 밑줄이나 그리드 헤더 경계선 같은 1px 선까지 제자리로 들어왔습니다.

결국 핵심은 진단 도구였습니다

돌아보면 이번 건에서 실제로 한 일의 대부분은 도구를 만드는 일이었습니다. 눈으로 스크린샷을 비교하는 동안에는 넷 중 하나도 잡지 못했습니다.

  • 1단계 · 손으로 만든 복제본을 좌표 대조 — 996개 중 어긋난 노드가 1개(애니메이션 중)뿐이었습니다. 여기서는 아무것도 못 잡았습니다.

  • 2단계 · 라이브러리가 실제로 만든 SVG를 꺼내 대조 — 의사요소 스타일과 필터가 전부 반영된 상태입니다. 여기서 flex 줄바꿈을 잡았습니다.

  • 3단계 · 캡처 이미지의 픽셀을 직접 스캔 — 행마다 잉크 유무를 세어 콘텐츠 구간의 경계를 뽑았습니다. 여기서 <noscript>를 잡았습니다.

2단계로 부족했던 이유가 이 작업 전체의 교훈이라고 생각합니다. 대조에 쓴 iframe은 스크립팅이 켜져 있어 <noscript>가 숨겨집니다. 그래서 좌표는 완벽히 일치하는데 실제 그림만 어긋났습니다. DOM 좌표 대조로는 래스터화 단계의 문제를 잡을 수 없다는 것을, 도구를 한 단계 더 올리고 나서야 알았습니다.

지금의 캡처 파이프라인

위의 결정과 수정이 모두 반영된 현재 코드입니다. 화면을 잠시 손댔다가 되돌리는 처리가 셋 붙어 있고, 이 순서에는 각각 이유가 있어 주석으로 남겨두었습니다.

export const captureViewport = async (): Promise<string | null> => {
  try {
    // 퀵메뉴 닫힘 등 직전 DOM 변경이 화면에 반영된 뒤 캡처합니다.
    await new Promise((r) =>
      requestAnimationFrame(() => requestAnimationFrame(r)));

    // 의사요소 배경을 data URI 로. pinScrollOffsets 보다 먼저
    const { css: pseudoCss, restore: restorePseudo } =
      await inlinePseudoBackgrounds();
    // 현재 라인을 파악해야 하므로 스크롤 처리보다 먼저 진행
    const unpinFlexLines = pinFlexLines();
    // scrollTop 은 CSS 가 아니라 런타임 상태여서 복제본이 찾지 못함
    //  transform 으로 번역해 두면 복제본이 그대로 복사함
    const unpinScrollOffsets = pinScrollOffsets();

    let result: string | null;
    try {
      const shot = toJpeg(document.body, {
        ...CAPTURE_OPTIONS,               // 화이트리스트
        pixelRatio: capturePixelRatio(),  // 실 기기 픽셀 기준
        // foreignObject 는 웹폰트에도 네트워크에도 접근할 수 없으므로 필요한 것은 전부 여기에 넣음
        fontEmbedCSS: CAPTURE_FONT_EMBED_CSS + GLOBAL_CAPTURE_CSS + pseudoCss,
      });
      const timeout = new Promise<null>((r) => setTimeout(r, TIMEOUT_MS, null));
      result = await Promise.race([shot, timeout]);
    } finally {
      // 복구는 반드시 역순. 순서를 바꾸면 스크롤 값이 엉뚱하게 잡힘
      unpinScrollOffsets();
      unpinFlexLines();
      restorePseudo();
    }
    return result;   // 실패·타임아웃이면 null — 제보 흐름을 막지 않음
  } catch (e) {
    console.warn('[captureViewport] failed', e);
    return null;
  }
};

코드 7. 캡처 진입점.

여기서 pinScrollOffsets만 조금 더 설명하겠습니다. 이 앱은 브라우저 창 전체가 아니라 main 안쪽 컨테이너가 스크롤되는 구조인데, 스크롤 위치는 CSS가 아니라 런타임 상태라 복제본에서는 0이 됩니다. 그래서 사용자가 화면 중간을 보며 제보해도 캡처에는 맨 위가 담깁니다. 세 라이브러리 모두 같은 문제가 있었고, 대응 옵션이 있는 둘도 이 구조에서는 효과가 없었습니다.

해법은 스크롤을 CSS로 번역하는 것이었습니다. 스크롤된 컨테이너의 자식을 스크롤량만큼 transform: translate()로 옮기고 동시에 컨테이너의 스크롤을 0으로 되돌립니다. 두 변경이 서로 상쇄되므로 실제 화면은 픽셀 하나 움직이지 않지만, transform은 computed style이라 복제본이 그대로 복사합니다. 부작용으로 sticky/fixed 요소가 제자리를 벗어나므로 어긋난 양을 재서 역방향으로 보정했습니다. 보정 전에는 96개가 어긋났고 얹은 뒤에는 1개로 줄었습니다.

아직 남아 있는 것

가장 큰 것은 화이트리스트 목록이 여전히 수동으로 작성해야 한다는 것입니다. 목록에 없는 프로퍼티는 브라우저 기본값으로 렌더되고 에러는 나지 않습니다. 게다가 본 라이브러리가 들어가는 각 호스트는 별개 저장소이고 목록은 배포된 패키지 안에 있어서, 다른 팀이 문제를 발견해도 저희에게 알리고 릴리스를 기다려야 합니다. 지금 목록은 최소로 고른 것이라 시각에 무관한 것만 빼는 방식으로 300개 안팎까지 넓힐 여지가 있고, 1초 예산 안에서 최적점을 찾아볼 생각입니다.

두 번째는 검증을 지켜주는 장치가 없다는 것입니다. 앞에서 만든 좌표 대조와 픽셀 스캔은 그때그때 만들어 쓴 도구이지 자동화된 검사가 아닙니다. content 를 빠뜨릴 뻔한 상황은 지금도 똑같이 재현될 수 있습니다. 캡처 결과를 자동으로 대조하는 검사를 붙이는 것이 다음 과제라고 보고 있습니다.

세 번째는 검증 범위입니다. 지금까지 잰 것은 그리드 목록 화면과 다이어그램 화면 두 종류뿐입니다. 그 밖의 화면에서 어떤 CSS가 쓰일지는 알 수 없고, 캡처가 조용히 어긋나도 아무도 모른다는 구조 자체는 그대로입니다.

그래서 사용자가 자동 캡처본 대신 직접 찍은 사진을 올릴 수 있는 경로를 그대로 열어두었습니다. 자동 캡처는 "아무것도 안 해도 기본으로 붙는 편의 기능"으로 두고, 그것이 원하는 장면을 담지 못했을 때는 사용자가 직접 첨부할 수 있게 한 것입니다. 다만 이것은 근본적인 해결이 아니라 완충 장치에 가깝습니다. 캡처 대상을 화면 전체가 아니라 사용자가 지정한 영역으로 좁히는 방식이나 브라우저의 화면 캡처 API를 쓰는 방식 등, 다른 개선 방향이 없을지 계속 검토해 볼 예정입니다.

마치며

이번 작업을 하면서 느낀 것은, 이것이 사실 성격이 다른 두 개의 일이었다는 점입니다. 속도는 무엇이 비싼지를 찾는 문제였고 숫자가 답을 알려줬습니다. 정확도는 무엇이 다른지 볼 수 있게 만드는 문제였고, 답보다 도구를 먼저 만들어야 했습니다. 눈에서 좌표로, 좌표에서 픽셀로 단계를 올릴 때마다 하나씩 잡혔습니다.

그리고 이 라이브러리들은 문서만 보고 접근하면 막히는 경우가 많았습니다. includeStyleProperties는 특정 분기에서만 쓰여서 처음에는 "Chrome에서는 무시된다"고 잘못 결론 냈고, fontEmbedCSS는 이름과 달리 임의 CSS 주입 지점이었으며, restoreScrollPosition이나 clip: 'viewport'처럼 이름만 보면 딱 맞아 보이는 옵션들은 이 구조에서 효과가 없었습니다. 막히는 순간에는 해당 옵션이 소스 어디에서 쓰이는지를 먼저 찾는 것이 가장 빠른 디버깅 방법이었습니다.

아쉬운 점도 남습니다. 화이트리스트를 만들 때 그것을 지켜줄 검증을 같이 만들지 않은 것, 그리고 정확도 문제를 속도 작업과 같은 시점에 확인하지 못하고 나중에서야 발견한 것입니다. 다만 이번 경험 덕분에 단순히 라이브러리를 가져다 쓰는 수준을 넘어, 브라우저가 화면을 그리는 과정과 그 결과를 다시 그림으로 옮길 때 무엇이 사라지는지를 조금 더 이해할 수 있게 됐습니다.

참고 문서

Owler

Site footer