HTML-in-canvas API를 이용해 HTML과 CSS로 만든 배너 미리보기를 캔버스에 렌더링하고 이미지로 저장하려 했던 실전 경험, 그리고 그 과정에서 마주친 connectedCallback 재귀와 캔버스 크기 조정 문제를 살펴본다.
게시: 1주 2일 전
그동안 저는 <canvas> 요소에 이것저것을 그리는 실험을 한두 번 해왔습니다. 여기서는 wave function collapse 실험을, 저기서는 crystallizing palette를 만들었죠. 그러다 얼마 후에는 버튼을 하나 연결해서, 클릭하면 canvas의 내용을 PNG 파일로 제 컴퓨터에 저장하게 하는 방법도 찾아냈습니다.꽤 멋진데, 라고 저는 생각했습니다.이걸 HTML+CSS 구조에도 똑같이 할 수 있을까?
먼저 canvas에서 생성한 다음, 버튼을 눌러 저장했습니다.
결론부터 말하면, 안 됐습니다. Firefox의 웹 인스펙터에 있는 “Screenshot node” 메뉴 항목이나, Firefox 콘솔의 the :screenshot command를 쓸 수는 있었고 실제로 자주 그렇게 했지만, 페이지 내부의 버튼으로 처리할 수는 없었습니다. 아시다시피 HTML 노드는 <canvas> 안으로 들어가지 않기 때문입니다. 스타일과 스크립트가 적용된 것들이라면 더더욱 그렇고요.
그런데 바로 최근까지는 그렇지 않았지만, 이제는 Chrome이 플래그로 활성화되는 HTML-in-canvas API의 미리보기 기능을 출시했습니다. 작동 방식은 이렇습니다. <canvas> 요소에 layoutsubtree 속성을 추가하면, 그 안에 원하는 HTML을 무엇이든 넣을 수 있고, 평소 적용하던 CSS와 JS도 그대로 적용할 수 있습니다. 여기에 마법 같은 JScantation 몇 가지를 더하면, 브라우저가 원래 페이지에 그렸을 내용을 canvas에 그려 주게 됩니다. 속도는 브라우저가 감당할 수 있는 만큼 나오는데, 보통은 초당 60프레임 이상입니다. 웹 브라우저는 고급형 first-person scrollers니까요.
직접 이걸 시험해 보고 싶다면, Frontend Masters 블로그에 실린 Amit Sheen의 “The Web Is Fun Again”을 추천합니다. 이 기능이 가능하게 만드는 괴상한 것들을 위해 어떻게 환경을 설정하는지 자세히 설명하고, 여러 실험도 보여 줍니다. 페이지 위로 물결이 퍼지고, 마우스 포인터를 따라다니는 렌즈 왜곡, chromatic aberration까지요!
물론 이런 것들은 “나는 그냥 웹을 쓰고 싶을 뿐인데”라고 생각하는 사람들에게는 꽤 거부감 있게 들릴 수 있다는 점을 인정합니다. 예를 들어, 입력 폼에서 타이핑하는 글자마다 물결이 퍼져 나가게 하는 데 무슨 실용성이 있을까요? 또는 드롭다운 메뉴가 페이지 맨 아래로 떨어지면서도 실제로는 계속 작동하게 만드는 건요? 아마 별로 없을 겁니다. 값비싼 디자인 스튜디오가 자랑용 페이지를 만들고 있는 게 아니라면 말이죠.
하지만 기억해 두세요. 새로운 그래픽 기술의 발전은 언제나 이런 식으로 흘러갑니다. 여기서 “우리”란 웹 업계 전체를 뜻하는데, 처음에는 나중에 후회하게 될 정도로 정말 기괴하고 눈길을 끄는 것들을 만듭니다. parallax scrolling 효과를 기억하시나요? 초창기 CSS animation은요?drop shadow는요? 처음에는 과잉의 시기가 있고, 그러다 결국은 차분하게 정착합니다.
하지만 저는 이미 그 차분해진 단계로 바로 건너뛰었습니다.
제가 <canvas> 위에 HTML+CSS를 렌더링한 다음 그 이미지를 제 컴퓨터에 저장할 수 있을지 자문했을 때, 그건 단지 제가 가끔 하는 그런 “웹 기능의 한계를 밀어붙이기” 놀이만은 아니었습니다. 실제적이고 실용적인 사용 사례가 있었죠. Igalia에서 제 업무를 위해 만든 브라우저 기반 도구에서, 소셜 미디어 배너와 썸네일을 클릭 한 번이나 비슷한 버튼 트리거만으로 저장하고 싶었습니다.
저희 YouTube channel를 구독하고 계시다면 이런 썸네일을 보셨을 것이고, Mastodon이나 Bluesky에서 저희를 팔로우하고 계셔도 마찬가지일 겁니다. 그것들을 만들기 위해 저는 custom elements로 구성한 브라우저 내 도구를 하나 만들어 두었습니다. 바로 여기에서 the super-slider pattern이 발전해 나왔습니다. 물론 도구 안에서는 다른 이름을 쓰고 있지만요. 이 도구 자체는 링크하지 않겠습니다. 사내 인트라넷에 있어서 로그인할 수 있는 분이 거의 없기 때문입니다. 대신 괴짜가 설계한 반쯤 영광스러운 모습의 스크린샷을 하나 보여 드리죠.
최근 썸네일이 이미 로드된 상태의 배너 메이커입니다.
배너 안의 텍스트 조각들은 모두 contenteditable HTML 요소이고, 각종 테마는 여러 CSS 블록으로 관리됩니다. (그리고 맞습니다, 저 range 입력들은 전부 “super sliders”입니다.) 이 모든 것의 요점은, 회사의 누구든 필요할 때마다 저를 기다리지 않고 배너를 만들 수 있게 하려는 것이었습니다.
제가 늘 원했던 것은, 저 말고 다른 누구에게나 쉽게 만들기 위해 “이 버튼을 클릭하면 배너를 이미지로 저장” 기능을 넣는 것이었습니다. 물론 Igalia의 누구라도 제가 사용하던 웹 인스펙터나 콘솔 방법을 쉽게 배울 수 있었을 겁니다. 이미 알고 있지 않았다면요. 하지만 그 방식은 너무 임시방편처럼 느껴졌습니다. 솔직히 말하면 약간은 민망하기도 했고요.
그런데 이제는 원하던 것을 갖게 되었습니다. HTML-in-canvas를 지원하는 브라우저에서는 “Download banner image”라는 레이블의 버튼이 있습니다. 현재로서는 적절한 개발자 플래그를 켠 최신 Chrome이 여기에 해당합니다. 다른 모든 브라우저에서는 버튼이 없고, 예전처럼 웹 인스펙터의 스크린샷 요령에 의존하면 됩니다.
하지만 이걸 구현하는 과정은, 방금 들으신 것만큼 쉽지는 않았습니다. 중간에 몇 가지 장애물에 부딪혔고, 그중 하나는 꽤나 짜증스러웠습니다. 오늘 제가 사실상 이 글로 이야기하고 싶은 것도 바로 그 부분입니다.
첫 번째 장애물은, call stack을 터뜨리지 않으면서 썸네일 미리보기를 <canvas> 요소 안으로 넣어야 했다는 점입니다. 설명을 위해 이 도구의 마크업 뼈대를 대략 보여 드리겠습니다.
<section id="youtube_talks">
<thumb-panel class="text"> … </thumb-panel>
<thumb-panel class="colors"> … </thumb-panel>
<thumb-panel class="highlightImage"> … </thumb-panel>
<thumb-panel class="backgroundImage"> … </thumb-panel>
<thumb-panel class="icons"> … </thumb-panel>
<thumb-panel class="scaler"> … </thumb-panel>
<thumb-panel class="loader"> … </thumb-panel>
<thumb-preview> … </thumb-preview>
</section>
보시다시피 거의 전부 custom elements이고, 각각에는 브라우저가 처음 그것들을 만났을 때 필요한 스크립트 마법을 수행하는 connectedCallback() 함수가 있습니다. 마지막 요소인 <thumb-preview>를 <canvas>로 감싸려면, 새 canvas 요소를 만들고, preview 요소를 새 canvas 안으로 옮긴 뒤, preview를 담은 canvas를 삽입해야 했습니다. 결과 구조는 이렇게 됩니다.
<section id="youtube_talks">
<thumb-panel class="text"> … </thumb-panel>
<thumb-panel class="colors"> … </thumb-panel>
<thumb-panel class="highlightImage"> … </thumb-panel>
<thumb-panel class="backgroundImage"> … </thumb-panel>
<thumb-panel class="icons"> … </thumb-panel>
<thumb-panel class="scaler"> … </thumb-panel>
<thumb-panel class="loader"> … </thumb-panel>
<canvas layoutsubtree>
<thumb-preview> … </thumb-preview>
</canvas>
</section>
그래서 <thumb-preview>가 로드될 때, 그 요소의 connectedCallback()에서 HTML-in-canvas 지원 여부를 확인하도록 했습니다. 지원되는 상황이라면, 위 결과로 가기 위해 필요한 작업을 수행했죠.
그런데 이 시점에서, <thumb-preview>는 DOM에 배치되는 custom element이므로 connectedCallback()을 다시 실행하게 됩니다. 그러면 다시 canvas를 만들고 <thumb-preview>를 새 canvas에 삽입하는 과정이 시작되고, 또다시 그 과정이 시작되며, 결국 무한대로 재귀하며 반복되었습니다. 몇 밀리초도 안 되어 call stack이 초과되었습니다.
그러니… 그건 통할 수가 없었습니다.
처음에는 플래그 변수를 true로 설정한 다음, 그 존재를 확인해서 canvas 생성 및 preview 삽입 전체를 건너뛰면 피할 수 있을 거라고 잠깐 생각했습니다. 하지만 그걸 실제로 어떻게 작동시켜야 할지 감이 오지 않았습니다. 그다음에는 connectedMoveCallback()를 써서 이 난국을 우회할 수 있지 않을까 생각했지만, 이건 이동이 아니라 (재)생성이었습니다.
하지만 문제를 해결하는 길은 바로 그 callback이었습니다. DOM의 한 부분에서 다른 부분으로 요소를 이동시키는 방법이 실제로 있거든요. 바로 Element.moveBefore()입니다. 안타깝게도 moveAfter()나 moveInto()는 없고, 그저 “이 노드를 어떤 다른 노드 바로 앞의 위치로 옮겨라”만 있습니다.
제가 그 기능을 활용한 방법은 이렇습니다.
let canvas = document.createElement('canvas');
canvas.setAttribute('layoutsubtree','');
canvas.setAttribute('width','1280');
canvas.setAttribute('height','720');
this.closest('section').appendChild(canvas);
let beacon = document.createElement('span');
canvas.appendChild(beacon);
canvas.moveBefore(this,beacon);
beacon.remove();
맞습니다. canvas를 만들고, 가장 가까운 상위 section 안에 넣고, span을 하나 만든 다음, 그 span을 canvas에 넣고, preview 요소를 그 span 바로 앞으로 이동시킨 뒤, span을 삭제했습니다. (아마 더 나은 방법이 있을지도 모릅니다. 제가 DuckDucking해 본 바로는 찾지 못했지만요. 있다면 아래 댓글로 알려 주세요!)
아, 그리고 preview가 append로 생성될 때가 아니라 이동될 때 실행되는 것은 이것입니다.
connectedMoveCallback() {
return;
}
참 별난 방식으로 철도를 까는 셈이죠.
그 시점에 이르자, canvas는 제가 원하는 곳에 있었고 preview도 원하는 자리에 있었으며 call stack도 터지지 않았습니다. 만세! 그런 다음 canvas가 실제로 자신의 서브트리를 렌더링하게 만드는 마법의 JScantation을 읊었고(자세한 내용은 앞서 링크한 “Web is Fun Again” 글을 보세요), 그러자 짠, DOM이 canvas 안에 렌더링되기 시작했습니다! 이어서 버튼을 클릭하자 canvas가 PNG로 렌더링되었고, 브라우저가 그 PNG를 다운로드했습니다! 원하던 것을 손에 넣은 겁니다!
거의요.
왜냐하면 두 번째 장애물은, 아시다시피 canvas에는 명시적인 크기가 있다는 점이었기 때문입니다. 사실상 반드시 있어야 합니다. 그렇지 않으면 기본값이 가로와 세로 모두 0픽셀이기 때문이죠. 그러니 뭔가 보이게 하려면 크기를 지정해야 합니다. 저는 앞선 코드처럼 setAttribute() 메서드로 canvas를 1280×720으로 설정했습니다. YouTube가 권장하는 썸네일 크기죠.
문제는 썸네일 미리보기의 기본 배율이 0.75라는 점이었습니다. 즉 960×540으로 변환됩니다. 그래서 이미지 캡처 버튼을 누르면, 브라우저는 1280×720 이미지 하나를 다운로드했는데, 썸네일은 왼쪽 위에만 있고 그 아래와 오른쪽은 투명한 상태였습니다.
앞서 본 배너를 크기 조정되지 않은 canvas 안에 0.75 배율로 렌더링한 모습으로, macOS 이미지 편집기 Acorn에서 본 화면입니다.
“canvas 크기를 그냥 바꿔, 이 얼간아!”라고 말하고 싶을지도 모르겠습니다. 사실 저도 그렇게 했습니다. 그러니까 그렇게 말했다는 뜻입니다. 하지만 너비를 960, 높이를 540으로 설정하면, 배율을 1로 높였을 때 1280×720 DOM 노드의 왼쪽 위 960×540만 잘린 채 보이게 되었습니다. 저는 canvas 요소의 크기를 동적으로 조정해서 thumb-preview의 크기와 일치시키고 싶었습니다.
그리고 바로 여기서 저는 여러 개의 벽에 정면으로 들이받았습니다. canvas 요소가 생성될 때를 포함해, 원하는 모든 상황에서 크기를 바꾸도록 밀어붙이는 일은 생각보다 전혀 쉽지 않았기 때문입니다. 적어도 저에게는 그랬습니다. 결국에는 고생고생하며 억지로 해결책까지 밀고 나갔고, 마침내 해냈습니다.
(이 글을 쓰는 지금, <div>도 하나 만들고 그 안에 canvas를 넣은 뒤, CSS로 그 div의 크기를 바꾸고 canvas는 높이와 너비를 100%로 두었어야 했나 싶기도 합니다. 아니면 DOM 서브트리를 1280×720에 고정해 두고 CSS scale로 canvas 크기를 시각적으로 바꾸는 방법도 있었을지 모르죠. 혹은 resizeObserver 관련 꼼수라든가. 아니면 아마 그냥 HTML-in-canvas의 drawElementImage 메서드에 몇 가지 매개변수를 전달했으면 됐을지도요. 음.)
제가 덜 짜증나는 방법을 놓쳤는지와는 별개로, 이 일은 HTML-in-canvas 접근법에 내재한 근본적인 긴장 관계를 여전히 가리킵니다. 바로 크기 조절입니다.
canvas는 보통 자신의 콘텐츠에 맞춰 커지거나 작아지지 않습니다. 반면 DOM 요소는 대체로 그렇습니다. 억지로 그러지 못하게 만들지 않는 한은요. HTML-in-canvas는 아주 유동적이고, 유연하며, 대부분 경계가 없는 레이아웃 패러다임을 가져다가, 그 전부는 아닐지라도 일부를 주어진 크기의 매우 제한된 창 안으로 rasterizing하고 있습니다. 초당 60번, 혹은 그 이상, 브라우저는 canvas의 content box 크기만 한 스크린샷을 찍고 그 스크린샷을 그 content box 안에 붙여 넣습니다. 그 과정에서 필터나 shader나 canvas draw call 등 여러분이 코딩할 수 있는 무엇이든 사용해 재미있는 효과를 줄 수 있습니다. 그래서 각각의 스크린샷이 뭔가 더 화려해질 수는 있죠. 하지만 근본적으로는 여전히 스크린샷, 붙여넣기, 스크린샷, 붙여넣기를 계속 반복하는 것에 불과합니다.
제 경우 같은 사용 사례에서는 이게 사실 큰 문제가 아닙니다. 결국 제가 원하는 것은 페이지의 정적인 일부를 스크린샷으로 얻는 것이니까요. HTML-in-canvas는 그런 용도에 아주 잘 맞습니다. 브라우저 기반 슬라이드쇼 장르를 완전히 혁신할 수도 있을 겁니다. Reveal.js 플러그인 생태계만 해도 정말 장관일 수 있겠죠.
하지만 일반적인 경우, 그러니까 우리가 거의 매일 하는 대부분의 작업에서는, 이게 널리 자리 잡을 것 같지는 않습니다. 더 쉽게 만들어 주는 패턴이나, 이 불일치를 극복하는 흥미로운 해킹 몇 가지가 생겨날 수는 있겠지만, 그것이 판도를 크게 바꿀 것 같지는 않습니다. 반대로, canvas를 평범한 <div>처럼 유연하고 콘텐츠를 감싸는 크기 조절이 가능하게 만들 수 있다면, 훨씬 더 많은 사용 사례를 보게 되리라고 생각합니다.
다만 그게 가능하다면, 굳이 HTML-in-canvas에 묶여 있을 필요도 없겠죠. 대신 표준 HTML 요소를 더 시각적으로 조작 가능하게 표시하는 문법을 정의할 수 있을 겁니다. HTML 속성이나 CSS 속성이나 DOM 메서드, 혹은 그 셋 모두를 통해서 말이죠.
우리는 전에도 거기 가까이 간 적이 있습니다. CSS Houdini와 Microsoft의 원래 filter 속성을 예로 들어 보죠. 다시 시도해 볼 수 있습니다. 어쩌면 HTML-in-canvas 시기는, 더 단순한 그 문법이 어떤 모습이어야 하는지 알아내는 과정일지도 모릅니다. 무엇을 가능하게 해야 하는지, 무엇을 쉽게 만들어야 하는지를 파악함으로써 말이죠.
저는 그런 방향이라면 괜찮을 것 같습니다. 여러분은 어떤가요?
이 글의 초안 검토와 피드백을 해 준 동료 Brian Kardell과 Stephen Chenney에게 깊이 감사드립니다.
이름
전자우편(필수, 하지만 표시되지는 않음)
URI(선택 사항)
Meyerweb dot com은 모든 댓글을 수정하거나 삭제할 권리를 보유합니다, 특히 주제와 무관하거나 공격적인 경우에 그렇습니다.
허용되는 HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <em> <i> <q cite=""> <s> <strong> <pre class=""> <kbd>
댓글 (Markdown 지원) 만족하셨다면.
Δ
CSS, DOM, 또는 JavaScript 카테고리의 글도 둘러보세요.
“Thoughts From Eric”의 모든 게시물 모음은 RSS 2.0과 Atom으로 제공됩니다.
StarForce
별도 표기가 없는 한 이 사이트의 모든 콘텐츠는 ©1993-2026Eric A. and Kathryn S. Meyer의 소유입니다. 모든 권리 보유.
"Thoughts From Eric"는 WordPress로 구동됩니다. 사이트의 나머지 대부분은 손수 작성되었습니다.
이 사이트 디자인에 사용된 잉크 스케치 이미지는 Yūzan Mori의 Hamonshū, 1-3권에서 가져와 변형한 것으로, 1903년에 출판되었으며 Smithsonian Libraries와 Internet Archive를 통해 공용으로 사용할 수 있게 제공되었습니다.