cadenzah's hideOut

~ 정처없이 떠돌 수 없어 속이 베베 꼬인 영혼을 위로하며 ~



Rendering: repaint, reflow / relayout, restyle

이 글은 Rendering: repaint, reflow / relayout, restyle을 번역한 것입니다. 의역이 있을 수 있습니다.

5개의 "R" 단어가 보기 좋지 않은가? 렌더링에 대한 이야기를 해보도록 하자. 페이지의 생명 주기 2.0에서 구성 요소들을 마구마구 다운로드받는 중 / 또는 후에 마주치게 되는 단어이다.

렌더링 과정

각 브라우저들이 다르게 동작하기는 하지만, 아래의 도식이 브라우저들에 대체적으로 적용할 수 있는 일반적인 아이디어를 제공한다고 보면 된다. 페이지를 위한 코드를 다운로드받은 뒤에 일어나는 과정이다.

  • 브라우저는 HTML 소스 코드(Tag soup)를 파싱하여 DOM 트리를 만든다. DOM 트리는 각 HTML 태그가 자신에 대응하는 노드를 갖고, 각 태그 사이에 존재하는 텍스트 덩어리는 텍스트 노드를 갖게 되는 데이터 표현을 가리킨다. DOM 트리의 Root(최상위) 노드는 documentElement(<html> 태그)이다.
  • 브라우저는 CSS 코드를 파싱하여 그곳에 존재하는 각종 코드들, 그리고 -moz, -webkit 등이 붙은 여러 확장 기능들을 읽어들이며, 인식할 수 없는 것은 단호하게 무시한다. 스타일 정보는 하위에 종속된다. 즉, 기본 규칙은 User Agent Stylesheet에 존재하고(브라우저의 기본값), 그 다음에는 사용자의 스타일시트, (페이지의) 작성자의 스타일시트, 외부에서 가져온 것, 인라인 삽입된 것, HTML style 태그에 특성으로 삽입된 것 순이다.
  • 그 다음으로 흥미로운 부분이 시작되는데, 렌더 트리를 구축한다. 렌더 트리는 DOM 트리와 유사한 것이지만, 완벽하게 대응하지는 않는다. 렌더 트리는 스타일을 이해하므로, 만약 div 요소를 display: none 등을 사용하여 숨긴다면, 이것은 렌더 트리에 나타나지 않을 것이다. head와 같은 그 외의 다른 보이지 않는 요소들도 마찬가지이다. 반면, 렌더 노드에 반드시 한개 이상의 노드와 함께 표현되는 DOM 요소가 존재한다. 예를 들어 텍스트 노드의 경우,

    안의 각 줄에는 렌더 노드가 있어야 한다. 렌더 트리 내의 노드는 프레임(frame) 또는 박스(box)라고 부른다(CSS의 박스 모델에서 CSS 박스라고 부르는 식이다). 각 노드는 CSS 박스 속성 - width, height, border, margin 등을 가진다.

  • 렌더 트리가 구축되고 나면, 브라우저는 이제 렌더 트리를 화면에 그릴 수(paint; draw) 있게 된다.

숲과 나무

아래의 예시를 보자.

<html>
<head>
<title>Beautiful page</title>
</head>
<body>

<p>
Once upon a time there was
a looong paragraph...
</p>

<div style="display: none">
Secret message
</div>

<div><img src="..." /></div>
...

</body>
</html>

DOM 트리는 각 태그마다 한개의 노드를 가지고, 각 노드에 존재하는 텍스트마다 한개의 텍스트 노드를 가진다. (상황을 간단히 하기 위하여 공백 또한 텍스트 노드라는 사실은 무시하자)

documentElement (html)
head
title
body
p
[text node]

div
[text node]

div
img

...

렌더 트리는 DOM 트리의 시각적인 부분을 담당한다. head와 숨겨진 div와 같은 일부 요소는 생략되지만, 텍스트로 이루어진 라인의 경우 (프레임 또는 박스로 불리는) 추가 노드가 생겨난다.

root (RenderView)
body
p
line 1
line 2
line 3
...

div
img

...

렌더 트리의 최상위 노드는 다른 모든 요소들을 포함하는 프레임이다. 페이지가 펼쳐져 나가는 제한 영역인 셈이기 때문에, 브라우저 창의 내부로 간주해도 된다. 기술적으로 WebKit는 최상위 노드를 RenderView라고 부르며, 이는 CSS의 initial containing block에 대응하여, 페이지의 최상단인 (0,0)부터 (window.innerWidth, window.innerHeight)에 이르는 Viewport 사각형에 해당한다.

화면에 무엇을 어떻게 출력하는지는 렌더 트리를 재귀적으로 위에서부터 읽어나가면서 알게 된다.

Repaint와 Reflow

애초에 빈 화면을 선호하는 것이 아니라고 한다면, 적어도 한번은 최초의 페이지 레이아웃을 구성하게 된다. 그 이후에는 렌더 트리 구축에 사용되는 입력 정보의 변화에 따라 아래의 경우 중 하나 이상의 결과가 생겨난다.

  1. 렌더 트리의 일부 (또는 전체)가 유효성 검사를 다시 받아야 하고, 노드의 크기들을 다시 계산해야 한다. 이 경우는 reflow, layout에 해당하는 경우다. 최초에 페이지 레이아웃을 구성할 때 적어도 한번의 reflow가 발생한다는 점을 기억하자.
  2. 화면의 일부가 갱신되어야 하는데, 그 이유는 노드의 기하학성 속성이 변하였거나, 스타일이 변하였기 때문(배경색의 변화 등)이다. 이러한 화면 갱신을 repaint, redraw라고 부른다.

Repaint와 Reflow는 비용이 많이 필요하며, 사용자 경험을 해치고 UI를 느리게 만들 수 있다.

Reflow와 Repaint를 발생시키는 것

렌더 트리를 구축하는 데에 필요한 입력 정보를 바꾸는 모든 것이 Repaint와 Reflow의 원인이 된다. 예를 들면 아래와 같은 것들이다.

  • DOM 노드를 더하거나, 제거하거나, 갱신하는 것
  • display:none을 사용하여 DOM 노드를 숨기는 것 (Reflow와 Repaint)
  • visibility: hidden을 사용하여 DOM 노드를 숨기는 것 (Repaint만. 기하학적 값이 변하지 않음.)
  • DOM 노드를 화면 상에서 이동시키거나 애니메이션을 추가하는 것
  • 스타일시트를 추가하거나, 스타일을 변경하는 것
  • 창 크기를 조절하거나, 글자 크기를 지우거나, 스크롤하는 것

예시를 보도록 하자.

var bstyle = document.body.style; // cache

bstyle.padding = "20px"; // reflow, repaint
bstyle.border = "10px solid red"; // another reflow and a repaint

bstyle.color = "blue"; // repaint only, no dimensions changed
bstyle.backgroundColor = "#fad"; // repaint

bstyle.fontSize = "2em"; // reflow, repaint

// new DOM element - reflow, repaint
document.body.appendChild(document.createTextNode('dude!'));

일부 Reflow는 다른 것들에 비교했을 때 아주 큰 비용이 든다. 렌더 트리를 떠올려 보자. body의 바로 밑에 자식으로 존재하는 요소를 건드린다면, 아마도 다른 노드들에 크게 영향을 미치지는 않을 것이다. 그렇지만 페이지의 가장 윗쪽에 존재하는 div를 움직이고 확장하여, 페이지의 나머지 것들을 밑으로 밀어낸다면 어떨까? 아주 큰 비용이 들 듯 하다.

브라우저는 똑똑하다

Reflow와 Repaint는 렌더 트리 변동에 대하여 비싼 작업이기 때문에, 브라우저는 부정적인 효과가 나타나지 않도록 노력한다. 첫번째 전략은, 단순히 그 작업을 하지 않는 것, 혹은 적어도 당장 지금 하지는 않는 것이다. 브라우저는 코드가 요청하는 변동 작업을 수행하기 위하여 큐를 만들고 일괄 작업을 수행한다. 이를 통하여 Reflow가 필요한 각각의 변동 작업을 하나로 합치고, 단 한번의 Reflow만이 계산될 것이다. 이제 브라우저는 합쳐진 변동 작업을 큐에 추가하고, 일정 시간이 지나거나 큐가 일정 수준 이상으로 채워지면 큐에 쌓여있던 작업들을 한번에 수행하게 된다.

하지만 때로는 스크립트가 Reflow를 최적화하려는 브라우저를 막고, 큐를 비우고 일괄 변동 작업을 수행하도록 만드는 경우가 있다. 이런 경우는 당신이 아래와 같은 스타일 정보를 요청했을 때 발생한다.

  1. offsetTop, offsetLeft, offsetWidth, offsetHeight
  2. scrollTop, scrollLeft, scrollWidth, scrollHeight
  3. clientTop, clientLeft, clientWidth, clientHeight
  4. getComputedStyle(), IE의 경우 currentStyle

위의 것들은 기본적으로 노드에 대한 스타일 정보를 요청하는 것으로, 이것이 발생할 때마다 브라우저는 가장 최신값을 당신에게 주어야 한다. 따라서 계획된 변화를 모두 적용하고, 큐를 비운 뒤 어쩔 수 없이 Reflow를 수행해야만 하는 것이다.

예를 들어 아래와 같이, 스타일값을 부여한 뒤 연이어서 바로 스타일값을 취하는 것(반복문 안에서)은 좋지 않다.

// 좋지 않다!
el.style.left = el.offsetLeft + 10 + "px";

Repaint와 Reflow를 최소화하자

Reflow와 Repaint가 UX에 미치는 부정적인 효과를 최소화하는 전략은 아주 단순한데, Reflow와 Repaint를 보다 적게 수행하고, 스타일 정보를 보다 적게 요청하는 것이다. 그래야 브라우저가 Reflow를 최적화할 수 있기 때문이다. 그러려면 어떻게 해야 할까?

  • 각각의 스타일을 하나하나씩 따로 바꾸지 말자. 스타일 자체를 바꾸는 것이 아니라, 클래스 이름을 바꾸는 것이 안정성과 유지보수성을 위하여 가장 좋다. 그런데 이것은 정적 스타일을 가정하는 것이다. 만약 동적 스타일을 사용해야 한다면, cssText 속성을 수정하자. 무언가 바뀔 때마다 요소와 요소의 스타일 속성을 직접 바꾸는 것은 지양해야 한다.
// bad
var left = 10,
top = 10;
el.style.left = left + "px";
el.style.top = top + "px";

// better
el.className += " theclassname";

// or when top and left are calculated dynamically...

// better
el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
  • DOM 변동을 일괄적으로 수행하고, "오프라인"으로 수행하라. 오프라인이란 실제 DOM 트리를 직접 바꾸지 말라는 의미이다. 선택지는 다음과 같다.
    • 임시 변경점을 보관하기 위하여 documentFragment을 사용한다.
    • 갱신할 노드를 복사하여 (메모리 상에서)복사본에 작업을 수행한 뒤, 원본과 교체한다.
    • display: none을 사용하여 요소를 숨기고(1 Reflow, 1 Repaint), 100개의 변동 사항을 적용한 뒤, 다시 표시시킨다(또 한번의 Reflow, Repaint). 이렇게 하면 잠재적인 100번의 Reflow를 2번으로 줄일 수 있다.
  • 계산된 스타일값을 과도하게 사용하지 말자. 계산된 값이 필요하다면, 한번만 값을 얻어낸 뒤, 지역 변수로 캐싱하고 이를 활용하자. 위의 좋지 않은 예시를 다시 보자.
// no-no!
for(big; loop; here) {
el.style.left = el.offsetLeft + 10 + "px";
el.style.top = el.offsetTop + 10 + "px";
}

// better
var left = el.offsetLeft,
top = el.offsetTop
esty = el.style;
for(big; loop; here) {
left += 10;
top += 10;
esty.left = left + "px";
esty.top = top + "px";
}
  • 일반적으로, 변동이 발생한 뒤 스타일 재계산을 필요로 하는 요소가 얼마나 되는지 떠올려보면 된다. 예를 들어, 어떤 요소에 대하여 absolute positioning을 사용할 경우 이 요소는 렌더 트리 내에서 body의 자식이 된다. 따라서 이 요소에 애니메이션을 넣는 등의 작업을 하더라도 영향을 받는 다른 노드들은 별로 많지 않다. 이 요소가 다른 요소의 바로 위(on top)에 위치한다면 다른 요소들이 Repaint할 필요는 있겠으나, Reflow할 필요는 없다.

도구

1년 전만 하더라도 Paint와 Render 등이 브라우저 내에서 어떻게 발생하고 있는지 알 수 있는 방법이 전혀 없었다(물론, MS가 만들고 아무도 쓰지 않는 요상한 개발 도구가 MSDN 어딘가에 묻혀있을 것이다 :p). 하지만 이제 모든게 달라졌다.

우선, MozAfterPaint 이벤트가 Firefox 나이틀리 버전에 추가되었고, Kyle Scholz가 제작한 이런 확장 프로그램도 등장했다. MozAfterPaint는 괜찮은 기능이지만, 단지 Repaint에 대한 정보만을 제공한다. (역자 주: Kyle Scholz가 만든 확장 프로그램은 검색이 되지 않아 확인할 수 없는 상태)

DynaTrace Ajax와 구글의 SpeedTracer는 Reflow와 Repaint를 탐구할 수 있는 완벽한 도구이다. 전자는 IE, 후자는 WebKit을 위한 도구이다.
(역자 주: 두 링크 모두 관련없는 다른 링크로 대체되었다)

작년 언젠가(역자 주: 글이 작성된 시점 기준으로 2008년), Doublas Crockford는 이런 말을 했다. "우리가 잘 알지 못하지만, 우리는 아마도 CSS를 가지고 아주 멍청한 짓을 하고 있는 지도 모른다." 나는 분명히 이게 무슨 말인지 안다. 내가 참여했던 한 프로젝트에서는 브라우저의 글자 크기를 키우자 CPU 점유율이 100%로 올라간 상태로 유지되어 10-15분 정도 이어지다가 Repaint가 완료된 뒤에야 멈췄다.

이제는 좋은 도구도 갖췄으니, CSS로 멍청한 짓을 계속할 이유가 없다.

다만, 도구 이야기가 나와서 말이지만, Firebug와 같은 도구가 DOM 트리뿐 아니라 렌더 트리도 볼 수 있도록 해주면 좋지 않을까?

마지막 예제

도구를 간단하게 사용해보며, Restyle, Reflow, Repaint의 차이점을 알아보자.

  • Restyle: 기하학적 값을 바꾸지 않는 렌더 트리의 변화
  • Reflow: 레이아웃에 영향을 끼치는 변화
  • Repaint: ?

동일한 결과를 만드는 두개 방법을 비교해보자. 우선, 레이아웃을 건드리지 않고서 스타일을 바꾸고, 매 변화마다 스타일 속성을 체크할 것이다.

// (1) 스타일 변경 후, 바로 스타일 속성 확인
bodystyle.color = 'red';
tmp = computed.backgroundColor;
bodystyle.color = 'white';
tmp = computed.backgroundImage;
bodystyle.color = 'green';
tmp = computed.backgroundAttachment;

두번째로, 동일한 작업을 하되, 이번에는 모든 변화가 발생한 후에 스타일 속성 정보를 얻어볼 것이다.

// (2) 스타일 변경 후, 한꺼번에 스타일 속성 확인
bodystyle.color = 'yellow';
bodystyle.color = 'pink';
bodystyle.color = 'blue';

tmp = computed.backgroundColor;
tmp = computed.backgroundImage;
tmp = computed.backgroundAttachment;

두 경우 모두에 적용되는 변수 정의는 아래와 같다.

var bodystyle = document.body.style;
var computed;
if (document.body.currentStyle) {
computed = document.body.currentStyle;
} else {
computed = document.defaultView.getComputedStyle(document.body, '');
}

이제 문서 화면을 클릭할 때마다, 두 개 예제에 따라 차례차례 스타일 변화가 발생할 것이다. 이를 위한 테스트 페이지를 링크에서 확인해볼 수 있다("dude"를 클릭하면 된다). 이것을 restyle test라고 부르자.

두번째 테스트는 앞의 것과 동일하지만, 이번에는 레이아웃 정보 또한 변경할 것이다.

// 스타일 변경 + 레이아웃 변경
// touch styles every time
bodystyle.color = 'red';
bodystyle.padding = '1px';
tmp = computed.backgroundColor;
bodystyle.color = 'white';
bodystyle.padding = '2px';
tmp = computed.backgroundImage;
bodystyle.color = 'green';
bodystyle.padding = '3px';
tmp = computed.backgroundAttachment;

// touch at the end
bodystyle.color = 'yellow';
bodystyle.padding = '4px';
bodystyle.color = 'pink';
bodystyle.padding = '5px';
bodystyle.color = 'blue';
bodystyle.padding = '6px';
tmp = computed.backgroundColor;
tmp = computed.backgroundImage;
tmp = computed.backgroundAttachment;

이 테스트는 레이아웃을 변경하므로, relayout test라고 부르자. 테스트 페이지는 링크에서 확인해볼 수 있다.

restyle test를 수행했을 때의 결과를 DynaTrace를 통하여 시각화한 것은 아래와 같다.


기본적으로 우선 페이지가 로드되고, 그 다음 첫번째 시나리오(스타일 변경 후 바로 속성값 확인, 2초 경과)를 수행했고, 그 다음으로 두번째 시나리오(스타일 변경이 모두 이루어진 뒤 한번에 속성값 확인, 4초 경과)를 수행했다.

도구를 보면, 페이지가 로드되고 IE 로고가 로드되는 것을 볼 수 있다. 그 다음, 마우스 커서가 위치한 곳에서는 클릭 동작에 뒤이어 Rendering activity를 확인할 수 있다. 이곳을 줌으로 땡겨보면 더 자세히 볼 수 있다.

여기서 볼 수 있듯, 파란 막대로 된 자바스크립트의 동작, 그리고 뒤이어서 녹색 막대로 된 렌더링 동작을 확인할 수 있다. 여기서 사용한 것은 단순한 예제임에도 불구하고, 각 막대의 길이를 볼 수 있듯 자바스크립트 동작보다 렌더링 작업에 필요한 시간이 훨씬 길다. Ajax/Rich 어플리케이션에서 자바스크립트는 병목의 원인이 아니며, 오히려 DOM 접근과 조작, 렌더링 부분이 병목을 유발한다.

이제 relayout test를 수행하여 body의 기하학적 수치를 바꿔보자. PurePaths 화면을 주목하자. 여기에는 타임라인과 각 타임라인의 항목별 정보가 표시된다. 첫번째 클릭이 이루어지고, 자바스크립트에 의하여 예정된 레이아웃 작업이 동작하는 상황을 주목하였다.

여기서도 다시한번 줌을 땡겨보면, drawing에 앞서서 calculating flow layout가 추가된 것을 확인할 수 있다. 앞서 relayout test에는 Repaint와 더불어 Reflow도 추가하였기 때문이다.
이번에는 동일한 화면을 Chrome과 SpeedTracer 결과로 확인해보자.
아래 사진은 restyle test를 수행한 뒤의 결과를 한눈에 본 것이다.

전체적으로 보면 클릭 이벤트가 발생한 뒤 Paint가 발생했다. 그런데 첫번째 클릭에 보면, 50%의 시간이 Recalculating style에 사용되었다. 어째서일까? 우리가 매 변화마다 스타일 정보를 요청했기 때문이다.
이벤트를 확장하여 숨겨진 라인까지 들여다보면 무슨 일이 벌어진지 확인할 수 있다(회색선은 느리지 않아서 SpeedTracer가 숨긴 것이다). 첫번째 클릭 이후, 스타일은 총 3번 계산되었다. 두번째 클릭 이후에는, 오직 1번 계산되었다.

이제 relayout test를 수행해보자. 한눈에 본 결과 리스트는 같다.

하지만 자세히보게 되면, 첫번째 클릭은 3번의 Reflow를 발생시키지만(계산된 스타일 정보를 요청했기 때문), 두번째 클릭에서는 오직 1번의 Reflow만 발생한 것을 확인할 수 있다. 아래 사진을 보면 무슨 일이 벌어진 것인지 확실히 알 수 있다.

(확장 프로그램과 관련된 내용은 번역하지 않았습니다)


충분한 반복을 통한 테스트가 말해주는 점 몇 가지가 있다.

  • Chrome에서는 스타일을 수정하는 사이사이 스타일값의 계산 및 요청을 하지 않는 것이 스타일을 변경할 때는 2.5배 빠르고, 스타일과 레이아웃을 바꿀 때에는 4.42배 빠르다.
  • Firefox에서는 위의 경우 각각 1.87배, 1.64배 빠르다.
  • IE6, IE8에서는 성능에 차이가 없다.

모든 브라우저 공통적으로, 스타일만 변경하는 것은 스타일과 레이아웃을 동시에 변경하는 것보다 절반의 시간만 들었다. IE6의 경우, 레이아웃만 변경하는 것이 스타일만 변경하는 것보다 4배의 시간이 더 들었다.

마치며

긴 글을 읽어주느라 고생했다. 마지막으로 개념들을 정리하고 마치도록 하겠다.

  • 렌더 트리(Render tree): DOM 트리에서 시각적인 부분을 담당
  • Frame, box: 렌더 트리 내에서 노드를 부르는 말
  • Reflow: 렌더 트리의 구성 요소를 다시 계산하는 것. Firefox 이외의 브라우저는 layout이라고 부른다.
  • Repaint: 다시 계산된 렌더 트리를 기반으로 화면을 갱신하는 것. Firefox 이외의 브라우저는 redraw라고 부른다.
  • 스타일만 변경하는 것 vs 스타일과 레이아웃을 함께 변경하는 것

덧글

댓글 입력 영역