본문 바로가기

프로그래밍(TA, AA)/자바스크립트

[자바스크립트] 브라우저 렌더링

브라우저에서 렌더링 성능은 중요한 요소 가운데 하나입니다. 렌더링 성능을 향상시키면 사용자가 느끼는 체감 속도를 개선할 수 있습니다. 자바스크립트로 동적인 작업을 실행할 때의 렌더링 문제를 최소화하여 성능을 높일 수 있습니다. 


렌더링 과정


렌더링이란 논리적인 문서의 표현식을 그래픽 표현식으로 변형시키는 과정입니다. 이 과정은 다음과 같이 크게 2단계를 거쳐 이뤄집니다.


1) DOM 요소와 스타일에 기반을 둔 레이아웃 계산

2) 계산된 요소의 화면 표현


일반적인 전체 흐름은 브라우저에 문서가 로딩됨에 따라 DOM 트리의 구성이 진행되면 레이아웃을 계산한 후 문서에 요소를 그립니다.


렌더링이 진행되는 과정

다음은 HTTP 요청 후 응답을 통해 구현되는 전체적인 브라우저의 처리 과정입니다.


1) DOM 트리 생성

브라우저는 HTML 태그를 파싱해 DOM 트리를 구성합니다. DOM은 데이터의 표현식으로 모든 HTML 태그에는 그에 상응하는 노드가 있으며, 태그 사이에는 텍스트 데이터가 포함될 수 있습니다. 이 또한 텍스트 노드의 표현식 입니다.


<html>
    <head>
        <title>테스트</title>
    </head>
    <body>
        <p>
            Hello World
        </p>
        <div>
            <img src="example.png" />
        </div>
    </body>
</html>

각 태그는 태그 데이터의 표현식인 DOM 요소로 1:1로 대응해 표현되며, DOM 요소 노드는 트리 형태로 구성됩니다. 이를 DOM 트리라고 합니다.




2) 스타일 구조체 생성

스타일 정보(스타일시트 파일이나 요소에 지정된 인라인 정보 등)를 통해 스타일 구조체(Style Struct)를 생성합니다. 스타일 정보는 단계적으로 처리되며, 가장 마지막 단계의 스타일 정보가 이전 스타일보다 우선으로 적용됩니다. 스타일 정보는 다음과 같이 3단계로 나누어 처리됩니다.


1. 브라우저 자체에 포함된 기본 스타일 정보(User Agent 스타일시트)

2. 사용자 정의 스타일(외부 파일 또는 내부 정의 스타일)

3. HTML 태그에 style 속성을 사용해 기술되는 인라인 스타일 정보

<!-- 외부 파일 -->
<link rel="stylesheet" type="text/css" href="/css/main.css">

<!-- 내부 정의 스타일 -->
<style type="text/css">
    body { background-color:#ffffff; background-image:none; background-reapeat:repeat; }
</style>


3) 렌더 트리 생성

DOM 트리와 스타일 구조를 통해 렌더 트리를 생성합니다.


렌더 트리는 DOM 트리와는 다르게 각 노드에 스타일 정보가 설정돼 있고 화면에 표현되는 노드로 구성됩니다. 어떤 노드는 스타일이 'display:none'으로 설정돼 있으면 해당 노드는 렌더 트리에 포함되지 않습니다.


DOM 트리와 렌더 트리의 노드는 서로 1:1로 대응되지 않습니다. DOM 트리의 구성원 가운데 일부 노드(<head>, <title>, <script> 등)는 화면에 표현되는 노드가 아니므로 DOM 트리의 구성원이지만 렌더 트리의 구성원은 아닙니다. 또한 DOM 트리의 단일 구성원이지만 렌더 트리에서는 여러 개의 노드로 구성되는 경우도 있습니다. <br> 태그로 인해 줄이 바뀌거나 노드 내에서 자연스럽게 줄이 바뀐 경우 등 단일 텍스트 노드가 여러 줄로 출력되는 경우가 여기에 해당합니다.


렌더 트리에서 각 노드는 '프레임(frame)'이나 '박스(box)'로 불리며, CSS박스 속성 정보가 있습니다.



4) 레이아웃 처리

렌더 트리의 각 노드의 크기가 계산되고 문서에서 정확한 위치에 배치되도록 위치를 계산합니다. 이 과정은 CSS 비주얼 렌더링 모델(CSS Visual Rendering Model)에 의해 제어되며, 루트에서 하위 노드로 반복되며 진행됩니다.


또한 브라우저는 레이아웃 계산을 출력되는 화면의 해상도보다 높은 해상도로 처리합니다. 그래서 사용자가 화면을 확대 또는 축소했을 경우를 대비해 추가적인 계산없이 원본 크기 상태의 픽셀 좌푯값과 매핑해 배율에 상관없이 올바르게 배치되게 합니다.



5) 페인트(Paint)

이제 렌더링 엔진은 요소가 어디에 표현돼야 할지 알고 있으므로 렌더 트리를 순회하면서 페인트 함수를 호출해 노드를 화면에 표현합니다.


다음은 위에서 설명한 렌더링의 각 단계에 따라 렌더링 엔진인 웹킷과 게코에서 처리되는 과정을 그림으로 표현한 것입니다. 모든 과정은 점진적으로 처리됩니다.








리플로와 리페인트

최초 페이지가 로딩되면 렌더링 작업이 진행됩니다. 이후 렌더링이 모두 완료된 상태에서 사용자의 인터랙션 또는 해당 페이지의 기능에 따라 화면의 일부 영역에 변경 요인이 발생합니다. 예를 들어, 레이어가 전환되거나 AJAX를 통해 새로운 데이터를 받아와서 페이지에 추가할 때 요소의 스타일이 변경되는 등의 작업이 그렇습니다.


이러한 작업이 발생하면 구성돼 있는 렌더 트리가 변경돼야 하며 리플로 또는 리페인트가 발생합니다.



리플로

변경(일부 또는 전체)이 필요한 렌더 트리에 대한 유효성 확인 작업과 함께 노드의 크기와 위치를 다시 계산합니다. 이 과정을 리플로(Reflow) 또는 레이아웃(Layout), 레이아웃팅(Layouting)이라고 부릅니다. 좀 더 정확하게는 노드의 크기 또는 위치가 바뀌어 현재 레이아웃에 영향을 미쳐 배치를 다시 해야 할 때 리플로가 발생합니다.


특정 요소에 리플로가 발생하면 요소의 DOM 구조에 따라 자식 요소와 부모 요소 역시 다시 계산될 수 있으며, 경우에 따라서는 문서 전체에 리플로가 발생할 수도 있습니다. 


<body>
<div id="gnb_more" class="more_skin">
<p><strong>검색 중심</strong>의 메인화면으로 더 가볍고 빠르게 검색 결과를 확인할 수 있습니다.</p>
<h3 class="blind">주요 서비스</h3>
<ul class="main">
<li class="fix_wt"><a href="http://www.naver.com/">네이버</a></li>
<li><a href="http://me.naver.com/">me</a></li>
<li><a href="http://mail.naver.com/">메일</a></li>
<li><a href="http://section.cafe.naver.com/">카페</a></li>
<li><a href="http://blog.naver.com/">블로그</a></li>
</ul>
</div>
</body>


위의 구조에서 <p>....</p> 영역에 리플로가 발생하면 자식 노드인 <strong>...</strong> 또한 당연히 리플로 대상이 됩니다. 이에 더해 부모 노드인 <div id="gnb_more">, 형제 노드인 <h3>, <ul>까지도 리플로가 발생해 결과적으로는 문서 전체에 리플로가 발생합니다.



리페인트

변경 영역의 결과를 표현하기 위해 화면이 업데이트되는 것을 의미합니다. 리플로가 발생하거나 배경색 변경 등의 단순한 스타일 변경과 같은 작업이 발생하는 경우입니다. 간단하게는 화면을 변경해야 할 때 발생한다고 생각하면 됩니다. 이러한 작업을 리페인트(Repaint) 또는 리드로드(Redraw)라고 합니다.


리플로와 리페인트 모두 처리 비용이 발생하지만 리페인트보다 리플로의 비용이 훨씬 높습니다. 리플로는 변경 범위에 따라 전체 페이지의 레이아웃을 변경해야 할 수도 있기 때문입니다. 어느 경우든 리플로와 리페인트 때문에 UI의 화면 표현이 느려져 사용자 경험에 영향을 줄 수 있으므로 코드를 작성할 때 이를 최소화해야 합니다.


발생 요인

현재 구성된 렌더 트리의 변경을 가져오는 작업이 실행되면 작업의 종류에 따라 리플로 또는 리페인트가 발생합니다. 주요 변경 요인은 다음과 같습니다.


DOM 노드의 변경: 추가, 제거 업데이트

DOM 노드의 노출 속성을 통한 변경: display:none은 리플로와 리페인트를 발생시키지만 비슷한 속성인 visibility:hidden은 요소가 차지한 영역을 유지해 레이아웃에 영향을 주지 않으므로 리페인트만 발생시킵니다.

스크립트 애니메이션: 애니메이션은 DOM 노드의 이동과 스타일 변경이 짧은 시간 내에 수차례 반복해 발생되는 작업입니다.

스타일: 새로운 스타일시트의 추가 등을 통한 스타일 정보 변경 또는 기존 스타일 규칙의 변경

사용자의 액션: 브라우저 크기 변경, 글꼴 크기 변경 등


렌더링 과정 확인하기

리플로와 리페인트의 발생 과정은 도구로 직접 확인해 볼수 있습니다. 인터넷 익스플로러 또는 파이어폭스에서는 dynaTrace로, 웹킷 계열인 크롬과 사파리에서는 개발자도구로 확인할 수 있습니다.


크롬에서 렌더링 처리는 세 가지 상태의 단계로 표현됩니다.


Recalculate Style: 요소의 스타일값을 재계산하는 단계

Layout: 리플로가 발생한 단계

Paint: 변경된 요소를 화면에 표현하는 단계


렌더 트리의 변경으로 인한 리플로와 리페인트는 비용이 많이 듭니다. 브라우저는 내부적으로 비용을 줄이기 위해 이러한 작업을 지금 당장 실행하지 않거나 나중에 실행하도록 미루는 방법으로 최적화합니다. 관련 작업을 큐(queue)에 쌓고 일정 시간 또는 일정 수의 작업이 쌓인 이후에 배치로 관련 작업을 한 번에 처리해서 여러 번 발생할 수 있는 리플로를 한 번으로 줄입니다. 하지만 요소의 특정 속성이나 메서드를 이용해 값을 요청하는 것만으로도 이 같은 브라우저의 최적화 실행을 중단시킬 수 있습니다.


보통 브라우저는 값을 반환하도록 요청받으면 요소의 최신 상태 정보가 반영된 값을 반환하려고 합니다. 그러나 해당 시점에 큐에 쌓인 작업이 요청된 요소의 스타일 정보에 영향을 줄 수도 있으므로 브라우저는 먼저 큐에 있는 작업을 모두 실행하고 나서 요청된 값을 반환합니다. 이렇게 되면 결과적으로 특정 속성과 메서드를 실행하는 것만으로도 추가적인 리플로를 발생시킬 수 있습니다.


다음은 브라우저의 리플로 최적화 실행을 중단하고 추가적인 리플로를 발생시키는 것으로 알려진 객체의 속성과 메서드 입니다.


  객체

 속성 및 메서드

 HTMLElement

clientHeight, clientLeft, clientTop, clientWidth, focus(), getBoundingClientRect(), getClientRects(), innerText, offsetHeight, offsetLeft, offsetParent, offsetTop, offsetWidth, outerText, scrollByLines(), scrollByPages(), scrollHeight, scrollIntoView(), scrollIntoViewIfNeeded(), scrollLeft, scrollTop, scrollWidth 

 Frame, Image

height, width 

 Range

getBoundingClientRect(), getClientRects() 

 SVGLocatable

computeCTM(), getBBox()

 SVGTextContent

getCharNumAtPosition(), getComputedTextLength(), getEndPositionOfChar(), getExtentOfChar(), getNumberOfChars(), getRotationOfChar(), getStartPositionOfChar(), getSubStringLength(), selectSubString() 

 SVGUse

instanceRoot 

 window

getComputedStyle(), scrollBy(), scrollTo(), scrollX, scrollY, webkitConvertPointFromNodeToPage(), webkitConvertPointFromPageToNode() 



리플로 최소화 방법

리플로와 리페인트는 비용이 많이 듭니다. 따라서 렌더링 성능을 향상시키려면 먼저 리플로를 줄여야 합니다. 리플로를 최소화해서 렌더링 성능을 향상시킬 수 있습니다.


작업 그루핑

DOM 요소의 정보를 요청하고변경하는 코드는 같은 형태의 작업끼리 그룹으로 묶어 실행시키는 것이 좋습니다. 다음 코드를 살펴보면


function change() {
    var width = document.getElementById("layer1").style.width;
    document.getElementById("layer2").style.width = width;
    var height = document.getElementById("layer3").style.height;
    document.getElementById("layer4").style.height = height;
}


위의 코드는 요소의 스타일 정보를 요청하고, 반환된 값을 다른 요소의 스타일을 변경하는데 사용합니다. 그 후 다시 다른 요소에 동일한 형태의 작업이 반복됩니다. 이 코드를 실행하면 리플로가 여러 번 발생할 수 있습니다. 따라서 다음과 같이 비슷한 형태의 작업끼리 그룹을 묶어 실행되도록 순서를 변경하면 렌더링 처리를 향상 시킬수 있습니다.


function change() {
    var width = document.getElementById("layer1").style.width;
    var height = document.getElementById("layer3").style.height;
    document.getElementById("layer2").style.width = width;
    document.getElementById("layer4").style.height = height;
}


실행 사이클

브라우저에서 자바스크립트의 실행은 "이벤트 루프(Event Loop)" 모델을 따릅니다. 기본적으로 브라우저는 이벤트가 발생하면 바로 처리가 가능하도록 "유휴(idle)" 상태에 머무릅니다. 그러다 사용자의 인터랙션이나 자바스크립트의 타이머, AJAX의 콜백 등에 의해 유휴 상태가 해제되면서 작업이 실행됩니다. 작업이 실행되면 브라우저는 작업의 실행 결과에 따른 리페인트가 완료될 때까지 기다립니다.


이러한 실행 사이클로 인해 타이머를 사용하면 수차례의 리플로와 리페인트가 발생될 수 있습니다.


같은 요소의 스타일을 변경하는 경우는 다른 ID를 가진 div 객체이더라도 단 한번의 리플로와 리페인트가 발생됩니다. 하지만 2개의 요소 중 한 개를 타이머에서 처리하도록 변경하면 2번의 리플로와 리페인트가 발생합니다.


타이머에서 실행되게 하면 추가적인 실행 사이클이 발생합니다. 타이머로 등록된 코드 블록은 브라우저가 유휴 상태가 됐을 때 실행되는데, 타이머의 실행 시간을 0으로 설정해도 브라우저가 유휴 상태가 아니면 그 상태가 되기까지 코드 블록은 실행되지 않습니다. 첫 번째 요소에 대한 작업이 한 사이클 내에서 실행되고, 타이머의 실행은 먼저 실행된 사이클이 끝난 다음에 진행됩니다. 이로 인해 결과적으로 리플로와 리페인트가 두 번 일어나게 됩니다. 따라서 리플로와 리페인트가 일어날 수 있는 작업은 가능하면 실행 사이클 안에서 실행하도록 처리하는 편이 효과적입니다.



노출 제어를 통한 리플로 최소화 방법

요소의 스타일을 변경하면 리페인트는 반드시 일어나며, 변경 형태에 따라 리플로도 일어납니다. 변경하는 스타일 속성의 수가 적다면 변경에 따른 렌더링 성능 저하가 부담스럽지 않겠지만 여러 속성의 스타일값을 변경하는 경우라면 얘기가 달라집니다.


display 속성

기본적으로 리플로와 리페인트는 모두 화면에 변경된 사항이 반영되는 시점에 발생합니다. 여러 속성의 스타일을 변경하는 중간 단계에서는 화면에 표시하지 않고, 작업이 완료되고 최종 경과가 반영되는 마지막 시점에 요소를 다시 표시한다면 리플로와 리페인트의 발생횟수를 크게 줄일 수 있습니다.


다음의 코드를 살펴보면, 이 코드는 값을 여러번 변경하며 값이 변경될 때마다 리플로와 리페인트가 발생합니다.

var element = document.getElementById("box1");

for(var i=50; i<100; i++) {
    element.style.width = i + "px";
}

for(i=1; i<=50; i++) {
    element.style.borderWidth = i + "px";
}


하지만 다음과 같이 요소를 보이지 않게 하고 모든 변경이 반영된 이후에 표시하면 처음과 마지막 시점 두번으로 리플로 발생 횟수가 줄어듭니다.

var element = document.getElementById("box1");
element.style.display = "none";

for(var i=50; i<100; i++) {
    element.style.width = i + "px";
}

for(i=1; i<=50; i++) {
    element.style.borderWidth = i + "px";
}

element.style.display = "block";

첫번째 코드의 경우, 중간 단계는 노출 제어를 하지 않고 요소의 모든 변경 과정이 바로바로 렌더링돼 노출 됩니다. 따라서 변경 과정이 지속되는 동안 계속해서 리플로와 리페인트가 발생합니다. 두번째 코드는 변경 과정을 실행하기 전에 해당 요소가 노출되지 않게 처리한 후 변경 과정을 처리합니다. 이렇게 처리하면 display="none" 시점(이때 왼쪽 요소가 사라진 영역으로 오른쪽 요소가 이동하며 리플로와 리페인트가 발생한다)과 변경 완료 시점의 display="block"으로 두번만 리플로와 리페인트가 발생합니다.


노드 복제

변경하려는 요소의 노드를 복제한 후 복제된 노드에 필요한 작업을 실행하는 방법입니다. 복제된 노드는 DOM 트리에 추가된 상태가 아니므로 렌더링 성능에 영향을 줄 수 있는 작업을 실행하더라도 리플로나 리페인트가 발생하지 않습니다.

var element = document.getElementById("box1");
var clone = element.cloneNode(true);     // 원본 노드를 복제한다.

for(var i=0; i < 100; i++) {
    clone.style.width = i + "px";
}

// 변경된 복제 노드를 DOM 트리에 반영하기 위해 기존 노드와 치환한다.
parentNode.replaceChild(clone, element);

작업이 모두 완료된 이후 복제된 노드를 원래 노드와 치환해 DOM 트리에 변경된 사항이 적용되게 합니다. 그러면 치환 시점에만 리플로와 리페인트가 발생합니다.


createDocumentFragment() 메서드 사용

브라우저의 화면에 표시되는 페이지는 DOM 트리와 렌더링 트리를 통해 표시됩니다. 화면에 보이는 DOM 트리는 메인 DOM 객체라 할 수 있는데, 간혹 DOM API 메서드를 사용해 노드 작업을 실행해야 할 때가 있습니다. 이때 메인 DOM 객체와는 별개의 새로운 DOM 객체를 생성해 사용하면 렌더링 성능을 좀 더 향상시킬 수 있습니다. 새로운 DOM 객체는 다음 예제와 같이 document.createDocumentFragment() 메서드를 사용해 생성합니다.


// 새로운 DOM 객체를 생성한다.
var fragment = document.createDocumentFragment();

// elms는 메인 DOM에 추가해야 하는 DOM 컬렉션 객체라고 가정하며, 새로운 DOM 객체에 반복문으로 추가한다.
for(var e=0; e<elems.length; e++) {
    fragment.appendChild(elems[e]);
}

// 메인 DOM 객체, 즉 화면에 보이는 DOM 트리에서 <div> 요소를 선택한다.
var div = document.getElementsByTagName("div");

// 선택된 <div> 요소를 반복문으로 순회하며 새로운 DOM 객체에 추가된 노드의 복제본을 메인 DOM 객체에 추가한다.
for(var i=0; i < div.length; i++) {
    div[i].appendChild(fragment.cloneNode(true));
}


캐싱

여기서 설명하는 캐싱은 별도의 변수에 자주 사용하는 값을 저장하는 것입니다. 특정 속성과 메서드를 사용하기만 해도 리플로가 발생할 수 있습니다. 자주 사용하는 속성의 값이나 메서드의 반환값을 변수에 저장하면 직접 속성이나 메서드를 호출하는 횟수를 줄여 성능을 향상시킬 수 있습니다.


다음과 같은 코드는 반복문을 실행할 때마다 리플로를 유발하는 속성을 호출합니다.


// 아래와 같이 반복 구문에서 리플로 유발 속성을 요청하는 것은 피해야 한다.
for(condition) {
    el.style.width = el.scrollWidth + "px";
    el.style.left - el.scrollLeft + "px";
}


이 경우 값을 최대한 별도의 변수에 캐싱해 자주 호출되지 않게 하면 리플로의 발생 빈도를 낮출 수 있습니다.

// 리플로를 유발할 수 있는 scrollWidth 속성과 scrollLeft 속성의 값은
// 반복 구문을 실행하기 전에 변수에 캐싱해 리플로 발생을 최소화한다.
var nWidth = el.scrollWidth, nLeft = el.scrollLeft;

for(condition) {
    el.style.width = nWidth + "px";
    el.style.left = nLeft + "px";
}


CSS 규칙

자바스크립트로 가장 빈번하게 실행되는 작업은 바로 요소의 스타일 속성값을 변경하는 것입니다. 대상 요소의 개수가 많아지면 많아질수록 렌더링 성능이 저하될 수 있습니다. 이때 각 요소에 접근해 값을 변경하기 보다는 CSS 규칙을 생성하고 해당 규칙이 요소에 반영 되게 하는 것이 좋습니다.


document.getElementsByTagName("head")[0].appendChild(document.createElement("style"));
var oStyle = document.styleSheets[document.styleSheets.length - 1];

if(oStyle.addRule) {     // 인터넷 익스플로러의 경우
    oStyle.addRule("div", "background-color:green");
} else {         // 그 밖의 바라우저
    oStyle.insertRule("div {background-color:green;}", 0);
}


한가지 유의할 점은 CSS 규칙을 이용한 처리가 항상 빠르지는 않다는 사실입니다. 대상 요소의 수가 많지 않다면 CSS 규칙 처리 방식보다 오히려 개별 요소에 접근해 처리하는 방식이 더 빠릅니다.