🖼

렌더링 최적화하기


렌더링(Rendering)

어찌됐든 저찌됐든 웹 어플리케이션의 가장 중요한 목표는 데이터를 화면에 보여주는 것이다. 데이터를 화면에 보여준다는 것은 결국 요소를 화면에 렌더링하는 것이다. 그리고 화면에 렌더링 요소들은 DOM으로 정의된다.

즉, 화면에 데이터를 '잘' 보여주기 위해서는 DOM 조작을 효과적으로 해야한다.

렌더링 과정

먼저 요소가 렌더링되는 과정을 살펴보자.

렌더링은 크게 '파싱' → '렌더 트리 구축' → '렌더 트리 배치(레이아웃)' → '렌더 트리 그리기(페인트)'의 순서대로 진행된다.

파싱

이 단계에서 HTML 문서를 파싱해 'DOM 트리'를, CSS 파일을 파싱해 스티일 규칙인 'CSSOM 트리' 구축한다.

렌더 트리 구축

DOM 트리와 CSSOM 트리가 생성되었으면 이 둘을 합쳐 렌더 트리를 구성한다. 렌더 트리에는 페이지를 렌더링하는데 필요한 노드만 포함된다.

display: none인 요소는 레이아웃에 포함되지 않기 때문에 렌더링 트리에서 요소를 완전히 제거한다.

렌더 트리 배치(레이아웃)

렌더 트리의 각 노드를 화면 상에 어디에 배치할 지 계산한다. 노드의 정확한 위치와 크기를 계산한다.

경우에 따라 '리플로우(reflow)'라고도 한다.

렌더 트리 그리기(페인트)

렌더 트리의 각 노드를 화면의 실제 픽셀로 변환하는 단계다(래스터화). 이때가 되서야 우리가 보는 화면에 요소가 출력된다.

성능

레이아웃(Reflow)과 페인트(Repaint)는 상황에 따라 반복해서 발생할 수 있다.

DOM의 추가/삭제, CSS 속성을 통해 기하학적인 변화가 일어나면 Reflow가 발생하고, Reflow가 발생했거나 CSS 속성 변경이 기하학적 변화를 발생시키지 않을 때는 Repaint가 발생한다.

여기서 문제점은 Reflow와 Repaint는 시간이 오래걸리는 작업이라는 것이다. 특히 Repaint보다 Reflow의 비용이 훨씬 높다.

DOM을 조작할 때 reflow가 최소한으로 발생하도록 해야 좋은 성능을 얻을 수 있다.

렌더링 최적화하기

그렇다면 어떻게 해야 DOM 조작을 최적화할 수 있을까? 함께 살펴보자.

가상 노드 조작하기

요소에 뭔가 변화를 줄 때는 DOM에 달려있는 채로 조작하는 것 보다는 DOM에서 떼어낸 채로 조작하는 것이 효과적이다.

1const target = document.querySelector(".target");
2const element = target.cloneNode(true);
3// 뭔가 변경 작업...
4target.replaceWith(element);

위에서 elementtarget의 복사본을 두었다. 이 복사본은 실제 DOM 요소와 똑같지만, document의 바디와는 전혀 무관해진다.

따라서 element를 여기저기 변경해도 실제 DOM에는 변경되는 게 없다.

변경이 완료된 후 실제 요소 바꿔줌으로써 reflow를 최소화 할 수 있다.

requestAnimationFrame

모든 DOM 조작이나 애니메이션은 requestAnimationFrame(rAF)을 통해 이루어지는 것이 좋다.

DOM을 수정하게 되면 직전 레이아웃은 효력이 없어지고 reflow가 발생한다. 브라우저는 일반적으로 현재 작업이나 프레임이 끝날 때까지 기다린 후 reflow를 계산하지만, 특정 속성(기하학적인 값)을 읽으면 최신 값을 계산하기 위해 reflow를 동기적으로 발생시킨다.

이를 강제 동기 레이아웃(layout thrashing)이라고 한다. 강제 동기 레이아웃이 발생하면 reflow 계산을 위해 메인 쓰레드가 블락킹 되므로 성능에 치명적인 원인이 될 수 있다.

rAF에 등록된 콜백은 다음 리페인트 이전에 실행된다. DOM 조작을 rAF에서 하게되면 reflow로 인한 메인 쓰레드를 블락킹을 방지할 수 있다. 그리고 등록된 콜백들을 repaint 전에 한 번에 처리하기 때문에 reflow를 최소화 할 수 있게 된다.

좋은 예시를 여기에서 볼 수 있다. 꼭 보도록 하자.

가상 DOM 적용

많은 SPA 프레임워크에서 렌더링을 최적화하기 위해 'Virtual DOM'을 사용한다.

UI에 대한 표현을 메모리 상에 저장하고 이것을 진짜 DOM과 동기화해서 최소한의 연산만을 하는 것이다. UI에 변경이 생기면 메모리 상에서 먼저 변경하고 진짜 DOM과 비교하며 변경해야하는 부분만 변경한다. 이 과정을 'reconcilation'이라고 한다.

그렇다면 가상 DOM의 핵심은 무엇을 바꿀지 말지 아는 것이다. 이때 사용하는 것이 'Diff 알고리즘'이다. 진짜 DOM을 새로운 DOM으로 바꾸기 위한 최소 연산을 구하는 알고리즘이다.

가장 간단한 Diff 알고리즘을 구현해보자.

구현할 것은 applyDiff(parent, oldNode, newNode)란 함수로, 진짜 노드의 부모 요소, 실제 노드(old), 새로운 노드(가상 노드)을 인자로 주면 차이를 계산해 바꿔줄 것이다.

1applyDiff(parent, oldNode, newNode);

이제 내용을 차례차례 채워보자.

  1. 먼저 newNode이 없다면, 삭제되었다는 뜻이므로 실제 DOM을 삭제한다.
  2. 반대로 newNode이 있는데 oldNode이 없다면 추가된 것이므로 그냥 추가해준다.
  3. 둘 다 존재한다면 차이가 있는지 확인하고 차이가 있다면 oldNodenewNode으로 바꿔줘야 한다.
  4. 현재 노드에서 차이가 없다면 이 다음으로는 두 노드의 자식 요소들을 비교해야 한다.
1function applyDiff(parent, oldNode, newNode) {
2 // 1.
3 if(oldNode && !newNode) {
4 oldNode.remove();
5 return;
6 }
7 // 2.
8 if(!oldNode && newNode) {
9 parent.append(newNode);
10 return;
11 }
12 // 3.
13 if(isNodeChanged(oldNode, newNode)) { // isNodeChanged는 노드가 변경됐는지 확인해주는 함수
14 oldNode.replaceWith(newNode);
15 return;
16 }
17 // 4.
18 const oldChildren = Array.from(oldNode.children);
19 const newChildren = Array.from(newNode.children);
20
21 const max = Math.max(oldChildren.length, newChildren.length);
22 for(let i = 0; i < max; i++) {
23 applyDiff(oldNode, oldChildren[i], newChildren[i]);
24 }
25}

이제 isNodeChanged를 살펴보자. 노드에는 어떤 변경에 발생할 수 있을까?

일단 노드의 타입이 변경됐을 수도 있겠다. 타입이 같다면 그 다음으로는 노드의 어트리뷰트가 변화됐을 수도 있다. 혹은 엣지 노드의 경우에는 내부 텍스트가 다를 수도 있다.

이런 변화들을 감지해보자.

1function isNodeChanged(oldNode, newNode) {
2 // 타입이 다른지 확인
3 if(oldNode.type !== newNode.type) {
4 return true;
5 }
6
7 const oldAttributes = oldNode.attributes;
8 const newAttributes = newNode.attributes;
9 // 어트리뷰트 길이가 다른지 확인
10 if(oldAttributes.length !== newAttributes.length) return true;
11
12 // 어트리뷰트 내용이 달라진게 있는지 확인
13 const differentAttribute = Array.from(oldAttributes).find(attribute => {
14 const { name } = attribute;
15 const oldAttribute = oldNode.getAttribute(name);
16 const newAttribute = newNode.getAttribute(name);
17 return oldAttribute !== new newAttribute;
18 });
19 if(differentAttribute) return true;
20
21 // 엣지 노드면 내부 텍스트 확인
22 if(oldNode.children.length === 0 &&
23 newNode.children.length === 0 &&
24 oldNode.textContent !== newNode.textContent) return true;
25
26 return false;
27}

완성이다! 실제 사용되는 알고리즘보다는 덜하겠지만 충분히 쓸만하다고 생각된다.

더 최적화하는 것은 사용자의 필요에 따라 추가하면 되겠다.


참조:
https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Virtual-DOM/#_1-reflow-repaint
https://kyu9341.github.io/WEB/2021/01/24/WEB_BrowserRendering/
https://ui.toast.com/fe-guide/ko_PERFORMANCE
https://thisblogfor.me/web/raf_perform/
Frameless Front-End Development