본문 바로가기

FrontEnd

[번역] layout 성능 정확하게 측정하기

반응형

해당 글을 의역한 글입니다. https://nolanlawson.com/2018/09/25/accurately-measuring-layout-on-the-web/

 

Accurately measuring layout on the web

Update (August 2019): the technique described below, in particular how to schedule an event to fire after style/layout calculations are complete, is now captured in a web API proposal called reques…

nolanlawson.com

2019년에 WebKit은 Chrome 및 Firefox에 맞게 requestAnimationFrame 구현을 업데이트했습니다
(예: 다음 프레임 전에 렌더링 rendering before the next frame).
브라우저 렌더링 파이프라인은 복잡합니다.
이러한 이유로 웹 페이지의 성능을 측정하는 것은 까다롭습니다.
 
특히 컴포넌트가 클라이언트 측에서 렌더링되고
모든 것이 JavaScript, DOM, 스타일 지정, 레이아웃 및 렌더링 간에 복잡하게 얽혀 있는 경우에는 더욱 그렇습니다.
많은 사람들이 자신이 이해하는 바를 고수하므로 웹사이트의 프런트엔드 성능을 과소 측정하거나 완전히 잘못 측정할 수 있습니다.

따라서 이 게시물에서는 이러한 개념 중 일부를 설명하고
웹 렌더링 진행 상황을 정확하게 측정하는 기술을 제공하고자 합니다.


The web rendering pipeline

JavaScript를 사용하여 클라이언트 측에서 렌더링되는 컴포넌트가 있다고 가정해 보겠습니다.
단순함을 유지하기 위해 바닐라 JS로 데모 컴포넌트(a demo component)를 작성했지만
내가 말하려는 모든 내용은 React, Vue, Angular 등에 적용됩니다.
 
Chrome Dev Tools의 편리한 성능 프로파일러를 사용하면 다음과 같은 내용이 표시됩니다.

Chrome Dev Tools의 편리한 성능 프로파일러 기능 활용 이미지

밀리초 단위로 UI 스레드 내 컴포넌트 렌더링의 CPU 비용을 확인할 수 있습니다.
문제를 해결하기 위해 필요한 단계는 다음과 같습니다.

  1. Execute JavaScript – 상태 조작, "가상 DOM 디핑" 및 DOM 수정을 포함하여 JavaScript 실행(반드시 컴파일할 필요는 없음).
  2. Calculate Style – CSS 스타일시트를 가져오고 해당 선택기 규칙을 DOM의 요소와 일치시킵니다. 이를 "포맷팅(formatting)"이라고도 합니다.
  3. Calculate Layout – 2단계에서 계산한 CSS 스타일을 가져와 화면에서 상자를 배치해야 하는 위치를 파악합니다. 이를 "리플로우(reflow)"라고도 합니다.
  4. Render – 실제로 화면에 픽셀을 배치하는 프로세스입니다.
    • 여기에는 종종 페인팅, 합성(composite), GPU 가속 및 렌더링 스레드 분리(a separate rendering thread)를 포함합니다.

이러한 모든 단계는 CPU 비용을 유발하므로 모든 단계가 사용자 경험에 영향을 미칠 수 있습니다.
그 중 하나라도 시간이 오래 걸리면 컴포넌트 로딩이 지연될 수 있습니다.


The naïve approach(단순 접근법)

사람들이 레이아웃 프로세스를 측정하려고 할 때 저지르는 가장 일반적인 실수는 2, 3, 4단계를 완전히 건너뛰는 것입니다.
즉, JavaScript를 실행하는 데 소요된 시간만 측정하고 그 이후의 모든 것은 완전히 무시합니다. 

레이아웃 프로세스를 측정할때 사람들이 주로 실수하는 영역
브라우저 성능 엔지니어로 일할 때 나는 종종 팀 웹 사이트의 흔적을 보고 "완료"를 측정하기 위해 어떤 표시를 사용했는지 묻곤 했습니다. 대부분의 경우, 그들의 점수는 JavaScript 실행 직후, 스타일과 레이아웃 이전을 기반으로 하였습니다.
즉, CPU 작업의 마지막 부분이 측정되지 않았다는 의미입니다.
 
그렇다면 이러한 비용을 어떻게 측정할까요?
이 게시물의 목적을 위해 스타일과 레이아웃을 측정하는 방법에 초점을 맞추겠습니다.
렌더 단계는 측정하기 훨씬 더 복잡하고 실제로 정확하게 측정하는 것은 불가능합니다.
렌더링은 종종 메인 스레드가 아닌 별도의 스레드와 GPU 사이의 복잡한 상호 작용을 포함하며,
이는 메인 스레드를 사용하는 JS에서 접근할 수 없는 영역입니다.
 
그러나 스타일 및 레이아웃 계산은 메인 스레드를 차단하기 때문에 100% 측정 가능합니다.
그리고 예, 이것은 Firefox의 Stylo 엔진(Stylo engine)과 같은 것에서도 마찬가지입니다.
작업 속도를 높이기 위해 여러 스레드를 사용할 수 있더라도
궁극적으로 메인 스레드는 최종 결과를 제공하기 위해 다른 모든 스레드를 기다려야 합니다.
이것은 사양(as specc’ed)에 따라 웹이 작동하는 방식입니다.

What to measure(무엇을 측정해야 하는가?)

실용적으로 우리는

JavaScript 실행을 시작하기 전에 성능 지표 마킹(performance mark)을 하고

모든 추가 작업이 완료된 후에 또 다른 성능 지표를 마킹하려고 합니다.

성능 지표를 마킹하는 위치

이전에 웹의 다양한 JavaScript 타이머에 대해 기사를 쓴 적(various JavaScript timers on the web)이 있습니다.
이들 중 어떤 것이 우리를 도울 수 있습니까?

결과적으로 requestAnimationFrame이 우리의 주요 선택 도구가 될 것이지만 문제가 있습니다.

Jake Archibald가 이벤트 루프에 대한 훌륭한 강연(his excellent talk on the event loop)에서 설명했듯이

각 브라우저는 이 콜백을 실행하는 위치가 다릅니다.

(주 : 이젠 아님)

브라우저 버전에 따른 raf 콜백 실행 위치
HTML5 이벤트 루프 사양(the HTML5 event loop spec)에 따라
requestAnimationFrame은 실제로 스타일과 레이아웃이 계산되기 전에 실행되어야 합니다.
Edge는 이미 v18에서 이 문제를 수정했으며 아마도 Safari도 향후 수정할 것입니다.
그러나 그렇게 하면 IE와 이전 버전의 Safari 및 Edge에서 여전히 일관성 없는 동작이 발생합니다.
 
또한 사양을 준수하는 동작은 실제로 스타일과 레이아웃을 측정하기 더 어렵게 만듭니다.
이상적인 세상에서 사양에는 두 개의 타이머가 있습니다.
하나는 requestAnimationFrame용이고 다른 하나는 requestAnimationFrameAfterStyleAndLayout(또는 이와 비슷한 것)용입니다. 사실 이를 위해 API를 추가하는 것(some discussion at the WHATWG)에 대해 WHATWG에서 약간의 논의가 있었으나,
지금까지는 사양 작성자의 눈에는 희미한 빛일 뿐입니다.
 
불행하게도 우리는 실제 제약이 있는 현실 세계에 살고 있으며 브라우저가 이 타이머를 추가하기를 기다릴 수 없습니다.
따라서 requestAnimationFrame이 실행되어야 하는 시점에 대해 브라우저가 동의하지 않는 경우에도
이 문제를 해결하는 방법을 알아내야 합니다. 
모든 브라우저에서 사용할 수 있는 솔루션이 있을까요?
 

Cross-browser “after frame” callback

스타일과 레이아웃 바로 뒤에 콜백을 배치하기 위한 완벽한 솔루션은 없지만
Todd Reifsteck(Todd Reifsteck)의 조언에 따르면 이것이 가장 가깝다고 생각합니다.

requestAnimationFrame(() => {
  setTimeout(() => {
    performance.mark('end')
  })
})

 

이 코드가 수행하는 작업을 분석해 보겠습니다.
Chrome과 같은 사양 준수 브라우저의 경우 다음과 같이 표시됩니다.

사양 준수 브라우저 마크

 

rAF는 스타일 및 레이아웃 전에 실행되지만 다음 setTimeout은 해당 단계(이 경우 "페인트" 포함) 직후에 실행됩니다.
Edge 17과 같이 사양을 준수하지 않는 브라우저에서 작동하는 방식은 다음과 같습니다.

사양 미준수 브라우저 마크

스타일 및 레이아웃 후에 rAF가 실행되고 다음 setTimeout이 너무 빨리 발생하여
Edge F12 도구가 실제로 두 마크를 겹처 렌더링합니다.

 

트릭은 rAF 내부에 setTimeout 콜백을 대기시키는 것입니다.
이렇게 하면 브라우저가 사양을 준수하는지 여부에 관계없이 두 번째 콜백이 스타일과 레이아웃 후에 발생합니다.


단점과 대안

이 기술에는 많은 문제가 있습니다.

  • setTimeout은 4ms(또는 경우에 따라 그 이상)로 클램프 될 수 있다는 점에서 다소 예측할 수 없습니다.(주 : 4ms 단위로 반올림)
  • 코드의 다른 곳에 대기 중인 다른 setTimeout 콜백이 있는 경우, 우리의 측정을 위한 콜백이 항상 마지막에 실행될 것이라는 보장이 없습니다.
  • 사양을 준수하지 않는 브라우저에서 setTimeout을 수행하는 것은 실제로 낭비입니다.
    • 마크를 설정하기에 완벽하게 좋은 장소인 rAF 내부가 있기 때문입니다.

하지만 그나마 rAF + setTimeout이 우리가 원하는 솔루션과 가장 가깝습니다.
몇 가지 대체 접근 방식과 이것들이 잘 작동하지 않는 이유를 고려해 보겠습니다.

rAF + microtask

requestAnimationFrame(() => {
  Promise.resolve().then(() => {
    performance.mark('after')
  })
})

 

자바스크립트 실행이 완료된 직후 마이크로태스크(예: Promise)가 실행되기 때문에 이것은 전혀 작동하지 않습니다.
따라서 스타일과 레이아웃을 전혀 기다리지 않습니다.

(주 : 브라우저는 콜 스택이 빌 때마다 마이크로태스크를 실행할 기회를 얻음)

microtasks fires before style/layout

rAF + requestIdleCallback

requestAnimationFrame(() => {
  requestIdleCallback(() => {
    performance.mark('after')
  })
})
requestAnimationFrame 내부에서 requestIdleCallback을 호출하면 실제로 스타일과 레이아웃이 캡처됩니다.

requestAnimationFrame 내부에서 requestIdleCallback을 호출하면 실제로 스타일과 레이아웃이 캡처됩니다.

그러나 마이크로태스크 버전이 너무 일찍 실행되는게 문제라면
이 버전은 너무 늦게 실행될 수 있습니다.
위의 스크린샷은 상당히 빠르게 실행되는 것을 보여주지만,
메인 스레드가 다른 작업을 하느라 바쁠 경우
rIC는 브라우저가 일부 "유휴" 작업을 실행하는 것이 안전하다고 결정할 때까지 오랜 시간 지연될 수 있습니다.
이는 setTimeout보다 정확하지 않을 수 있습니다.

rAF + rAF

requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    performance.mark('after')
  })
})
 
"double rAF"라고도 하는 이 솔루션은 완벽하게 훌륭한 솔루션이지만
setTimeout 버전과 비교하면,
setTimeout의 표준 4ms와 달리 60Hz 화면에서 약 16.7ms로 더 많은 유휴 시간을 캡처할 수 있습니다.
따라서 약간 더 부정확합니다.
두 rAF 간 간격
 
이전 블로그 게시물(a previous blog post)에서
setTimeout(0)이 실제로 0(또는 반드시 4)밀리초 내에 실행되지 않는 것에 대해 이미 이야기했습니다.
 
setTimeout()이 1초까지 클램프 될 수 있지만 이는 백그라운드 탭에서만 발생합니다. (this only occurs in a background tab)
백그라운드 탭에서 실행 중인 경우 rAF가 모두 일시 중지될 수 있기 때문에(it may be paused altogether) rAF에 전혀 의존할 수 없습니다.
(백그라운드 탭에서 시끄러운 원격 분석(noisy telemetry from background tabs)을 처리하는 방법은 흥미롭지만 별개의 질문입니다.)

따라서 결함에도 불구하고 rAF+setTimeout이 여전히 rAF+rAF보다 더 나을 것입니다.


스스로를 속이지 말자

어쨌든 rAF+setTimeout 또는 double rAF를 선택하든
이벤트 루프 기반 스타일 및 레이아웃 비용을 캡처하고 있음을 확신할 수 있습니다.
이 방법을 사용하면 JavaScript 및 직접적인 DOM API 호출 성능만 측정하여 스스로를 속일 가능성이 훨씬 줄어듭니다.
 
예를 들어 스타일 및 레이아웃 비용이 이벤트 루프에 의해 호출되지 않는 경우
즉, 컴포넌트가 getBoundingClientRect(), offsetTop 등와 같이 스타일/레이아웃 재계산을 강제하는 많은 AP(APIs that force style/layout recalculation)I 중 하나를 호출하는 경우 어떤 일이 발생할지 생각해 보겠습니다. 
 
getBoundingClientRect()를 한 번만 호출하면 스타일 및 레이아웃 계산이 JavaScript 실행 중간으로 넘어가는 것을 알 수 있습니다.
스타일, 레이아웃 비용이 이전에 처리됨
 
여기서 중요한 점은 이 것을 했다고 브라우저의  렌더링 사이클이 느려지거나 빨라지지 않았다는 점입니다.
단지 비용이 발생하는 시점을 이동했을 뿐입니다.
하지만 스타일과 레이아웃의 전체 비용을 측정하지 않으면
getBoundingClientRect()를 호출하는 것이 호출하지 않는 것보다 느리다고 스스로를 속일 수 있습니다!
하지만 이는 저축계좌를 위해 생활비 계좌에서 돈을 빼서 이동하는 것에 불과합니다.
 
Chrome 개발자 도구가 스타일/레이아웃 계산에 "강제 리플로우는 성능 병목 현상일 수 있습니다."라는 메시지와 함께
작은 빨간색 삼각형을 추가했다는 점은 주목할 가치가 있습니다.
이것은 이 경우 약간 오해의 소지가 있을 수 있습니다.
비용이 실제로 더 높지 않고 추적에서 더 일찍 이동했기 때문입니다.
 
getBoundingClientRect()를 반복적으로 호출하고 프로세스에서 DOM을 변경하면 레이아웃 스래싱(layout thrashing)이 발생할 수 있으며,이 경우 전체 비용이 실제로 더 높아질 수 있습니다.
따라서 이 경우Chrome 개발자 도구는 사람들에게 경고하는 것이 옳습니다.
 
어쨌든 내 요점은 명시적인 JavaScript 실행 비용만 측정하고
이후에 발생하는 이벤트 루프 기반 스타일 및 레이아웃 비용을 무시한다면 자신을 속이기 쉽다는 것입니다.
두 비용은 다르게 스켸줄 될 수 있지만 둘 다 성능에 영향을 미칩니다.

결론

웹에서 레이아웃을 정확하게 측정하는 것은 어렵습니다.
스타일과 레이아웃(실제로는 렌더링)을 캡처하는 완벽한 지표는 없습니다.
세 가지 모두 JavaScript만큼이나 사용자 경험에 영향을 미칠 수 있습니다.
HTML5 이벤트 루프가 작동하는 방식을 이해하고 컴포넌트 렌더링 생명 주기의 적절한 지점에 성능 마크를 배치하는 것이 중요합니다.
 
이렇게 하면 파이프라인의 불완전한 기준을 기반으로 하는 "더 느리다" 또는 "빠르다"에 대한 잘못된 결론을 방지하고
스타일 및 레이아웃 비용을 고려하는 데 도움이 될 수 있습니다.
 
 
 
반응형