- 해당 md는 여기의 글을 재구성한 것입니다.
렌더링 최적화
픽셀 파이프라인

위 그림은 작업 시 유의해야하는 5가지 주요 영역이며, 픽셀 - 화면 파이프라인의 핵심 요소이다.
- JS / CSS : JS 및 CSS를 통해 이루어지는 시각적 변화의 트리거를 가리킨다.
- Style :
.headline
과 같은 선택자에 따라 어떤 CSS 규칙을 어떤 요소에 적용할지 계산하는 프로세스이다. - Layout : 브라우저가 각 요소에 어떤 규칙을 적용할 지 알고난 후, 실제로 어디에, 어느 정도의 공간을 차지하며 위치할지를 계산하는 과정. 한 요소가 다른 요소에 영향을 줄 수 있기 때문에 해당 과정이 필요하다.
- Paint : 실제로 화면의 픽셀을 채우는 과정. 텍스트 / 색 / 경계 및 그림자 등 요소의 모든 시각적 부분을 그려낸다. 일반적으로 레이어라고 하는 여러 개의 표면에서 수행된다.
- Composition : 페이지의 각 부분들이 여러 레이어를 통해 그려졌기 때문에, 페이지가 정확히 렌더링되기 위해 정확한 순서대로 화면에 그려내는 과정.
JS / CSS를 통해 시각적인 변경이 이루어졌을 때, 파이프라인이 동작하는 세가지 형태가 존재한다.
1. JS / CSS -> Style -> Layout -> Paint -> Composition

- 너비 / 높이 / 위치 등 요소의 기하학적 형태에 영향을 주는 Layout 속성들을 변경하면 브라우저가 다른 요소들을 확인하고 페이지에 대해 리플로우(Reflow) 작업을 수행해야 한다. 이후 영향을 받은 영역이 있다면 다시 페인트해야 하고, 최종적으로 페인트한 요소는 다시 합성이 이루어져야 한다.
2. JS / CSS -> Style -> Paint -> Composition

- 페이지의 레이아웃에 영향을 주지 않는 배경 이미지, 텍스트 색상 또는 그림자 등 Paint Only 속성을 변경하면, 브라우저가 레이아웃 작업을 건너뛰고 페인트 작업부터 수행한다.
3. JS / CSS -> Style -> Composition

- 레이아웃과 페인트 모두 필요없는 속성을 변경하게 되면 브라우저가 바로 합성 단계로 건너뛴다.
각 속성을 변경함에 있어 위 중 어떤 과정을 거치게 되는지에 대해 알고 싶다면 CSS Triggers를 참조하자.
아래부터는 파이프라인의 각 부분에 있어서 발생할 수 있는 일반적인 문제와 그 진단 / 해결방법에 대해 살펴보자.
JS 실행 최적화
실행 타이밍이 안좋거나, 실행 시간이 긴 JS는 렌더링 성능에 영향을 미칠 수 있다.
시각적 업데이트에 setTimeout 또는 setInterval을 피하고 대신 항상 requestAnimationFrame을 사용하라.
setTimeout
에 의해 특정 시점에 콜백이 실행되는 경우, 종종 프레임이 누락되어 버벅거리는 현상이 발생할 수 있다. requestAnimationFrame
을 이용한 방법은 JS가 프레임 시작 시에 실행되도록 보장한다.

/**
* If run as a requestAnimationFrame callback, this
* will be run at the start of the frame.
*/
function updateScreen(time) {
// Make visual updates here.
}
requestAnimationFrame(updateScreen);
메인 스레드를 벗어나 오래 실행되는 자바스크립트를 Web Workers로 이전하라.
원하는 작업에 DOM 액세스가 필요하지 않은 경우에는 Web Worker의 사용을 고려해볼 수 있다. 정렬 / 검색 또는 순회(traversal)는 대개 이 모델에 적합하며, 로드 및 모델 생성도 마찬가지다.
const dataSortWorker = new Worker('sort-worker.js');
dataSortWorker.postMesssage(dataToSort);
// The main thread is now free to continue working on other things...
dataSortWorker.addEventListener('message', function (evt) {
const sortedData = evt.data;
// Update data on screen...
});
마이크로 작업을 사용하여 여러 프레임을 통해 DOM을 변경하라.
단, 반대로 말해서 DOM 액세스를 요구하는 작업의 경우 이런 방식이 적합하지 않다. 이와 같이 작업이 메인 스레드에 있어야 한다면, 큰 작업을 몇 개의 마이크로 작업으로 세분화하여, 각각의 프레임에서 requestAnimationFrame
핸들러를 통해 실행하는 방식을 고려해볼 수 있다.
const var taskList = breakBigTaskIntoMicroTasks(monsterTaskList);
requestAnimationFrame(processTaskList);
function processTaskList(taskStartTime) {
const taskFinishTime;
do {
// Assume the next task is pushed onto a stack.
const nextTask = taskList.pop();
// Process nextTask.
processTask(nextTask);
// Go again if there’s enough time to do the next task.
taskFinishTime = window.performance.now();
} while (taskFinishTime - taskStartTime < 3);
if (taskList.length > 0) requestAnimationFrame(processTaskList);
}
이러한 접근 방식을 활용하는 경우, UX/UI를 통해 특정 작업을 계속 수행하고 있음을 이용자에게 나타내는 것이 중요하다. 또한 앱의 메인 스레드를 계속해서 사용 가능한 상태를 유지하여 사용자의 상호작용에 계속 반응할 수 있도록 해야한다.
Chrome DevTools의 Timeline 및 자바스크립트 프로파일러를 사용하여 자바스크립트의 영향을 평가한다.
프레임별로 JS 코드의 실행 비용을 평가하는 것 역시 중요한데, 이는 특히 트랜지션이나 스크롤처럼 성능이 중요한 애니메이션 작업 시에 더욱 중요하다.
JS 비용 및 성능 프로필을 측정하기 위한 가장 좋은 방법은 DevTools를 사용하는 것이다. (Timeline, Profiler)

JS 미세 최적화(Micro Optimization)를 피하라.
offsetTop
이 getBoundingClientRect()
계산보다 빠른 것처럼, 브라우저는 일부 작업을 다른 작업보다 100배 가까이 빨리 처리할 수 있다. 하지만 실제로 함수 호출 시의 프레임 당 시간은 거의 항상 짧기 때문에, JS의 성능적인 측면에 중점을 두는 것은 일반적으로 시간 낭비에 가깝다. 이러한 노고를 통해 절약되는 시간이 거의 밀리초의 일부에 불과하기 때문이다. 단, 게임이나 컴퓨팅 비용이 비싼 앱의 경우엔 예외인데, 일반적으로 많은 계산이 단일 프레임에 적용되고, 이 경우에는 모든 것이 도움이 되기 때문이다. 거꾸로 말하면, 그렇지 않은 경우(게임 등을 개발하는 것이 아닌 경우)에는 적절하지 않으므로 피해야 한다.
Style 계산의 스코프 / 복잡성 최적화
요소의 스타일링 규칙을 정하는 단계에서, 더 간단한 규칙을 지닌 더 작은 트리가 큰 트리나 복잡한 규칙보다 더 효율적으로 처리된다.
다음의 각각은 동일한 요소를 대상으로 하기 위해 지정한 선택자지만, 브라우저가 이를 계산하는데에 드는 시간 비용에는 차이가 생긴다.
.box:nth-last-child(-n + 1) .title {
/* styles */
}
.final-box-title {
/* styles */
}
BEM과 같은 CSS 아키텍처 역시 이러한 선택기 매칭의 성능 이점에서 구현된다.
.list { }
.list__list-item { }
.list__list-item--last-child {}
레이아웃 최적화
레이아웃은 브라우저가 요소의 기하학적인 정보를 파악하는 장소이며, 각 요소는 사용한 CSS, 요소의 컨텐츠 또는 상위 요소에 따라 명시적 / 암시적인 크기 지정 정보를 갖게된다. 해당 프로세스를 Chrome, Opera, Safari 및 IE에서는 레이아웃이라고 하며, Firefox에서는 리플로우(Reflow)라고 한다.
레이아웃의 범위는 거의 항상 전체 문서로 지정된다.
요소가 많은 경우 모든 요소의 위치와 크기를 파악하는데 오랜 시간이 걸린다. 레이아웃을 피할 수 없는 경우, DevTools의 Timeline을 통해 해당 레이아웃에 시간이 얼마나 걸리는지에 대한 파악이 필요하다.

위의 예에서는 레이아웃 내부에서 20ms 이상 소요된 것을 확인할 수 있는데, 애니메이션 화면에서 프레임당 16ms가 필요한 경우 이에 비해 훨씬 높은 값이다. 또한 트리 크기(위에서는 1,618 요소) 및 레이아웃에 필요한 노드 수도 확인할 수 있다.
Flexbox는 동일한 수의 요소에 대해 레이아웃 시간을 훨씬 덜 소요한다.
브라우저에 따라 Flexbox를 지원하지 않는 경우도 있겠지만.. 결국 Flexbox의 사용 여부 이전에 레이아웃 트리거 자체를 완전히 피하려고 노력하는 것이 좋다. 아래는 float를 사용하는 레이아웃과 flex를 사용한 레이아웃 간의 처리시간 차이를 나타내는 결과다.


강제 동기식 레이아웃을 피하라
화면에 프레임을 추가하는 순서는 다음과 같다.

JS를 실행한 후 -> 스타일 계산을 수행한 후에 -> 레이아웃을 실행한다.
하지만, JS를 사용해 브라우저가 레이아웃을 더 일찍 수행하도록 하는 것도 가능한데, 이를 **강제 동기식 레이아웃(forced synchronous layouts)**이라고 한다.
JS가 실행될 때, 이전 프레임의 모든 레이아웃 값은 알려져 있고, 이를 쿼리에 사용할 수 있다. 이를테면 프레임 시작 시 요소의 높이를 기록하려면 다음과 같이 작성할 수 있다.
// Schedule our function to run at the start of the frame.
requestAnimationFrame(logBoxHeight);
function logBoxHeight() {
// Gets the height of the box in pixels and logs it out.
console.log(box.offsetHeight);
}
헌데, 높이를 요청하기 전에 스타일을 먼저 변경한 경우 문제가 발생할 수 있다.
function logBoxHeight() {
box.classList.add('super-big');
// Gets the height of the box in pixels
// and logs it out.
console.log(box.offsetHeight);
}
이 경우, 정확한 높이를 구하기 위해 브라우저는 먼저 스타일을 변경한 후(super-big
이 클래스에 추가되었기 때문에), 레이아웃을 실행해야 한다. 이는 불필요하고, 비용도 많이 드는 작업이다.
때문에, 항상 스타일 읽기를 일괄적으로 처리하여 먼저 수행한 다음, 스타일 쓰기를 작성해야 한다.
결국, 위의 코드를 올바르게 수정하자면 아래와 같아진다.
function logBoxHeight() {
// Gets the height of the box in pixels
// and logs it out.
console.log(box.offsetHeight);
box.classList.add('super-big');
}
대부분의 경우 스타일을 적용한 다음에 그 값을 쿼리할 필요가 없다. 이전의 프레임 값을 사용하면 충분하기 때문이다. 브라우저가 원하는 시간보다 일찍 스타일 계산과 레이아웃을 동시에 실행하지 않도록 하자.
레이아웃 스래싱을 피하라
많은 레이아웃을 연속적으로 빠르게 실행한다면 강제 동기식 레이아웃이 더 악화된다.
function resizeAllParagraphsToMatchBlockWidth() {
// Puts the browser into a read-write-read-write cycle.
for (let i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = box.offsetWidth + 'px';
}
}
위 코드는 매 루프마다 스타일 값(box.offsetWidth
)을 읽은 다음 이 값을 즉시 사용해 너비(paragraphs[i].style.width
)를 업데이트한다.
스타일링의 변경을 일으킨 직후에 box.offsetWidth
를 요구하였기 때문에, 이 시점에서 강제 동기식 레이아웃이 발생한다.
이 경우, 바로 루프의 바로 다음부터 시작해 매 반복마다 스타일이 변경되었음을 확인하고, 이에 따라 스타일 변경을 적용하고, 레이아웃을 실행하게 된다.
이를 수정하려면, 기존 프레임의 하나의 값을 읽은 다음 계속해서 사용해야 한다.
// Read.
const width = box.offsetWidth;
function resizeAllParagraphsToMatchBlockWidth() {
for (let i = 0; i < paragraphs.length; i++) {
// Now write.
paragraphs[i].style.width = width + 'px';
}
}
이런 레이아웃 스레싱(Layout thrashing)을 없애기 위해 fastDOM이라는 라이브러리도 존재한다.
페인트 최적화
페인트 과정은 최종적으로 사용자의 화면에 픽셀을 채우는 과정이며, 파이프라인의 모든 작업 중 대체로 실행시간이 가장 긴 작업이기 때문에 가급적 피해야 한다.
언제 페인팅이 이루어지는가??
페인트가 트리거되는 경우는 다음의 두가지이다.

- 레이아웃이 트리거되면, 항상 페인트 역시 트리거된다.

- 레이아웃이 필요없는 비기하학적 속성(배경 / 텍스트 색상, 그림자)을 변경하는 경우에도 페인트가 트리거된다.
이동되거나 페이드되는 요소를 승격(Promote)해라
페인트가 항상 메모리 상에 단일 이미지로 수행되는 것은 아닌데, 실제로 필요에 따라서 브라우저가 다중 이미지 혹은 컴포지터 레이어로 페인트를 할 수 있다.

이러한 접근방식의 이점은, 정기적으로 페인트하거나 변형에 의해 화면에서 움직이는 요소를 다른 요소에 영향을 주지 않으면서 처리할 수 있다는 것이다.
이는 Photoshop과 같은 툴에서 볼 수 있는 레이어의 개념과 유사한데, 최상위에서 개별 레이어를 처리 / 합성하여 최종적인 이미지를 생성할 수 있다.
새로운 레이어를 생성하는 가장 좋은 방법은 will-change
CSS 속성을 사용하는 것이다. 이는 Chrome, Opera 및 Firefox에서 작동하며, transform
값으로 새 컴포지터 레이어를 생성한다.
.moving-element {
will-change: transform;
}
Safari처럼 will-change
를 지원하지는 않으나, 레이어 생성은 활용하는 브라우저의 경우 3D 변형을 사용해 새 레이어를 강제적으로 적용해야 한다.
.moving-element {
transform: translateZ(0);
}
단, 각 레이어는 메모리와 관리가 요구되기 때문에 너무 많은 레이어를 생성하는 것은 오히려 독이 될 수 있다. 또한 요소를 새 레이어로 승격시키는 경우, 이렇게 하는 것이 성능 상으로 이점이 있는지부터 먼저 확인해야 한다.
페인트 영역을 줄여라
앞선 설명처럼 요소를 승격시켰음에도 불구하고 페인팅 작업이 여전히 요구되는 경우가 있다. 페인트의 커다란 문제점은 브라우저가 페인팅이 필요한 두 영역을 합치고 나면, 전체 스크린에 대해 다시 페인팅 작업을 수행할 수도 있다는 점이다. 예를 들어, 페이지 상단에 고정된(fixed) 헤더를 갖고 있더라도, 스크린 아래쪽에서 페인팅이 이루어진다면 그냥 스크린 전체가 리페인팅될 수 있다.
참고: 높은 DPI를 가진 디바이스의 경우
fixed
position을 가진 요소는 자동으로 컴포지터 레이어(Compositor layer)로 승격된다. 반면, 낮은 DPI를 가진 경우는 해당하지 않는데, 이 경우 승격은 텍스트 렌더링을 서브픽셀(subfixel)에서 그레이스케일로 변경하고, 레이어 승격은 수동적으로 이루어져야 하기 때문이다.
페인트 영역을 줄이는 것은 일반적으로 다음과 같은 방법을 통해 이루어질 수 있다.
- 애니메이션과 트랜지션이 가능한 겹치지 않도록 조율하는 것
- 한 페이지의 특정 부분에 애니메이션을 적용하는 것을 피하는 것
페인트 복잡성 단순화

페인트는 작업에 따라 그 비용에 차이가 있다. 다만, 이 기준이 CSS 관점에서 항상 명확한 것은 아니다. 페인트 프로파일러를 사용하면 현재 페인트 작업에 어느 정도의 비용이 드는지를 확인할 수 있고, 이를 대체하기 위한 스타일링에 대해 생각해볼 수 있다.
프레임 당 10ms는 일반적으로, 특히 모바일 디바이스에서는 페인팅 작업을 수행할 만큼 긴 시간이 아니다. 때문에 애니메이션 도중에는 항상 페인팅 작업을 피하기를 원할 수 있다.
합성 (Composition) 최적화
합성은 화면에 표시하기 위해 페이지에서 페인트된 부분들을 합치는 과정이다. 이 영역에서 페이지 성능에 영향을 주는 두 가지 핵심 요소가 있다.
- 관리가 필요한 컴포지터 레이어 수
- 애니메이션에 사용하는 속성
애니메이션에 변형(transform
) 또는 불투명도(opacity
) 변경을 사용하라
앞서 레이아웃과 페인트를 모두 피하고 합성에 대한 변경만 요구하는 픽셀 파이프라인이 최고의 성능을 제공한다고 살펴봤었다.

이를 위해서는 컴포지터가 혼자서 처리할 수 있는 변경 속성을 사용해야 하는데, 현재로서 이에 해당하는 것은 transform
과 opacity
두 가지 속성 뿐이다.

해당 속성들을 사용할 시에 주의할 점은 이러한 속성을 변경하는 요소가 자체적인 컴포지터 레이어에 있어야 하는데, 즉, 레이어를 만들기 위해 요소를 승격해야 한다.
참고 : 애니메이션을 이 속성들로만 제한할 수 없을 것 같다고 생각되면 FLIP 원칙을 참조하라. 이는 비용이 많이 드는 속성을
transform
과opacity
를 이용한 방법으로 다시 작성하도록 도와준다.
애니메이션 적용 요소를 승격해라
위의 페인트 최적화 섹션에서 언급한 것처럼, 애니메이션 적용 요소에 대해 자체 레이어로 승격해야 한다.
.moving-element {
will-change: transform;
}
또는 이전 브라우저나 will-change
를 지원하지 않는 브라우저에 대해서는 다음을 사용한다.
.moving-element {
transform: translateZ(0);
}
레이어 관리 및 레이어 급증 피하기 : 요소를 불필요하게 레이어 승격하지 마라
레이어 승격이 성능 개선에 도움이 된다고 해서 페이지 모든 요소를 승격시켜버리는 것이 자칫 이상적으로 들릴지 모른다.
* {
will-change: transform;
transform: translateZ(0);
}
이 경우의 문제는, 생성하는 모든 레이어가 메모리 및 관리가 요구되며, 이는 공짜로 생겨나는 것이 아니라는 점이다. 결국, 무작정 레이어를 생성하는 경우 오히려 안하느니만 못하다.
입력 핸들러 디바운싱
입력 핸들러는 프레임 완성을 차단시킬 수 있기 때문에 불필요한 추가 레이아웃 작업을 유발할 수 있다.
오래 걸리는 입력 핸들러를 피하라
가장 빠른 경우의 예시를 먼저 들자면, 이용자가 페이지와 상호작용할 때, 페이지의 컴포지터 쓰레드가 이용자의 터치 입력을 감지하고, 컨텐츠를 단순히 이동시킨다. 이 경우, 메인 쓰레드에서 요구되는 동작(JS, 레이아웃, 스타일, 페인트)이 없다.

그런데, 만약 touchstart
, touchmove
, touchend
와 같은 입력 핸들러를 추가한다면, 컴포지터 쓰레드는 해당 핸들러의 처리과정이 끝날때까지 기다려야 한다.
왜냐하면 preventDefault()
가 호출될지도 모르기 때문인데, 만약 호출되었다면 컴포지터 쓰레드는 기본 스크롤 동작을 멈춰야만 한다.
심지어 preventDefault()
를 호출하지 않았더라도, 컴포지터는 기다려야만 한다. 이처럼 컴포지터가 기다리는 동안에 이용자의 스크롤 동작을 막게 되며, 이에 따라 버벅거리거나 프레임이 손실되는 결과가 나타난다.

쉽게 말해, 입력 핸들러는 빠르게 처리되어야 한다. 그래야 컴포지터가 원래 해야하는 일을 할 수 있으니까.
입력 핸들러 내에서의 스타일 변경을 피하라
스크롤과 터치 같은 입력 핸들러들은 requestAnimationFrame
콜백 이전에 실행되도록 되어있다.
만약 이러한 핸들러 내부에서 스타일 변경을 시도한다면, requestAnimationFrame
이 시작될 때 스타일 변경이 보류된다.
만약 requestAnimationFrame
콜백이 시작할 때 스타일 정보들을 읽어오고자 한다면, 위쪽에서 언급했던 **강제 동기 레이아웃(Forced synchronous layout)**이 발생한다.

스크롤 핸들러 디바운스
위의 두가지 문제(입력 핸들러 간소화 + 핸들러 내 스타일 변경 회피)를 해결하는 방법은 동일한데, 시각적 변경에 대해 항상 다음 requestAnimationFrame
콜백으로 디바운스 하는 것이다.
function onScroll(evt) {
// Store the scroll value for laterz.
lastScrollY = window.scrollY;
// Prevent multiple rAF callbacks.
if (scheduledAnimationFrame) return;
scheduledAnimationFrame = true;
requestAnimationFrame(readAndUpdatePage);
}
window.addEventListener('scroll', onScroll);
이 경우, 컴퓨팅 비용이 많이 드는 코드에서도 스크롤이나 터치를 차단하지 않으므로 입력 핸들러를 가볍게 유지할 수 있다는 이점이 있다.