브라우저의 렌더링 과정을 통해 캔버스 성능 살펴보기

미뇽·2024년 11월 3일
11
post-thumbnail

들어가며

나는 개인적으로 html canvas를 활용한 서비스는 프론트엔드의 정수라고 생각한다. 아마 극한으로 프론트엔드에서 극한까지 사용하게 된다면 figma와 같은 궁극의 결과물이 나오지 않을까.. 생각해서 찍먹해보지 않을 수 없었다.
그래서 새롭게 프로젝트를 시작하는 과정에서 나는 마인드맵을 캔버스로 그려주는 서비스를 만들려고 했는데, 여기서 고민이 하나 있었다.

캔버스만이 선택지일까?

html 캔버스는 그래픽 요소를 그리기 위한 html 내장 콘텐츠로, 원하고자 하는 것을 비트맵 기반으로 그려낼 수 있다.
하지만 사실 다이어그램같은 요소들을 그려내는 과정에서 캔버스는 굳이 필수사항이 아니라고 생각한다.


당장 react-flow만 보아도 각 다이어그램들을 모두 div로 표현하면서, 여기서 CSS를 붙여 사용하는 방식인데, 캔버스로 굳이 할 필요가 없다는 생각도 들었다.
하지만 canva와 excalidraw와 같은 서비스들은 모두 canvas 기반으로 작동하는 서비스들이었기 때문에, 문득 이 html 요소들과 canvas 중에 무엇이 더 성능에 좋을까? 하는 의문이 들었다.

이에 대해서는 보다 브라우저의 렌더링에 대해 이해해야지 이유를 찾을 수 있을 것 같다고 판단하여 브라우저의 렌더링에 대해 간략하게나마 정리해보았다.

브라우저의 엔진

우리는 브라우저가 어떻게 보여지는지에 대해서 말해보라는 질문을 받으면 아마 간단하게 'HTML를 해석해서 DOM Tree로 파싱하고 CSS도 파싱한 담에 그려냅니다'라고 말할 수 있다. 하지만 이 '그려냅니다'라는 말은 많은 과정이 함축되어 있는 말이다.

브라우저는 Rendering Engine, Javascript Engine, Graphics Library 3가지 요소로 렌더링이 처리된다. 각 요소들은 가지는 한계가 명확하기 때문에 각자의 역할만을 담당한다.

  • 렌더링 엔진
    - HTML 파싱 후 DOM트리 생성
  • 자바스크립트 엔진(V8)
    - 자바스크립트 코드 실행
  • 그래픽 라이브러리
    - 화면에 그리기

렌더링 엔진의 경우 자바스크립트를 읽을 수 없기 때문에 html의 렌더링만 담당한 후, 구글의 V8 자바스크립트 엔진을 통해 자바스크립트를 해석하고 그래픽 라이브러리를 통해 우리가 보는 화면을 그려낸다.
이 일련의 과정은 모두 메인 쓰레드에서 작동한다.

하지만 이 DOM 요소를 만들고 자바스크립트를 해석하여 화면을 그리는 과정 자체가 16ms를 넘다 보니까 모니터의 주사율 60Hz의 관점에서도 프레임 드랍이 발생한다.

이 문제는 메인 쓰레드가 이 세 가지 작업을 모두 하려고 하다 보니까 생기는 문제이다.
그렇다면 이 문제를 해결하기 위한 방법은 무엇이 있을까?
바로 메인 스레드가 모든 작업을 처리하지 않게 하는 것이다.

브라우저에서 사용되는 스레드는 비단 메인스레드만 있지 않다.
웹페이지를 효율적이고 부드럽게 렌더링하기 위해 별도의 컴포지터 스레드와 래스터 스레드가 렌더러 프로세스에서 실행된다.
그렇다면 이 컴포지터 스레드와 래스터 스레드가 무엇인지, 또 그것의 역할이 무엇인지에 대해서는 다시 브라우저 렌더링을 파악하면서 알아보도록 하자.

브라우저의 렌더링

앞에서 말했던 것처럼 브라우저는 DOM 트리를 만들고, CSS를 파싱하여 스타일 시트를 생성하고, 스타일을 계산한다.

스타일을 계산한다고 모든 요소들이 우리가 예상한 대로 화면에 그려지는 과정에서도 많은 과정들이 수반된다.

파싱

해당 단계에서는 여러 파싱 단계를 거친다.

  • HTML을 파싱하여 DOM Tree 생성
  • CSS를 파싱하여 CSSOM 생성
  • DOM + CSSOM의 렌더 트리 생성
    이 과정 자체도 많지만 사실 이 정도는 기본으로 알고 있는 브라우저의 렌더링 단계이니 더 깊은 설명은 생략한다

Layout

이 단계에서는 어디에 그려야 할 지에 대해서 결정한다.

위 예시에서도 글자 크기가 커지면서 박스를 넘어가게 되면 이 부분에 대해서도 줄바꿈 등의 계산을 모두 해당 과정에서 수행해야 한다.

그렇기에 브라우저는 스타일이 적용된 DOM을 다시금 훑으면서 레이아웃 트리를 만든다. 이 레이아웃 트리에는 오로지 '보이는' 요소들이 '어디에' 보여야 할 지에 대한 정보를 가지고 있다.

Pre-Paint

레이아웃 트리가 만들어지면 다음에는 Pre-paintpaint과정을 거친다. pre-paint는 말 그대로 그리기 전에 하는 작업과 그리는 작업이다.

Pre-painting는 다음 단계에서 화면에 그려낼 레이어를 구성하기 위한 준비 단계이다. 해당 단계에서는 두가지 단계로 이루어진다.
1. paint Invalidation
브라우저가 화면의 일부를 다시 그려야 한다고 판단할 때 기존의 페인팅 정보를 무효화한다. 이 과정은 페이지의 시각적 상태가 바뀌어 현재의 그리기 정보가 더 이상 유효하지 않다고 판단될 때 실행된다.

2. Property Tree

무효화가 끝나면 transform, opacity 등의 속성을 트리화시켜 각 노드에서 이 Property Tree의 노드를 참조하여 레이어를 합치는 단계에서 필요한 효과를 빠르게 적용할 수 있다.

Paint

이 DOM, 스타일, 레이아웃을 기반으로 브라우저에게 '어떻게 그려야 할 지' 에 대해 정의하는 paint records를 생성한다. 위에서 말한 것과 같이 z-index를 두는 경우 요소들을 브라우저 내에서 그리게 된다면 그리는 순서에도 차이가 발생하기 때문이다. 페인트 레코드에는 세 가지 정보가 포함된다.

  • Action (e.g. Draw Rect)
  • Position (e.g. 0, 0, 300, 300)
  • Style (e.g. backgroundColor: red)

하지만 이 받은 정보들을 기반으로 실제로 그리는 작업은 여기서 수행되지 않는다는 점을 기억해야 한다.

Layerize

Paint Records를 만들었으면 그 paint records를 가지고 일정한 기준에 따라 layer로 분리한 다음에 Paint Layer를 생성한다.
이렇게 페인트 레이어를 분리하는 과정을 Layerize 과정이라고 한다. 이 과정에서 나오는 결과물은 여러개의 paint layer이다. 이 paint layer는 보통

  • 최상위 요소(root element)
  • position: relative, absolute 사용
  • 3D(translate3dpreserve-3d, ,..)나 perspective transform 사용
  • <video><canvas> 태그 사용
  • CSS filter나 alpha mask 사용
    정도로 구분된다.

Paint Layer중 Compositing Trigger 를 가지고 있거나 스크롤 가능한 컨텐츠가 있을 경우 별도의 Graphics Layer가 생성된다.

Compositing Trigger

  • 3D 변형: translate3dtranslateZ …
  • <video><canvas>, <iframe> 요소
  • position: fixed
  • CSS 트랜지션과 애니메이션을 사용해 구현한 transform과opacity 애니메이션
  • position: fixed
  • will-change
  • filter

분리된 graphnics Layer들은 독립적인 픽셀화가 가능하여 프레임마다 후에 설명할 단계인 래스터하는 과정을 다시 실행하지 않고 GPU 연산이 가능하기 때문에 빠른 스크롤링이나 애니메이션이 가능하다.

아무튼 이 Composited Layer List 과정에서 각 레이어를 그리는 스레드는 래스터 스레드, 이를 통해 만들어진 결과물을 웹페이지에 합성하는 작업은 컴포지트 스레드가 담당하면서 메인스레드와는 별개로 작동한다.

Commit

Layerize단계의 출력인 Composited Layer List는 PrePaint단계에서 생성한 Property Tree와 함께 합성 스레드(Composite Thread)로 복사되는 과정을 ‘Commit’이라 한다.

메인 스레드의 작업은 여기에서 끝난다는 사실도 중요하게 기억해야 할 점이다. 커밋 이후에는 자바스크립트를 실행하거나 렌더링 파이프라인을 다시 실행할 수 있다.

이 과정에서는

  • 요소들을 작은 단위로 나누어 "그려내는" 래스터화 작업 (Tilling)
  • 이를 통해 만들어진 요소들을 한 곳에 "합치는" 작업인 컴포지트 작업(Composite)
    으로 나누어진다.

Tilling

Tilling단계에서는 받았던 Paint Layer를 기반으로 각 레이어들을 따로 그려야 할 필요성이 있다. 레이어를 따로 그린 다음에 이를 합쳐 하나의 결과물이 나오도록 하기 때문이다.

하지만 이 레이어는 크기가 클 수도 있기 때문에 이를 다수의 타일(Tile) 형태로 나눠 래스터 스레드로 보내면서 래스터화해 GPU 메모리에 저장한다.


Tilling은 이렇게 타일 형태로 분할하는 작업을 의미한다. 각 타일에는 PaintRecord가 포함되고, 뷰 포트 포함 여부나 인접성 등에 따라 다른 우선순위를 가지면서 래스터화된다.

Raster

이 Raster 단계가 실질적으로 그리는 단계이다.

각 타일을 래스터화 시키는 작업은 skia라는 그래픽 라이브러리를 사용하여 비트맵 이미지를 생성하고 이를 GPU 메모리에 저장한다.

이 과정 자체를 cpu가 아닌 gpu에게 담당하도록 하면서 보다 자연스러운 애니메이션 등을 연출할 수 있는 환경이 주어진다.

이렇게 타일들이 래스터화되면 drawquad라는 데이터가 생성되고, 쿼드에는 메모리에서 타일의 위치와 웹 페이지 합성을 고려해 타일을 웹 페이지의 어디에 그려야 하는지에 관한 정보를 가지고 있으며, 앞서 생성한 레이어와 Property Tree 정보를 바탕으로 생성된다.

이 쿼드들이 모인 것을 컴포지터(합성) 프레임이라고 하는데, 이는 웹 페이지의 프레임을 나타낸다.

Activate

컴포지트 스레드의 경우에는 pending tree와 active tree를 가지고 서로 swap하는 멀티버퍼링 패턴을 가지고 있다. 비동기로 진행되는 래스터 작업 중에 이전 커밋에 대한 내용을 보여주어야 하기 때문에 가지는 패턴이다.

pending tree는 커밋을 받고 렌더링에 필요한 작업이 완료되면 pending tree를 active tree로 복제한다. 이렇게 분리 된 트리구조로 인해 active tree에서 GPU작업을 하는 동안 pending tree에서 커밋된 변경사항을 대기시킬 수 있다.

마지막으로 activate된 쿼드들은 Compositor Frame, 즉 위에서 말한 합성 프레임이라는 데이터로 묶여 GPU프로세스로 전달된다.
컴포지트 스레드의 최종 목표는 commit받은 레이어를 쪼개서(tiling) 래스터화하고 Frame으로 만들어 GPU에 전달하는 것이다.

만약 스크롤 이벤트가 발생하면 컴포지터 스레드는 GPU에게 보내질 다른 컴포지터 프레임을 생성한다.

Display


마지맞 Display에서는 GPU 프로세스의 viz 스레드에서 여러 개의 합성 프레임을 단일 합서으 프레임으로 합치는 작업을 하며, 화면에 픽셀을 렌더링하면서 한 프레임을 그리는 과정까지가 렌더링의 파이프라인이다.

그래서 왜 캔버스가 나은데?

앞의 렌더링 과정에서 보면 어느정도 왜 캔버스를 사용하는 것이 성능적으로 이점을 가지는 지에 대해서는 감이 조금은 올 수도 있다.

기존 DOM 요소를 통해 다이어그램을 조작한다고 생각해보자.
다이어그램을 조작하는 과정에서 사용자는 계속해서 클라이언트와 상호작용할 일이 많다. 이를 다른 말로 생각하면 그만큼 요소가 변할 일이 많고, 기존 DOM 요소가 변경되거나 수정이 되면 HTML 파싱, Layout, Pre-paint 등의 과정이 반복해서 이루어지면서 오버헤드가 생길 수 있다는 이야기기도 하다.

하지만 캔버스의 경우에는 다르다. 캔버스의 경우에는 자바스크립트로 canvas api를 통해서 각 요소들을 어떻게 그려야 하는 지에 대해 Paint Record의 형태로 이미 정의되어 있다. 그렇기 때문에 캔버스에서 그려지는 요소들이 메인 스레드에서 하는 작업이라고는 자바스크립트를 통해 paint record들을 정의하는 작업정도밖에 되지 않는다.

따라서 위 사진처럼 메인쓰레드에서는 Recording만을 수행하여 그림을 그리는 방법을 정의하기만 하고, 컴포지터 스레드와 래스터 스레드를 통해 타일링과 래스터링하는 과정이 진행되면서, 보다 CPU의 부담은 낮아짐으로써 프레임 드랍이 일어날 확률 또한 줄어들게 된다.

추가적으로 현재는 Offscreen Canvas API가 새롭게 생기며, canvas를 조작하는 자바스크립트만을 또 떼어내 이를 Web worker에서 처리함으로써 기존에 메인 스레드에서 동작하던 코드를 워커 스레드에서 실행 및 비트맵 그리기를 수행하면서 보다 성능이 향상될 수 있다.

이 OffscreenCanvas는 기존의 DOM에서 완전히 분리되고 동기화 기능이 없기 때문에 일반 캔버스에 비해 속도가 향상되는 효과를 가질 수 있다.

이를 통해 쓰레드의 경우에는 계속해서 리렌더링할 DOM이 줄어들면서 자원을 더 사용할 수 있는 여유가 생기기 때문에 이 부분에서도 이점을 가질 수 있다.

결론

캔버스에 그릴만한 요소가 많아지면 많아질수록 DOM 객체로 만들게 되면 그만큼 걸리는 과정에서 오버헤드가 크므로 캔버스의 경우가 DOM 객체를 그리는 과정보다는 보다 성능이 좋을 수 있다.

도형 그리기 html vs svg vs canvas 성능 비교
이는 레퍼런스에서 또한 확연하게 좋아지는 성능을 통해서도 증명할 수 있는 사실이다.

하지만 캔버스의 경우 또한 반복해서 렌더링하는 방식이다보니, 이 과정에서 애니메이션의 처리와 같은 부분에서 성능이 저하될 수 있는 가능성 또한 배제할 수 없다. 따라서 이러한 부분의 경우에는 캔버스의 최적화를 최대한 고려하면서 개발하는 편이 가장 이상적이라고 생각한다.

처음에 개발을 시작하면서 브라우저가 어떻게 띄워지는지에 대해서는 DOM tree와 CSSOM 정도로만 알고 있었는데, 이번 기회에 렌더 트리를 만들고 '그려내는' 작업에 대해서 딥다이브를 해보니 브라우저가 정말 많은 기술의 집합체였구나를 깨달았다. 하긴 그렇게 쉬우면 다 만들었지..

아무튼 이번 기회를 통해 브라우저의 렌더링 과정을 이해하면서 캔버스를 활용할 때 좋은 배경 지식이 생긴 것 같아 나름 만족스럽다.
지금 쓴 것도 나름 축약해서 정리한 것이긴 한데, 나중에 시간이 된다면 보다 더 면밀하게 살펴볼 예정이다.


참조

https://d2.naver.com/helloworld/5237120
https://developer.chrome.com/blog/inside-browser-part3
https://so-so.dev/web/browser-rendering-process/
https://developer.chrome.com/docs/chromium/blinkng#composite-after-paint-pipelining-paint-and-compositing
https://onlydev.tistory.com/82

profile
문이과 통합형 인재(人災)

1개의 댓글

comment-user-thumbnail
2024년 11월 15일

As stated in the article, optimizing canvas performance not only involves technical adjustments, but also requires a deep understanding of the relationship between browser rendering mechanisms and canvas interaction. This in-depth exploration and analysis, just like players constantly exploring and optimizing their strategies in Shell Shockers, is a reflection of continuous progress and improvement in the field of technology.

답글 달기