렌더링 최적화는 왜 하는걸까?
브라우저 최적화를 진행하는 이유는 사용자 경험이다. 웹 애플리케이션에도 첫인상이라는게 존재한다. 웹의 첫인상은 사이트의 디자인, 적재적소의 UI 애니메이션, 헤드 카피 등의 시각적인 요소일 수도 있다.
시각적인 측면도 웹의 첫인상일 수 있지만, 응답성 측면의 첫인상도 있다. 페이지 로딩이 길어서 사용자가 3초 이상 흰 화면만 보게 된다면, 좋지 않은 첫인상을 갖게 된다.
렌더링 최적화 설명 이전에 브라우저 렌더링을 한번 더 이해하자.
다음은 렌더링 엔진의 기본적인 동작 과정이다.
DOM 트리 구축을 위한 HTML 파싱 → 렌더 트리 구축 → 렌더 트리 배치(레이아웃) → 렌더 트리 그리기(페인트)
레이아웃 단계에서는 노드의 정확한 위치와 크기를 계산
노드의 정확한 크기와 위치를 파악하기 위해 루트부터 노드를 순회하면서 계산하고, 레이아웃 결과로 각 노드의 정확한 위치와 크기를 픽셀 값으로 렌더 트리에 반영한다. 이 과정은 HTML의 루트 오브젝트로부터 재귀적으로 실행 됨.
아래는 레이아웃 전/후 과정을 보여준다. 만약 CSS에서 크기 값을 %로 지정하였다면, 레이아웃 단계를 거친 후 % 값은 계산되고 측정 가능한 픽셀 단위로 변환된다.
레이아웃 전
레이아웃 후
레이아웃 계산이 완료되면 이제 요소들을 실제 화면을 그린다.
레이아웃 단계를 통해 화면에 배치된 엘리먼트들에게 색을 입히고 레이어의 위치를 결정하는 단계. 이 단계 역시 루트 오브젝트로부터 재귀적으로 실행이 됩니다.
요소들의 위치와 크기, 스타일 계산이 완료된 렌더 트리를 이용해 실제 픽셀 값을 채워 넣습니다. 이때 픽셀로 변환된 결과는 하나의 레이어가 아니라 여러 개의 레이어로 관리된다.
텍스트, 색, 이미지, 그림자 효과 등이 모두 처리되어 그려지는데, 스타일이 복잡할수록 페인트 시간도 늘어난다. 예를 들어, 단색 배경의 경우 시간과 작업이 적게 필요하지만, 그림자 효과는 시간과 작업이 더 많이 필요하게 된다.
페인트 단계에서 생성된 레이어를 합성하여 스크린을 업데이트
합성 단계가 끝나면 화면에서 웹 페이지를 볼 수 있다.
브라우저 로딩 과정 중 스타일 이후의 과정(스타일 -> 레이아웃 -> 페인트 -> 합성)을 렌더링이라고 하는데, 이 렌더링 과정은 상황에 따라 반복하여 발생할 수 있다.
이 과정의 단계에 따라 리플로우, 리페인트라고 부르는데 자세히 알아보자.
Reflow = Layout
스타일 단계에서 구성되는 렌더 트리는 자바스크립트에 의해 DOM 트리, CSSOM 트리가 변경될 때 다시 재구성된다. DOM이 추가/삭제되거나 요소에 기하적인 영향(높이, 넓이, 위치)을 주는 CSS 속성 값을 변경하는 경우, 렌더 트리가 다시 재구성되는 것이다.
const example = document.getElementById('example');
example.style.width = '400px';
즉, 레이아웃부터 이후 과정을 다시 수행하며 이것을 '리플로우' 라고 합다.
Repaint = Paint
리플로우는 요소에 기하적인 영향을 주는 CSS 속성 값을 변경할 때 발생한다고 했는데, 반대로 영향을 주지 않는 CSS 속성값을 변경하면 레이아웃 과정을 건너뛰게 된다.
const sample = document.getElementById('example');
example.style.backgroundColor = 'blue';
즉 background-color, visibility와 같이 레이아웃에는 영향을 주지 않는 스타일 속성이 변경되면, 이는 페인트부터 수행하기에 '리페인트' 라고 한다.
여기서 문제점은 리플로우와 리페인트는 시간이 오래 걸리는 작업이라는 것이다.
그렇기 때문에 브라우저는 렌더링 과정에서 성능을 제일 많이 잡아먹게된다.. (특히 리플로우가 순간적으로 많이 발생할 경우 치명적)
따라서, DOM을 조작할 때 리플로우와 리페인트가 최소한으로 발생하도록 해야 좋은 성능을 얻을 수 있다!
그럼 리플로우와 리페인트를 줄일 수 있는 방법을 포함하여 렌더링을 최적화하는 방법에 대해 알아보자.
<head />
에서 CSS 파일 로드
<head>
아래)에 배치합시다.</body>
직전에 자바스크립트 파일 로드
<script>
태그를 만나면 스크립트가 실행되며 그 이전까지 생성된 DOM에만 접근할 수 있습니다. 그리고 스크립트 실행이 완료될 때까지 DOM 트리 생성이 중단되어 버립니다.. 외부에서 가져오는 자바스크립트의 경우 또한, 모든 스크립트가 다운로드되고 실행될 때까지 DOM 트리 생성이 중단됩니다.</body>
직전)에 배치합시다.visibility: invisible;
은 레이아웃 공간을 차지하기 때문에 리플로우의 대상이 된다. 하지만 display: none;
은 레이아웃 공간을 차지하지 않아 렌더 트리에서 아예 제외된다.
브라우저가 더 렌더링을 빠르고 효율적으로 할 수 있게 개발하기 위해서는 Reflow과정을 최소화 시키는 것이 좋다. Reflow가 발생하면 필연적으로 Repaint가 일어나기 때문에 렌더링 최적화에 좋지 않은 영향을 준다.
그러므로 Reflow가 발생하는 속성보다 Repaint만 발생하는 속성을 사용하는 것이 좋다.
또한 리플로우, 리페인트가 일어나지 않는 transform, opacitiy 와 같은 속성도 있다. 따라서left, right, width, height 보다 transform을, visibility / display 보다 opacitiy를 사용하는 것이 성능 개선에 도움이 된다.
Reflow가 일어나는 대표적인 속성
position, width, height, left, top, right, bottom, margin, padding, border, border-width,
clear, display, float, font-family, font-size, font-weight, line-height, min-height,
overflow, text-align, vertical-align, white-space...
Repaint가 일어나는 대표적인 속성
background, background-image, background-position, background-repeat, background-size,
border-radius, border-style, box-shadow, color, line-style, outline, outline-color,
outline-style, outline-width, text-decoration, visibilty...
요소에 무엇인가 변화를 줄 때는 DOM에 달려있는 채로 조작하는 것보다는 DOM에서 떼어낸 채로 조작하는 것이 효과적이다.
const target = document.querySelector(".target");
const element = target.cloneNode(true);
...
target.replaceWith(element);
(위 예시에서 element을 target의 복사본으로 정의.)
이 복사본은 실제 DOM 요소와 똑같지만, DOM 트리와는 전혀 무관해지게 된다. 그렇기에 element를 여기저기 변경해도 실제 DOM에는 변경되는 게 없다.
따라서, 변경이 완료된 후에 실제 요소로 교체함으로써 리플로우를 최소화할 수 있다.
브라우저는 일반적으로 현재 작업이나 프레임이 끝날 때까지 기다린 후 리플로우를 계산하지만, 특정 기하학적인 속성 값을 읽으면 최신 값을 계산하기 위해 리플로우를 동기적으로 발생시킨다.
이를 '강제 동기 레이아웃' 이라고 한다.
강제 동기 레이아웃이 발생하면 리플로우 계산을 위해 메인 스레드가 블락되므로 성능에 치명적인 원인이 될 수 있다.
const tabBtn = document.getElementById('tab_btn');
tabBtn.style.fontSize = '24px';
console.log(testBlock.offsetTop);
// offsetTop 호출 직전 브라우저 내부에서는 동기 레이아웃이 발생
tabBtn.style.margin = '10px';
위 예시처럼 스타일을 변경한 다음 offsetTop과 같은 계산된 값을 속성으로 읽을 때 강제로 동기 레이아웃을 수행한다.
계산된 값을 반환하기 전에 변경된 스타일이 계산 결과에 적용되어 있지 않으면 변경 이전 값을 반환하기 때문에 브라우저는 동기로 레이아웃을 해야만 한다.
최신 브라우저에도 동일하게 발생하는 부분이므로 강제 동기 레이아웃을 발생할 수 있는 코드를 최대한 사용하지 않도록 주의해야 한다!
function resizeAllParagraphs() {
const box = document.getElementById('box');
const paragraphs = document.querySelectorAll('.paragraph');
for (let i = 0; i < paragraphs.length; i += 1) {
paragraphs[i].style.width = box.offsetWidth + 'px';
}
}
위 예시처럼 반복문 안에서style.width를 설정하고 offsetWidth를 읽어오면 for문이 반복 실행될 때마다 레이아웃이 발생하게 된다.
이와 같은 반복적인 리플로우로 인해 DOM에 반영하는 CPU 이용률이 높아지게 되며, 이를 '레이아웃 스레싱'이라고 한다.
이때, 아래 예시처럼 반복문 밖에서 엘리먼트의 너비를 읽어오면 레이아웃 스래싱을 막을 수 있다!
function resizeAllParagraphs() {
const box = document.getElementById('box');
const paragraphs = document.querySelectorAll('.paragraph');
const width = box.offsetWidth;
for (let i = 0; i < paragraphs.length; i += 1) {
paragraphs[i].style.width = width + 'px';
}
}
아래의 애니메이션 최적화 방법으로도 레이아웃 스레싱을 최적화할 수 있습니다.
애니메이션의 경우, 리페인트 과정이 끝나지도 않았는데 다음 좌표로 이동하라고 애니메이션을 수행하게 되면, 애니메이션이 의도한 대로 부드럽게 움직이지 않게 된다.
requestAnimationFrame 은 이러한 문제를 해결해준다 👍
function animate() {
// 애니메이션 처리 프레임 코드
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
requestAnimationFrame 메서드는 브라우저에게 수행하기를 원하는 애니메이션을 알리고 다음 리페인트가 진행되기 전에 해당 애니메이션을 업데이트하는 함수를 호출하게 한다. 이 메서드는 리페인트 이전에 실행할 콜백을 인자로 받는다.
requestAnimationFrame 메서드를 사용하는 이유는 다음과 같다.
- 브라우저가 레이아웃을 계산하는 것보다 더 자주 또는 덜 자주 호출 ❌ → 정확한 주기로 호출
- 브라우저가 레이아웃을 계산하기 바로 전에 호출 → 정확한 타이밍에 호출
- 브라우저의 프레임 속도(보통 60fps)에 맞추어 애니메이션을 실행
때문에, DOM을 읽는 로직은 현재 프레임에서 실행하고, DOM을 수정하기 위한 로직은requestAnimationFrame 메서드 와 함께 사용해 다음 프레임에서 함께 실행하도록 예약하여 레이아웃 스레싱을 줄일 수 있다.
또한, 현재 페이지가 보이지 않을 때는 콜백 함수가 호출되지 않기 때문에 불필요한 동작을 하지 않습니다. 등록된 콜백들을 리페인트 전에 한 번에 처리하기 때문에 리플로우를 최소화할 수 있게 된다.
따라서, 화면에 새로운 애니메이션을 업데이트할 준비가 될 때마다 requestAnimationFrame 메서드를 호출하는 것이 좋다!
JavaScript와 CSS를 조합해 애니메이션이나 레이아웃 변화가 많은 요소의 경우 position
을 absolute
또는 fixed
를 사용하면 영향을 받는 주변 노드들을 줄일 수 있다.
fixed
와 같이 영향을 받는 노드가 전혀 없는 경우 Reflow과정이 전혀 필요없어지기 때문에 Repaint연산비용만 들게 되어 효율적이다.
단순하게 생각하면 0.1초마다 1px
씩 이동하는 요소보다 0.3초마다 3px
씩 이동하는 요소가 Reflow 연산비용이 3배가 줄어든다고 볼 수 있다. 따라서 부드러운 효과를 조금 줄이면서 성능 개선을 할 수 있다.