웹 페이지를 렌더링하기 위해서는 DOM과 CSS가 필요하다. 그러나 다양한 기능과 효과를 구현하기 위해서 자바스크립트를 많이 사용하기 때문에, 자바스크립트가 렌더링 성능에 어떤 영향을 주는지 잘 알아야 한다. 또한 자바스크립트는 브라우저에서 단일 스레드로 동작하기 때문에 자바스크립트의 실행 시간은 곧 렌더링 성능과 직결된다. 렌더링은 자바스크립트의 실행 시간과 자바스크립트로 인한 DOM, CSS 변경을 다시 화면에 그리는 시간을 모두 포함한다. 렌더링 성능 최적화는 이러한 소요 시간을 단축하고 화면에 끊김 없이 그리는 것이다. 이번에는 브라우저 렌더링 과정에서 어떤 부분이 성능에 영향을 주고, 특히 자바스크립트에서 실행되는 일련의 코드가 렌더링 성능에 어떠한 영향과 최적화할 방법을 알아본다.
렌더링 과정에서 레이아웃은 DOM 요소들이 화면에 어느 위치에 어떤 크기로 배치될지를 결정하게 되는 계산 과정이다. 자바스크립트 코드를 통해 DOM을 변경하거나 스타일을 변경할 경우, 아래 그림같이 변경된 스타일을 반영하고 다시 레이아웃을 해야만 화면에 렌더링할 수 있다. 특히 레이아웃은 글자의 크기를 일일이 계산하고 요소 간 관계를 모두 파악해야 하는 과정이므로 시간이 오래 걸린다.
레이아웃 최적화의 목표는 자바스크립트 실행 과정과 렌더링이 다시 일어나는 과정에서 레이아웃에 걸리는 시간을 단축하고 레이아웃이 최대한 발생하지 않도록 하는 것이다. 레이아웃을 최대한 적게하고 리페인트만 할 수 있도록, 자바스크립트와 HTML, CSS 측면에서 최적화 방법을 하나씩 알아본다.
자바스크립트 실행 시간이 긴 경우, 한 프레임 처리가 오래 걸려 렌더링 성능이 떨어진다. 많은 작업을 수행할 때 자바스크립트 실행 시간은 당연히 오래 걸린다. 그러나 코드가 단순하더라도 불필요한 레이아웃으로 인해 실행 시간이 오래 걸릴 수 있으므로 성능 저하의 원인을 잘 파악해야 한다. 또한 레이아웃을 줄일 수 있도록 DOM 및 스타일 변경을 최소화해야 한다.
DOM의 속성을 변경하면 화면 업데이트를 위해 레이아웃이 일어날 수 있다. 원래 레이아웃은 비동기이나 특정 상황에서 동기적으로 레이아웃이 발생할 수 있다. 특정 속성을 읽을 때 최신 값을 계산하기 위해 레이아웃이 동기적으로 발생하며 이를 강제 동기 레이아웃이라고 한다. 강제 동기 레이아웃은 자바스크립트 실행 시간을 늘어나게 하므로 신경 써야 한다. 강제 동기 레이아웃이 일어나는 경우와 개선 방법은 다음과 같다.
스타일을 변경한 다음 offsetHeight, offsetTop과 같은 계산된 값을 속성으로 읽을 때 강제로 동기 레이아웃을 수행해야 한다.
const tabBtn = document.getElementById('tab_btn');
tabBtn.style.fontSize = '24px';
console.log(testBlock.offsetTop); // offsetTop 호출 직전 브라우저 내부에서는 동기 레이아웃이 발생한다.
tabBtn.style.margin = '10px';
// 레이아웃
계산된 값을 반환하기 전에 변경된 스타일이 계산 결과에 적용되어 있지 않으면 변경 이전 값을 반환하기 때문에 브라우저는 동기로 레이아웃을 해야만 한다. 최신 브라우저에도 동일하게 발생하는 부분이므로 강제 동기 레이아웃을 발생할 수 있는 코드를 최대한 사용하지 않도록 주의해야 한다.
한 프레임 내에서 강제 동기 레이아웃이 연속적으로 발생하면 성능이 더욱 저하된다. 다음 코드에서는 paragraphs[i] 요소를 순회하면서 각 요소의 너비를 box 요소의 너비와 일치하도록 설정한다. 반복문 안에서 style.width를 설정하고 box.offsetWidth를 읽어오면 for문이 반복 실행될 때마다 레이아웃이 발생한다. 이것을 레이아웃 스래싱이라고 한다. 반복문 밖에서 box 엘리먼트의 너비를 읽어오면 레이아웃 스래싱을 막을 수 있다.
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';
}
}
// 레이아웃 스래싱을 개선한 코드
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';
}
}
DOM을 변경하면 스타일 계산, 레이아웃, 페인트 과정이 모두 필요하며, 조작이나 스타일 변경을 하는 DOM이 상위에 있을수록 한 프레임에 드는 시간이 길어진다.
DOM과 스타일을 변경하면 레이아웃 과정에서 주변의 엘리먼트도 영향을 받아 다시 레이아웃을 해야 하는 경우가 있다.
엘리먼트가 display: none 스타일을 가지고 있으면 DOM 조작과 스타일을 변경하더라도 레이아웃과 리페인트가 발생하지 않는다. 많은 수의 엘리먼트를 변경해야 할 경우 숨겨진 상태에서 엘리먼트를 변경하고 다시 보이도록 하여 레이아웃 발생을 최대한 줄인다. visibility: hidden은 보이지 않아 리페인트는 발생하지 않지만, 공간을 차지하기 때문에 레이아웃은 발생하게 된다.
화면을 렌더링하기 위해서 필요한 데이터는 HTML과 CSS로, 각각 DOM트리와 CSSOM 트리를 만들고 렌더링할 때 사용된다. DOM트리와 CSSOM 트리를 변경하면 렌더링을 유발하고 트리가 클수록 더 많은 계산이 필요하다. 그러므로 HTML과 CSS를 최적화하여 렌더링 성능을 향상할수 있다.
엘리먼트의 클래스를 변경하면 렌더링이 발생하는데, CSS가 복잡하고 많을수록 스타일 계산과 레이아웃이 오래 걸린다.
DOM 트리가 깊을수록, 하나의 노드에 자식 노드가 많을수록 DOM 트리는 커진다. 그만큼 DOM을 변경했을 때 업데이트에 필요한 계산은 많아진다.
한 프레임 처리가 16ms(60fps) 내로 완료되어야 렌더링 시 끊기는 현상 없이 자연스러운 렌더링을 만들어낼 수 있다. 자바스크립트 실행 시간은 10ms 이내에 수행되어야 레이아웃, 페인트 등의 과정을 포함했을 때 16ms 이내에 프레임이 완료될 수 있다. 애니메이션을 구현할 때 네이티브 자바스크립트 API를 사용하는 것보다 CSS 사용을 권장한다.
requestAnimationFrame API를 사용하면 브라우저의 프레임 속도(보통 60fps)에 맞추어 애니메이션을 실행할 수 있도록 해준다. 특히 setInterval, setTimeout과 달리 프레임을 시작할 때 호출되기 때문에 일정한 간격으로 애니메이션을 수행할 수 있는 장점이 있다. 또한 현재 페이지가 보이지 않을 때는 콜백함수가 호출되지 않기 때문에 불필요한 동작을 하지 않는다.
function animate() {
// 애니메이션 처리 프레임 코드
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
자바스크립트를 사용한 애니메이션은 성능이 나쁠 수 있다. CSS3 애니메이션을 사용하면 자바스크립트를 실행할 필요가 없고, 브라우저가 애니메이션을 처리하는데 최적화되어 있어서 부드러운 애니메이션을 구현할 수 있다. CSS 애니메이션을 구현할 때는 다음 사항을 지켜서 사용한다.
애니메이션 영역이 주변 영역에 영향을 주지 않도록 주의해야 한다. position을 absolute나 fixed로 설정하면 주변 레이아웃에 영향을 주지 않는다.
스타일 속성 중 position, width, height 등과 같이 기하적 변화를 유발하는 속성을 변경하면 레이아웃이 발생한다. transform을 사용한 엘리먼트는 레이어로 분리되기 때문에 영향받는 엘리먼트가 제한되어 레이아웃과 페인트를 줄일 수 있다. 그리고 합성만 발생시키기 때문에 애니메이션에서 사용 시 렌더링 속도가 향상할 수 있다. 때에 따라 하드웨어가 지원될 경우 GPU를 사용할 수 있으므로 성능이 빠르다. 예를 들어 left, top을 사용하면 모든 프레임마다 엘리먼트와 배경이 합성되어 많은 시간이 걸리므로, transform: translate()를 사용해야 한다.
body {
background-color: lime;
}
.animation-item {
position: absolute; /* good */
top: 0;
left: 0;
width: 50px;
height: 50px;
background-color: red;
animation: move 3s ease infinite;
}
/* bad */
@keyframes move {
50% {
top: 100px;
left: 100px;
}
}
/* good */
@keyframes move {
50% {
transform: translate(100px, 100px);
}
}