Back
Featured image of post Critical Rendering Path와 렌더링 최적화 기법

Critical Rendering Path와 렌더링 최적화 기법

웹 브라우저가 화면을 그리기 위해 수행하는 과정과 최적화 기법을 소개합니다.

개요

01-crp
CRP (Critical Rendering Path)란 페이지 레이아웃을 생성하거나 변경할 때, 브라우저가 화면을 그리기 위해 수행하는 단계를 의미합니다. 더 세부적으로, Browser Rendering Pipeline이 존재하는데 이는 레이아웃 관점에서 콘텐츠들이 어떻게 표시되는지에 대한 일종의 파이프라인을 의미합니다.

페이지가 느려지면 최적화가 필요하고, 최적화를 수행하려면 레이아웃이 어떻게 그려지고 그 과정에서 어느정도의 비용이 발생되는지 알고 있어야 합니다. 페이지에 접근할 때 브라우저가 어떤 과정으로 사용자에게 표시하는지,사용할 수 있는 최적화 기법에 대해 알아보고자 합니다.

Browser Pipe Rendering

Step01. DOM, CSSOM 생성

먼저 서버로부터 HTML 리소스를 응답 받으면, HTML 파싱 알고리즘을 사용하여 구문을 분석하고 head 요소에서 CSS 리소스를 찾으면 해당 리소스의 존재를 확인하고 DocumentObjectModel(DOM) 트리를 생성한 후 CSS 구문 분석을 통해 CSSObjectModel(CSSOM) 트리를 생성합니다.

<html>
  <head>
    <link rel="stylesheet" type="text/css" href="style.css" />
  </head>
  <body>
    <p><span>Hello</span>World</p>
    <div>
      <img src="image.jpg" />
    </div>
  </body>
</html>

생성된 Document Object Model
02-dom

/** style.css*/
body {
  font-size: 16px;
}

p {
  font-weight: bold;
}

p span {
  display: none;
  color: #ededed;
}

p:before {
  content: 'Welcome';
}

img {
  float: left;
}

생성된 CSS Object Model
cssom

DOM과 CSSOM을 생성하여 렌더 트리까지 도달하는 단계는 다음과 같습니다.

  1. 렌더링 엔진이 HTML에서 원시 바이트를 읽고 개별 문자로 변환
  2. 문자열을 고유한 토큰으로 변환
  3. 토큰을 각 정의된 규칙을 갖는 노드 객체로 변환
  4. 각 노드의 부모-자식 관계를 가지는 트리구조를 생성 (DOM 트리)

Step02. 렌더트리 생성

render-tree

  • 가상 요소(pesudo-element)는 DOM에는 존재하지 않지만 렌더 트리에는 존재하게됩니다.
  • <head>, <title>,<scirpt>노드들은 렌더 트리에 포함시키지 않습니다.
  • display 속성 값이 none 인 요소들도 렌더 트리에 제외합니다.

이 단계에서는 요소 선택자 매칭을 수행하여 일치하는 선택자의 스타일 규칙 기반으로 최종 스타일을 결정합니다.

DOM, CSSOM이 생성되는 파이프라인 05-parsing

Step03. 스타일 계산

생성된 렌더트리를 빌드시키는 단계 입니다. 이 과정에서 리플로우(Reflow)리페인트(Repaint) 개념이 등장하는데 최적화를 위해서 꼭 알아야 할 개념이기도 합니다.

Layout

06-box-model 레이아웃 단계는 각 HTML 요소의 위치와 크기를 계산하는 과정입니다. 쉽게 말해 요소의 Box Model을 생성하는 단계라고 볼 수 있습니다.(em, rem, 백분율 등 상대적으로 선언한 측정 단위는 절대 단위로 변환됩니다.)
레이아웃 단계가 발생하는 것은 리플로우가 발생된다는 의미입니다. 자식 요소와 부모 요소까지 재 계산이 필요할 수 있기 때문에 비용이 상대적으로 리페인트 보다 많이 발생할 수 있습니다.

Paint

07-paint 페인트는 렌더 트리를 순회하면서 paint 함수를 호출해 시각적인 요소(color, shadow 등)를 실제 화면에 표현하는 단계입니다.

리플로우와 리페인트

리플로우는 변경이 필요한 렌더 트리에 대한 유효성 검사 및 노드의 수치를 다시 계산하는 과정입니다. 리페인트는 변경된 영역의 결과를 표현하기 위해 화면이 갱신되는 것을 의미합니다. 예를 들어 레이아웃 과정이 발생하거나, 단순한 스타일이 변경되는 경우가 있습니다.

  • 리플로우는 요소 위치와 크기가 변경되기 때문에, 경우에 따라 문서 전체까지 재계산이 필요할 수 있습니다
    • DOM 노드가 갱신될 때 (추가, 제거)
    • display 속성을 통해 요소를 숨기는 경우 (hide)
    • 폰트 크기의 변화, 브라우저 크기의 변화
  • 리페인트는 리플로우가 발생된 이후와 단순한 스타일이 변경될 때 발생합니다. 발생된 요소의 자식 요소까지 전파합니다.
    • visibility 속성을 통해 요소를 숨기는 경우 (hidden)

상대적으로 리플로우 과정이 비용이 많이 들기 때문에 리플로우 발생을 최소화하는 것이 중요합니다.

Step04. 레이어 합성(Composite Layers)

브라우저에서는 여러 layer가 존재합니다. 브라우저는 요소의 스타일을 분석하여 필요한 레이어의 수를 파악합니다. 그리고 페인팅 후 브라우저는 모든 레이어를 모아서 최종 화면으로 보여줍니다. 이 단계에서 요소들을 겹치지 않게끔 레이어를 형성하고, 분석한 스타일대로 픽셀을 그려넣는 과정이 이루어집니다. 레이어 합성은 아래 두 단계로 이루어져 있습니다.

  • UpdateLayerTree: 요소의 스타일을 분석하여 필요한 레이어의 개수 파악
  • Composite Layers: 페인트 단계 이후 파악된 모든 레이어를 합성후 화면에 표현 (복합 레이어)

최적화의 필요성

08-crp

이상적인 프레임

animation, hover 등 레이아웃에 시각적인 변화가 있을 경우 브라우저가 새 프레임을 렌더링합니다. 대부분의 디바이스는 초당 60회(60FPS) 새로고침을 수행하는 것이 부드러운 애니메이션 표현에 이상적입니다. 60 FPS를 유지하려면, 브라우저는 매 16ms(1000ms / 60)마다 한 번씩 새로운 화면을 그려야 합니다. 하지만 브라우저는 단순히 레이아웃만 그리는 역할만 수행하지는 않기에 몹시 제한적입니다.

따라서 웹 브라우저에서 사용자가 매끄러운 화면을 유지할 수 있도록 여러 최적화 기법을 통해 프레임을 유지시키는 것이 중요합니다.

간단한 선택자를 사용

<nav id="header">
	<ul class="sidebar">
		<il class="list_item">Item</li>
		...
	</ul>
</nav>
.list_item {
}
nav#hader ul.sidebar li {
}

CSS 구문 분석은 오른쪽에서 왼쪽 요소로 탐색합니다. 따라서 선택자 구성이 복잡할수록 모든 요소를 찾고 상위 요소를 탐색하는 과정을 수행합니다. class 선택자 등을 사용하여 간단하게 구성할수록 DOM에서 노드를 탐색하는 이동 과정을 줄일 수 있습니다.

리플로우 최소화

CSS에서는 리페인트가 발생하는 속성과 리플로우가 발생하는 속성으로 나누어집니다. 예를 들어, 아래 스크린샷에서는 속성에 따른 리페인트 횟수의 차이가 나타납니다. left 속성을 변경하면 페이지에서 다른 요소의 위치나 크기에 영향을 미치기 때문에 이 과정에서 리플로우가 발생됩니다. 반면에 transform 속성은 레이아웃 계산 작업이 수행되지 않기 때문에 불필요한 리플로우가 발생하지 않습니다.

left 속성의 paint 횟수(96회) left-paint left-paint-data

translate 속성의 paint 횟수(24회) translate-paint translate-paint-data

<body>
  <button class="toggle-sidebar">Sidebar</button>
  <nav class="sidebar">
    <ul>
      <li><a href="#">List1</a></li>
      <li><a href="#">List2</a></li>
      <li><a href="#">List3</a></li>
    </ul>
  </nav>
</body>
<script>
  const toggleButton = document.querySelector('.toggle-sidebar');
  const sidebar = document.querySelector('.sidebar');

  toggleButton.addEventListener('click', () => {
    sidebar.classList.toggle('open');
  });
</script>
/*❎ Bad*/

.sidebar {
  left: -450px;
  width: 450px;
  position: absolute;
  transition: 1s;
}

.sidebar.open {
  left: 0;
}

/*✅ Good*/
.sidebar {
  left: -450px;
  width: 450px;
  transform: translateX(0);
  position: absolute;
  transition: 1s;
}

.sidebar.open {
  transform: translateX(450px);
}

리플로우 발생 속성

  • left, right, top, bottom
  • padding, margin, width, height

리페인트 발생 속성

  • background-color
  • color
  • visibility
  • shadow

비슷한 속성에서 대안적인 방법이 있다면, 리플로우를 최소화하는 속성을 사용하는 것이 성능에 좋은 영향을 미칠 수 있습니다. 더 많은 속성들을 확인해보고 싶으시다면 csstrigers.com 사이트에서 확인하실 수 있습니다.

레이어 분리

will-change 속성을 사용하면, 특정 요소가 변경될 가능성이 있을 때, 브라우저에게 미리 알려줄 수 있습니다. 내부적으로 해당 요소를 새로운 레이어로 분리하는 작업이 이루어지며, 복잡한 애니메이션을 표시하는 요소를 레이어로 분리하면 해당 레이어만 재연산하므로 성능적으로 이점이 있습니다.

.sidebar {
  left: -450px;
  width: 450px;
  transform: translateX(0);
  position: absolute;
  transition: 1s;
  will-change: transform;
}

will-change 속성을 활용한 paint 최적화
12-will-change-data
12-will-change

⚠️ 주의사항
모든 요소에 will-change 속성을 부여하면 브라우저가 모든 요소의 레이어 관리를 하게 되며, 이는 레이어 합성 단계에서 많은 비용을 발생시킬 수 있으므로 사용에 주의가 필요합니다. 최적화에 필요한 요소에만 속성을 부여하는 것이 좋습니다.

will-change: auto;
will-change: scroll-position;
will-change: contents;
will-change: transform;
will-change: top, left;

노출 제어 최소화

자바스크립트를 사용하여 노드를 복제하는 방법도 있습니다. 복제된 노드를 수정하면 해당 노드가 DOM 트리에 추가되지 않으므로 리플로우나 리페인트가 발생하지 않습니다.

const element = document.getElementById('box');
const clone = element.cloneNode(true); //원본 노드를 복제
const parentNode = document.getElementById('parent');

for (let index = 0; index < 400; index++) {
  clone.style.width = index + 'px';
}

parentNode.replaceChild(clone, element); //기존 노드 대치

Request Animation Frame

이 API는 자바스크립트 실행을 예약하는 데 사용됩니다. 이를 통해 프레임이 시작될 때 자바스크립트 코드가 실행되도록 보장할 수 있습니다. 브라우저에게 미리 수행하려는 애니메이션을 알리면 브라우저가 스타일 계산, 레이아웃, 페인트, 레이어 합성을 실행할 수 있는 충분한 시간을 확보할 수 있습니다.

브라우저에게 수행하기를 원하는 애니메이션을 알리고 다음 리페인트가 진행되기 전에 해당 애니메이션을 업데이트하는 함수를 호출하게 합니다. 이 함수는 리페인트 이전에 실행할 콜백을 인자로 받습니다.

// Sidebar DOM 객체를 가져옵니다.
const sidebar = document.querySelector('.sidebar');

function openSidebar() {
  // 현재 시간을 가져옵니다. (애니메이션의 시작 시간)
  const start = performance.now();
  // 애니메이션의 전체 지속 시간을 설정합니다. (ms)
  const duration = 1000;
  // Sidebar가 이동해야 하는 최종 위치를 설정합니다. (픽셀)
  const endPosition = 450;

  // 애니메이션을 수행하는 함수입니다.
  function animate(currentTime) {
    // 현재 시간과 시작 시간의 차이를 계산하여 애니메이션이 얼마나 진행되었는지 확인합니다.
    const elapsedTime = currentTime - start;
    // 애니메이션의 진행률을 계산합니다. (0부터 1까지)
    const progress = Math.min(elapsedTime / duration, 1);
    // Sidebar가 이동해야 하는 새 위치를 계산합니다.
    const newPosition = endPosition * progress;

    // Sidebar를 새 위치로 이동시킵니다. (레이아웃 변경)
    sidebar.style.transform = `translateX(${newPosition}px)`;

    // 애니메이션 진행률이 1 미만이면, 즉 애니메이션이 아직 끝나지 않았으면 다음 프레임을 요청합니다.
    if (progress < 1) {
      requestAnimationFrame(animate);
    }
  }

  // 첫 번째 프레임을 요청하여 애니메이션을 시작합니다.
  requestAnimationFrame(animate);
}

마치며

이번 포스팅에서는 브라우저가 화면 요소를 그리는 과정에서 발생하는 비용과 최적화 기법들에 대해 알아보았습니다. 소개한 최적화 기법은 대중화되어 있지만, 다양한 최적화 기법들이 존재합니다. 중요한 점은 리플로우와 리페인트가 무엇이며 왜 발생하는지, 레이아웃→페인트→레이어 합성 단계를 이해해야 앞으로 등장할 많은 최적화 기법들을 이해하는 데 큰 도움이 될 것 같습니다. 이상으로 글을 읽어주셔서 감사합니다!

참고 자료