-
[스압/데이터주의] 웹 최적화 방식 모음 - 3. Layout 및 렌더링프로그래밍/Web 2021. 2. 14. 18:25
- [스압/데이터주의] 웹 최적화 방식 모음 - 0. 전반적 원칙과 원리
- [스압/데이터주의] 웹 최적화 방식 모음 - 1. 다운로드
- [스압/데이터주의] 웹 최적화 방식 모음 - 2. 파싱 및 렌더링 트리
- [스압/데이터주의] 웹 최적화 방식 모음 - 3. Layout 및 렌더링(현재)
- [스압/데이터주의] 웹 최적화 방식 모음 - 3.3 UX 트릭
- [스압/데이터주의] 웹 최적화 방식 모음 - 4. 로드 후
- [스압/데이터주의] 웹 최적화 방식 모음 - 5. 빌드
3. Layout 및 렌더링
Layout 발생 빈도 최소화 및 비용 최소화와 CPU 처리 효율화, UX 트릭으로 나뉜다.
레이아웃에 영향을 미치는 것들에 대한 정리는 What forces layout/reflow란 글이 잘 설명해준다.
3.1 발생 빈도 최소화
3.1.1 CSS 속성
개요
분류: CSS
이야기가 나온김에 리플로우에 영향을 미치는 유명한 CSS 요소들을 뽑아보자면 [CSS Triggers, 20 Tips for Optimizing CSS Performance]
- 창 크기 조정
- 폰트 변경
- CSS 추가와 삭제
- input 에 입력시 내용 변경
- CSS 가상 클래스 활성화
- 클래스 속성, 스타일 속성과 DOM 조작
- offsetWidth, offsetHeight 계산
이 있다.
그리고 재계산을 유발하기로 유명한 속성은 다음이 있으며 불필요하게 값을 수정하지 않는게 좋다.
- border-radius
- box-shadow
- opacity
- transform
- filter
- position: fixed
보통 JS-리플로우(Layout)-페인트-컴포지터의 과정으로 렌더링이 되기 때문에 동일한 동작을 수행시
컴포지터를 업데이트하는 것이 페인트가 필요한 것보다 낫다.
또한 비용이 크다고 알려진
- background-repeat
- 각종 그라디언트
들도 사용하지 않는게 원칙적으로는 좋다.
+.
성능을 위한 엄격한 하위집합들은 AMP나 Cobalt의 예에서 볼 수 있다.
++.
CSS를 동적으로 수정해야 할 일이 있다면 직접 CSS 속성을 제어하기보다는
클래스의 추가/제거하는 것이 좋다.
+++.
CSS 이미지 교체 관련 기술 [Nine Techniques for CSS Image Replacement]
3.1.2 DOM 액세스 최소화
선행지식
DOM 모델에 대한 글들을 읽어보자
- DOM은 무엇인가? DOM Node와 Element의 차이
- 문서 객체 모델(Document Object Model)
- 8/ DOM(1) - 노드의 계층 구조(1)
- 8/ DOM(1) - 노드의 계층 구조(2)
- 8/ DOM 확장
개요
분류: Javascript
JavaScript로 DOM 요소에 액세스하는 속도가 느리다.
DOM과 관련된 영향을 고려하며 프로그래밍 해야한다.
- 부모-자식 관계: 부모 엘리먼트가 가변적인 크기를 가질 때 자식 엘리먼트의 크기를 수정하면 부모에게 영향
- 같은 위치: 여러 엘리먼트가 인라인일때 첫 번째 엘리먼트의 크기 수정으로 나머지 엘리먼트에 영향
- 숨겨진 엘리먼트: 숨겨진(display: none) 스타일이면 돔 조작이나 스타일 변경시에도 레이아웃, 리페인트가 생기지 않음
visibility: hidden일 경우 리페인트는 발생하지 않아도 레이아웃은 계산
그리고 다음 같은 원칙을 생각해볼 수 있다. [DOM access optimization, 무한 DOM 렌더링 최적화 경험기, html5 성능최적화 방법들, DOM creation and manipulation tips and tricks, Javascript DOM Manipulation to improve performance, Taming huge collections of DOM nodes, DHTML 속도 향상을 위한 몇 가지 팁]
- DOM 연산 결과 캐싱
접근했던 요소(element)에 대한 참조를 캐시 - DOM 변경 시 개별 노드단위 반복변경보다는 배칭 작업
- DOM 노드 변환은 오프라인에서 수행
노드(node)는 오프라인으로 업데이트 한 다음 트리에 추가(변경노드 떼기 -> 연산 -> 붙이기) - JavaScript로 레이아웃을 고정하지 않기
- 강제 동기 레이아웃 및 스래싱 피하기
레이아웃은 원래 비동기적이지만, 특정 속성을 읽을 때 최신 값을 계산하는 등 레이아웃이 동기적으로 발생할 때가 있다. [크고 복잡한 레이아웃 및 레이아웃 스래싱 피하기, Preventing 'layout thrashing]
스타일 변경 후 offsetHeight나 offsetTop처럼 계산된 값을 속성으로 읽을 때가 대표적인 예.
연속적인 강제 동기 레이아웃이 발생해 레이아웃 스래싱[한글]까지 발생하면 성능은 더욱 저하된다.
- DOM 트리 상위노드를 변경시 하위 노드에 영향
이므로 변경범위를 최소화 한다는 것을 고려하도록 하자.
해결방법
- DOM 연산 결과 캐싱
DOM에 접근하는 것을 매번 이용하는 것은 좋은 선택이 아니다.
document.getElementById('box').style.top = "10px"; document.getElementById('box').style.left = "10px"; document.getElementById('box').style.color = "lightgrey";
어찌보면 당연하겠지만, 변수로 만들어놓으면 된다.
const boxSty = document.getElementById('box').style; boxSty.top = "10px"; boxSty.left = "20px"; boxSty.color = "lightgrey";
HTMLCollection처럼 실시간 업데이트되는 것도 마찬가지.
HTMLCollection의 여러값들에 지속적으로 접근해야 하는 경우 Array로 바꾸는 것도 고려할만 하다. [Most efficient way to convert an HTMLCollection to an Array]
lodash의 template()같은 것도 캐싱이 가능하다. [JavaScript Performance: Pre-Compiling And Caching HTML Templates]
- 개별노드 변경보다 배칭 작업
개별 노드를 반복변경하기보다 배칭(Batching, 일괄처리)을 하는 것이 좋다.
유니티 같은 엔진에서 잘 사용하고 있는 기법. [드로우 콜 배칭, Dynamic Batching이 효율적인가]
이때도 중간에 너무 많은 계산을 하지 않는 것이 좋긴하다.
Main UI thread를 막을 수 있기 때문.
CSS 활용
앞선 예제는 CSS를 사용해 한번에 업데이트가 가능하다.
const boxSty = document.getElementById('box').style; boxSty.classList.add('boxstyles')
.boxstyles { top: 10px; left: 20px; color: lightgrey; }
DocumentFragment 활용
다음코드에서 appendChild(쓰기)와 getBoundingClientRect(읽기)의 최적화가 목표.
function noBatch(elements) { elements.forEach(el => { // This is alternating write and read this.parentElement.appendChild(el); el.getBoundingClientRect(); }); }
DocumentFragment를 사용하면 리플로우를 일으키지 않으며 처리가 가능하다. [JavaScript DocumentFragment]
function batch(elements) { // First write const fragment = document.createFragment(); elements.forEach(el => { fragment.appendChild(el); }); this.parentElement.appendChild(fragment); // Then read elements.forEach(el => { el.getBoundingClientRect(); }); }
DOM 노드 변경은 오프라인으로
많은 수의 DOM을 조작해야 한다면 DocumentFragment와 마찬가지로 직접적인 돔 조작을 줄여야 한다.
이때 생각해볼 만한 것은
- DOM에서 때어냄
- 연산
- 다시 붙임
의 과정을 수행하는 것이다.
jQuery의 detach()가 대표적인 예.[제이쿼리 성능 향상하기(performance), 다른 삭제와 비교]
바닐라의 경우 ba-detach.js를 참고하면 될 듯.
뭐 사실, GTK의 Pixmap(페인트가 필요 없음)과 Pixbuf(X11 서버에 접근 불필요) 같은 것을 보면 새로운 개념은 아닌듯 하다.
- 강제 동기 레이아웃 및 스래싱 피하기
offsetWidth처럼 강제 동기 레이아웃 속성들은 되도록 사용하지 않는 것이 좋다.
하지만 써먹어야 하는 경우가 있기에.. 몇가지 예를 들어본다.
순서변경
다음 코드는 box의 너비(Width)를 기록하기 위한 코드이다.
// Schedule our function to run at the start of the frame. requestAnimationFrame(logBoxWidth); function logBoxWidth() { box.classList.add('super-big'); // Gets the width of the box in pixels // and logs it out. console.log(box.offsetWidth); }
코드를 보면 높이를 요청하기 전에 box의 클래스를 바꾸었기 때문에 스타일 변경이 적용되어 있어야 하는 상황.
스타일 변경후의 결과를 위해 강제적으로 레이아웃이 동기화가 되어야 한다는 것을 알 수 있다.
만약 다음처럼 순서를 바꾼다면, 전 프레임의 레이아웃 값을 사용할 수 있어 비용을 아낄 수 있다.
function logBoxWidth() { // Gets the width of the box in pixels // and logs it out. console.log(box.offsetWidth); box.classList.add('super-big'); }
변수로 선언
매번 offsetWidth를 호출하면 스래싱이 생겨서 성능이 확 줄어든다.
function resizeAllParagraphs() { for (let i = 0; i < paragraphs.length; i += 1) { paragraphs[i].style.width = box.offsetWidth + 'px'; } }
역시, 변수처리를 하면 해결되는 부분.
function resizeAllParagraphs() { const width = box.offsetWidth; for (let i = 0; i < paragraphs.length; i += 1) { paragraphs[i].style.width = width + 'px'; } }
추가 및 innerHTML
innerHTML과 성능에 관한 글들이다.
오래된 글들이 좀 있기 때문에 최근 소프트웨어들에서 벤치마크가 필요할 수도 있다.
- What is faster to insert into DOM - HTML or DOM nodes?
- innerHTML로 html 작성 속도
- innerHTML의 속도가 만족스럽지 않을 때
- innerHTML vs createElement/appendChild
- JavaScript innerHTML vs createElement
- Which method is better/ faster for performance - createElement or innerHTML? [duplicate]
- insertAdjacentHTML vs innerHTML
- insertAdjacentHTML() Enables Faster HTML Snippet Injection
전반적으로 insertAdjacentHTML이 빠른듯
삭제
요소 삭제 관련이며 유의점은 위와 같다.
- is removeChild faster than innerHTML given thousands of DOM elements?
- What is the fastest way to remove child elements from the DOM in IE?
탐색
DOM 탐색 관련 글. 역시 유의점은 같음.
TreeWalker는 처음 들어봐서 약간 찾아봤다.
- querySelectorAll vs NodeIterator vs TreeWalker - fastest pure JS flat DOM iterator
- Javascript DOM access methods speed benchmark
- TreeWalker Performance
- Dom TreeWalker
- Document Object Model Traversal
히든 탭일때
Document.hidden 여부에 따라 비용이 많이드는 로직은 실행하지 않을 수 있다. [Speed up your ClojureScript Webapp]
생각치 못한 트릭.
- 라이브러리
레이아웃 스레싱
레이아웃 스레싱 문제를 해결하기 위해 DOM을 자동으로 읽고 써주는 [Preventing 'layout thrashing']
을 사용할 수 있다.
AMP의 Vsync 클래스도 비슷한 원리인듯. [AMP는 어떻게 웹 페이지의 성능을 높일 수 있나]
리엑트18은 각종 상태, 이벤트등을 모두 Automatic Batching 한다!!
DOM 조작
jQuery 독점기를 깨고 나오는 각종 라이브러리들은 돔조작을 간편하고 효율적으로 만드려고 눈물나는 노력들의 산물이다. [Change And Its Detection In JavaScript Frameworks, React Virtual DOM vs Incremental DOM vs Ember’s Glimmer: Fight, JavaScript vs JavaScript. Fight!, Die Rendering-Engines von React, Angular und Ember im Vergleich]
Backbone.js, Ext JS, Dojo는 데이터 모델의 상태를 변경하여 반영하려했는데, 변경의 이벤트의 대해 다시 렌더링을 해야할지 결정하고 해결해야하는 것은 프로그래머가 해야 한다.
Ember.js는 데이터 바인딩을 통해 이벤트가 수행할 리스너를 등록하여 동기화를 편하게 만들었다.
AngularJS는 더티체크를 수행하여 마지막 순간에서 변경되었는지를 체크한다.
그러나 매번 참조한 값이 변경되었는지 확인하는 것은 빈번히 재렌더링이 발생할 시 비용이 크다.
Angular는 Zone을 이용한다.
- 10 Things Every Angular Developer Should Know About Zone.js
- Change Detection 란 무엇인가?
- 아직도 NgZone이 단순하게 Angular의 변화감지(Change Detection)를 위해서만 필요하다고 생각하시나요?(번역)
thoughtram란 블로그에 Angular 구조 및 성능과 관련된 좋은 글들이 많다.
Taking advantage of Observables in Angular[part1, part2]
- Zone의 이해
- Angular에서 Zones의 역할(번역)
- Angular Change Detection Explained
- Two-way Data Binding in Angular
- Making your Angular apps fast
- Using Zones in Angular for better performance
- Advanced caching with RxJS
React는 매우 가벼운 Virtual DOM을 매번 생성 후, 비교후 실제 돔조작을 하는 방식이다.
장점은 변경사항을 추적할 필요가 없다는 것.
만약 리엑트와 앵귤러 렌더링 방식의 차이점을 알고 싶다면 다음 링크들을 참고할만 하다. [2020년과 이후 JavaScript의 동향 - 라이브러리와 프레임워크 1, 프론트엔드 개발자가 알아야 하는 Angular와 React의 Change Detection(영문), Angular vs. React: Change Detection]
기타 리엑트 스타일의 가벼운 라이브러리들. (보통 Diff 알고리즘을 개선했다고 알려져있다. #10703 A faster diff algorithm)
- Preact: 이 중 가장 유명한 걸로 안다.
- Inferno: 메모이즈, 노멀라이징, 마이크로레벨 최적화
- Ivi: Directed Graph와 Observable을 사용해 더티체킹
- Fre: 리엑트처럼 Concurrent 모드를 지원
- Nerv: 빠른 결정적 Diff
- Crank: 제네레이터와 비동기함수(프로미스)를 JSX에서 지원
Ember의 Glimmer 엔진에는 Virtual DOM에서 영감을 받은 Stream Tree를 이용해 변경점을 업데이트하고, Incremental DOM는 실제 DOM과 메모리상 DOM을 비교하여 업데이트 한다.[Incremental DOM 101: What is it and why I should care?]
Om이나 Reagent는 변경이 안된 것들을 Persistent 데이터구조를 이용해 파악하여 재사용하여 성능에 이점이 있다.[Understanding Clojure's Persistent Vectors, pt. 1, Persistent Data Structures and Managed References]
단점은 사람들이 익숙하지 않은 LISP 계열의 Closurescript를 이용한다는 것.
이 아이디어는 불변성을 관리하는 라이브러리인 Immutable JS, Immer를 탄생시켰고, Angular에도 적용되고 있다. [Change Detection in Angular 2]
자료들을 읽다보면 알겠지만 Reactive한 Signal-Graph를 만들어 성능 개선을 할 수도 있다. [Why React & re-frame are fast in 8 Performance Hacks(영상), Re frame]
Reactive하기 때문에 즉시 업데이트가 필요한지 알 수 있으며(예를 들어 Angular는 주기적으로 CD를 한다),
Signal-Graph로 이루어져 즉시 전파가 된다. (상태 전파를 파악하고 중복되는 변화등을 막음, Reactive 자체로는 성능 향상이 크지 않고 Signal-Graph가 핵심)
이 아이디어는 비교적 최근에 나온 페이스북의 상태관리 라이브러리인 Recoil에도 적용된듯 하다. [Recoil - 또 다른 React 상태 관리 라이브러리?(다른 번역 버전), Recoil: 왕위를 계승하는 중입니다 (새로운 React 상태 관리 라이브러리)(PDF), Recoil: A New State Management Library Moving Beyond Redux and the Context API]
세분화된 반응형 프로그래밍에 대한 좋은 글들
- Thinking Granular: How is SolidJS so Performant?
- A Hands-on Introduction to Fine-Grained Reactivity
- Building a Reactive Library from Scratch
- Finding Fine-Grained Reactive Programming
- What the hell is Reactive Programming anyway?
자료를 찾다가 벤치마크 자료를 보게 되었는데 몇가지 흥미로운 프레임워크가 존재한다.[A RealWorld Comparison of Front-End Frameworks 2020]
Elm과 최근에 뜨고 있는 Svelte가 그 대상이다. [Blazing Fast HTML(Round 1, Round 2), Elm — 웹 앱 전문 함수형 프로그래밍 언어]
이중 Elm의 아키텍처는 언어적으로 상태관리를 하도록 만들어 Hyperapp등에도 영향을 주었다. [Subscriptions, The Elm Architecture (TEA) animation, Everything You Need To Know About Hyperapp JS]
개인적으로 Elm의 아키텍처를 가장 쉽게 풀어낸 것이 Apprun이나 re-frame인 듯하다. [AppRun - Architecture, Re-frame - A Data Loop]
React에서는 elm-ts란 라이브러리나 react-use-elmish 훅을 이용해 elm을 흉내낼 수 있다.
사이드 이펙트 관리가 좋아지는 듯?
이와 다른 접근법으로 게임개발에서 쓰이는 ECS(Entity Component System) 패턴이라는 것이 존재한다. [DOTS 기술 소개: 엔티티 컴포넌트 시스템, Entity Component System, Data Oriented vs Object Oriented Design, Introduction to Data-Oriented Programming, Introduction to Data-Oriented Programming(FrostBite)]
데이터가 모여있어 캐싱(지역성)이 유리하고, 병렬로 처리하기도 좋은 편이다.
물론 웹에서 사용해보려는 시도가 존재한다. [Entity Component Systems For The Web, TodoMVC implemented using a game architecture — ECS]
ECS 패턴을 구현한 라이브러리에서는 WolfECS가 가장 빠른 듯
상태머신으로 상태들를 다루려면 Xstate도 한번쯤 살펴보는 것도 좋다. [Finite state machine & statecharts – XState, Boost your React application’s performance by Xstate, Welcom to the world of Statecharts]
컴포넌트/템플릿의 최적화와 관련 있는 프로젝트들도 존재한다.
Svelte의 목표는 컴포넌트들을 컴파일하여 매우 효율적인 코드로 변환해 Virtual DOM의 오버헤드를 제거하자는 것이다.[Frameworks without the framework: why didn't we think of this sooner?, Virtual Dom is pure overhead, Svelte 3: Rethinking reactivity, svelte가 빠른 이유, Svelte CheetSheet, The Svelte Compiler Handbook]
최근 malina라고 svelte와 비슷한 프로젝트, vdom의 컴파일인 million도 있다.
사실 Ember의 Glimmer 엔진은 비슷하지만 완전 다른 접근법을 가지고 있다.[Glimmer: Blazing Fast Rendering for Ember.js(part1, part2), The Glimmer VM: Boots Fast and Stays Fast, The Glimmer Binary Experience]
Ember의 템플릿 형식인 Handlerbar를 이용해 AST를 만든뒤, OP코드 형태와 같이 스트림 목록으로 만들고 최적화를 수행한다.
우리는 효과적으로 프로그래밍 언어와 기본 런타임을 설계하고 있기 때문에 JIT 컴파일러와 바이트코드 인터프리터와 같이 언어 구현을 프로그래밍할 때 잘 확립된 원칙을 사용하여 그것을 설계해야 한다. VM 아키텍처를 구축하면 constant folding, 인라이닝, 매크로 확장 등과 같은 잘 알려진 최적화를 보다 쉽게 구현할 수 있을 것이다.
정적, 동적인 템플릿을 구분하여 최적화가 수행된다.
Virtual Dom과 Svelete 방식의 사이 느낌이랄까?
Svelte부터 시작해 좋은 성능을 내는 라이브러리들 중 Virtual Dom을 사용하지 않는 경우가 많아지고 있는 듯 하다. [Virtual DOM is pure overhead, The Fastest Way to Render the DOM]
앞서 이야기한 Reactive Signal-Graph 방식으로만 업데이트하는 경우도 있고,
- Solid: Fine-Grained Reactive Graph 사용
- Surplus: 컴파일하며, S.js를 이용해 돔을 업데이트
- Sinuous: Surplus와 비슷한데 역시 더 빠르다. Reactive/Observable 기반
- Domc: Top-Down 방식으로 Diff
- Stage0: Domc의 아이디어 바탕이라는데 더 빠르다는 듯
- blockdom: Element 단위가 아니라 Block 단위로 비교
Domc처럼 diff 관련 라이브러리도 있는데 diff쪽은 udomdiff(관련글), nanomorph, x-tree-diff-plus(논문), A* Tree Diff, 스택오버플로우, 네이버 list-differ도 흥미로운 듯.
앵귤러나 리액트도 최적화를 시키려는 움직임이 있다. [Angular {{AOT vs JIT}} vs React compiler: Part — I, Ahead-of-Time Compilation in Angular, Angular vs. React: Compilers]
앵귤러는 AOT 컴파일을 도입하며, 리엑트는 Prepack을 이용하거나 Svelte처럼 직접 변환하는 식(rawact, ecmacomp)
추가적으로 Solid의 저자가 쓴 컴파일과 컴포넌트 관련 글들이 인상깊다.
- A Look at Compilation in JavaScript Frameworks
- The Real Cost of UI Components
- Components are Pure Overhead [Svelte의 Virtual DOM is pure overhead 따라하기인듯 ㅋㅋ]
lit-html, hyperHtml처럼 템플릿 리터럴을 이용하기도 한다. [lit-html vs hyperHTML vs lighterhtml]
일반 Virtual DOM에 비해 주된 성능 향상 원리는 템플릿 리터럴을 사용하기 때문에 정적, 동적인지 판별하기 쉬워 변화가 생기는 문자열만 업데이트하고 innerHTML로 렌더링을 하는 것이다. [HTML templating with ES6 template strings, lit-html, JavaScript templating from the Polymer team at Google, A night experimenting with Lit-HTML…, A bit about lit-html rendering]
h1, h3태그가 새로 생성되는 Preact와 달리 수정만 되는 모습을 볼 수 있다.
Mikado라는 템플릿 엔진의 성능도 주목할만 했다. [JS Framework Benchmark]
Recycle, Reuse를 극대화하며 독자적인 Diff 알고리즘을 사용했다나.
아무튼 팩토리 풀, 템플릿 풀, 키 풀, 라이브 풀로 이어지는 풀링 시스템이 특이하다.
Neo라 하여 추후 나올 워커의 사용을 극대화한 프레임워크 또한 존재한다. [Create Blazing Fast Multi-Threading User Interfaces Outside Node.js]
마지막으로 또 다른 특이한 프레임워크인 Qwik.
재개가 가능하며, 세분화된 lazy loading이 가능하다고 한다.
- Hydration is Pure Overhead (또 다른 Pure Overhead 시리즈, HN 토론)
3.1.3 이벤트 핸들러를 잘 개발하기 with API
개요
분류: Javascript
DOM 트리의 다른 요소에 너무 많은 이벤트 핸들러가 첨부되어 너무 자주 실행되거나 실행 간격이 크기 때문에 페이지의 응답성이 떨어지는 경우가 있다.이벤트를 사용하지 않으면 해제시켜주고, Observable을 subscribe했으면 unsubscribe를 해줘야한다.
해결방법
- 이벤트 위임
이벤트 위임(event delegation)을 사용 하는 것이 좋은 방법. [왜 이벤트 위임(delegation)을 해야 하는가?, 이벤트 버블링, 이벤트 캡처 그리고 이벤트 위임까지, Event Delegation, Event Bubbling and Event Catching in JavaScript and React – A Beginner's Guide]
안에 10 개의 버튼이있는 div경우 각 버튼마다 하나의 핸들러 대신 하나의 이벤트 핸들러만 div 래퍼에 연결하는 방식을 쓰자.
이벤트 핸들러가 너무 많으면 반응속도가 느려진다.
이벤트가 버블업되어 이벤트를 포착하고 이벤트가 시작된 버튼을 파악할 수 있다.
리엑트에서는 합성 이벤트(SyntheticEvent)에서 자동적으로 위임을 하여 수동으로 하지 않아도 된다. [Should I use event delegation in React?, React에서 이벤트 위임을 통한 이벤트 리스너 최적화]
또한 이벤트 풀링(Pooling)을 하여 최적화를 하였는데 모던 브라우저에서 이득이 없어 v17부터 제거 되었다. [Remove event pooling in the modern system, 리액트를 처음부터 배워보자. — 06. 합성 이벤트와 Event Pooling]
stopPropagation()과 preventDefault()로 위임을 제어할 수 있다.
- onload vs DOMContentLoad
DOM 트리로 무언가를 시작하기 위해 onload 이벤트를 기다릴 필요가 없다.트리에서 사용할 수 있도록 액세스하려는 요소만 있으면 된다.
모든 이미지가 다운로드 될 때까지 기다릴 필요도 없다.
DOMContentLoaded는 onload 대신에 사용할 수 있다. [문서의 로드시점 - onload, DOMContentLoaded, DOMContentLoaded vs load]
자세한 정보는 Julien Lecomte이 작성한 "High Performance Ajax Applications"를 참고.
- requestAnimationFrame(), requestIdleCallback() 사용
requestAnimationFrame과 requestIdleCallback을 사용하면 작업을 작은 조각으로 나누어 처리할 수 있다. [시각적 변화에 requestAnimationFrame 사용, Using requestIdleCallback, 브라우저는 vsync를 어떻게 활용하고 있을까]
따라서 장기 실행중인 자바스크립트가 메인 스레드를 차단하여 반응성을 낮추는 일을 막을 수 있다.
이 둘은 setTimeout, setInterval을 대체해 사용하는데 성능 향상폭이 상당하다.[남다른 개선방법을 다시 보여준 페이스북의 React Fiber, 100,000개의 아이템도 거뜬한 셀렉트박스 만들기]
jQuery requestAnimationFrame은 requestAnimationFrame을 사용해 jQuery의 animate 반응성을 높이는 대표적인 라이브러리.
리엑트에서 requestAnimationFrame을 사용하려면 다음글이 유용할 것이다. [Using requestAnimationFrame with React Hooks]
- IntersectionObserver 사용
Lazy 로딩시 scroll 이벤트나 resize 이벤트를 받아서 getBoundingClientRect()같은 DOM API를 사용하여 뷰포트 위치를 계산하는 것이 일반적이지만 IntersectionObserver를 사용하는 것이 효율적.[IntersectionObserver's Coming into View, Intersection Observer 간단 정리하기, Now You See Me: How To Defer, Lazy-Load And Act With IntersectionObserver]
구형 브라우저를 위한 폴리필이 존재한다. [IntersectionObserver polyfill]
- pointer 이벤트나 touch 이벤트 사용
모바일에서는 더블탭이 될수도 있고, 제스쳐와 같은 동작을 위해 클릭 이벤트는 300ms를 대기 시간으로 둔다.
touchstart - touchmove - touchend - mouseover - mousemove - mousedown - mouseup - click
의 순으로 이벤트가 일어난다는 점을 고려하면 [Supporting both TouchEvent and MouseEvent, Handling Gesture Events, 사이트에 터치추가]
click보다는 touchstart, touchend를 이용하는 것이 좋다.
다음처럼 터치이벤트를 지원하는지 확인하거나
const clickEvent = (() => { if ('ontouchstart' in document.documentElement === true) return 'touchstart'; else return 'click'; })
구형 브라우저는 fastclick과 같은 라이브러리를 사용해 응답성을 개선할 수 있다. [300ms tap delay, gone away]
그리고 Pointer Event란 터치이벤트보다 좋은 것이 나왔다. [포인터 이벤트, getting touchy, Pointer Events, iOS 13의 Pointer 이벤트, Pointer Events in React — The Why, How, and What, Enabling Cross-Platform Touch Interactions: Pointer vs. Touch Events]
마우스, 터치 뿐만아니라 팬까지 모두 사용할 수 있도록 만든 이벤트라는데 Touch/pointer tests and demos(이벤트 리스너)로 테스트 해보자.
둘을 대체 가능할 뿐만아니라 클릭 이벤트의 300ms 지연도 막을 수 있다.(우선순위가 가장 높음)
역시 PEP(Pointer Events Polyfill)처럼 폴리필이 존재.
3.1.4 로드 후(Post-load) 구성 요소
개요
분류: content
페이지를 곰곰히 살피다보면 "처음에 페이지를 렌더링하기 위해 반드시 필요한 것은 무엇인가?"에 대한 고민을 할 수 있다.나머지 내용과 구성 요소는 기다리게 만들어도 된다.
해결방법
JavaScript는 onload 이벤트 전후에 분할하기에 이상적인 후보이다.
function afterPageLoad(){ console.log('after page load'); } window.onload = function afterPageLoad();
예를 들어, 드래그 앤 드롭 및 애니메이션을 수행하는 JavaScript 코드 및 라이브러리가 있는 경우 초기 렌더링 후 페이지 드래깅 요소가 오기 때문에 대기 할 수 있다.
Post load의 후보를 찾을만한 다른 장소로는 fold 아래에 숨겨진 콘텐츠 (사용자 작업 후에 표시되는 콘텐츠)와 이미지가 있다.
(이미 언급했죠?)
그런데 성능 목표는 다른 웹 개발 모범 사례와 일치 할 때 이상적이란 사실은 염두해야 한다.
이 경우 점진적 향상이라는 아이디어는 JavaScript를 사용하여 UX을 향상시킬 수 있지만, JavaScript 없이도 페이지가 작동하는지 확인해야한다는 것을 뜻한다.
따라서 페이지가 제대로 작동하는지 확인한 후 드래그 앤 드롭 및 애니메이션과 같은 부가기능을 제공하는 Post Load 스크립트로 페이지를 향상시킬 수 있다.
3.1.5 Flexbox, Grid 활용
개요
분류: CSS
옛날에는 CSS로 레이아웃을 구성하는 방법은 [CSS 레이아웃 입문서, 레거시 조판 메서드]
를 이용하였다. (더 옛날은 Table...)
그러나 최신 CSS의 방식을 사용하면 훨씬 빠르다. [이전 레이아웃 모델 대신 Flexbox 사용]
다음은 1300개의 상자들의 레이아웃을 짠 것.
3.544ms로 14.289 ms보다 훨씬 빠름
레거시한 방법은 더 많은 DOM을 요구하거나, 더 많은 마진/패딩을 요구하기 때문이다.
3.1.6 Contain 속성 활용하기
개요
분류: CSS
contain은 웹페이지에서 선택된 하위 트리를 문서의 나머지 영역과 분리하는 기능을 가지고 있다. [CSS Containment Module, CSS Containment in Chrome 52, Helping Browsers Optimize With The CSS Contain Property, How I made Google's data grid scroll 10x faster with one line of CSS]
분리되면 너무나 당연하게도 성능 향상이 된다!!
첫번째 참조 링크에 따르면 3~6ms에서 0.3~1.9ms로 약 2~10배까지 차이가 난다.
텍스트 요소를 바꿔 리플로우 발생[코드펜]
두번째 링크의 결과는 차이가 더 많이 난다. ㅎㅎ
대략적으로만 속성을 알아보자면
- strict: size + layout + paint
- content: size + paint
- size: 상위요소는 하위요소의 크기와 독립적임
- layout: 하위요소는 상위요소 안에서만 움직임
- style: 요소와 해당 요소에 영향을 끼치지만, 그 이상 벗어나진 않음(서로 영향을 끼치지 않음, 독립적)
- paint: 하위요소는 상위요소 안에서만 보임
처럼 이루어져 있다.
쉐도우 돔을 사용해도 효과가 이리 나타나야 할텐데..
3.1.7 content-visibility 속성 활용하기
개요
분류: CSS
화면 밖 컨텐츠의 렌더링을 생략하여 초기 로드 속도를 개선 하는 content-visibility 속성이 Chrome 85에 적용되었다. [랜더링 성능을 향상 시키는 새로운 CSS 속성 css-visibility(번역), CSS Tricks content-visibility]
화면밖의 박스는 스타일과 레이아웃만 처리하고 다른 렌더링 작업은 생략하기 때문에 퍼포먼스가 올라갈 수 있다.
.story { content-visibility: auto; contain-intrinsic-size: 1000px; /* Explained in the next section. */ }
문제가 있다면 크기가 0으로 측정된다는 것이다.
따라서 contain-intrinsic-size를 사용하여 공간을 차지하도록 만들어 레이아웃 시프트를 억제할 수 있다.
hidden 속성 잘 이용하면 성능향상에 도움을 줄 수 있다.
- display: none: 숨기며 렌더링 상태를 제거. 나중에 다시 표시하면 새 요소를 렌더링 하는 것만큼 비용이 듦
- visibility: hidden: 숨기며 렌더링 상태는 유지. 요소를 실제로 제거하지는 않기 때문에 공간을 차지하고 클릭할 수 있으며, 렌더링 상태가 업데이트 됨
- content-visibility: hidden: 숨기며 렌더링 상태는 유지. 상태를 변경할 일이 생기면 화면에 표시될 때만 변경
content-visibility: hidden은 바로 다음에 나올 대형 리스트 최적화에 사용하면 좋을 것으로 보인다.
3.1.8 대형/연속적 리스트 최적화
개요
분류: Content
구글 검색시 인피니티 스크롤, 페이스북이나 인스타그램의 타임라인, 핀터레스트의 Masonry 레이아웃은 끝없는 데이터를 연속적으로 보여주는 예다. [Virtualize large lists with react-window, How GitHub Actions renders large-scale logs]
이를 어떻게하면 효율적으로 구현할 수 있을까?
해결방법
대형인 데이터를 최적화하여 보여주는 방법은 고전적인 텍스트 에디터에서도 많이 고민해왔던 문제이다.
프로그래밍 경력이 짧은 본인의 경험만 생각해도 머신러닝용 데이터나 로그파일을 보아야 할 때 성능 때문에 문제가 생긴적이 있었으니까.
이맥스의 VLF(VLFI)가 대표적인 예인데 OS에서 가상 메모리의 페이징 기법처럼 사용하여 커다란 데이터를 필요한 만큼만(Lazy하게) 다룰 수 있다.
약간 더 직관적으로 바라보고, UX를 생각해보자.
위의 작업을 페이지와 프레임단위로 사용자가 사용할 페이지들만 메모리에 올려놓고, 보여주는 것은 뷰에서 현재 사용할(Visible Window, Viewport) 또는 곧 사용할 리스트(Realization Window)만 보여주자는 이야기다.
Realization Window처럼 리스트 여백을 남겨두는 이유는 사용자가 스크롤할 때 곧장 보여줄 수 있기 때문.
ListView basics and virtualization concepts
어차피 Realized View까지 한정된 갯수의 아이템만 사용할 것이라면, 각 Item을 매번 재생성하는 것은 비효율적이지 않을까?
안드로이드의 ReycylerView나 IOS의 UICollectionView는 Item을 재사용(recyler, reuse)한다. [RecyclerView(리사이클러뷰)의 원리와 사용법(feat. Kotlin), 안드로이드 리사이클러뷰 기본 사용법. (Android RecyclerView), Kotlin & RecyclerView for High Performance Lists in Android, RecyclerView로 목록 만들기]
보여지는 각 Item을 View Holder 객체에 저장해두고, View Holder 내부에서 바꿔야 할 데이터들만 바인딩하여 바꿔치기만하면, 매번 Item을 제거하고 다시 생성할 필요가 없다.
관련 코드설명을 함께 사용하고 싶다면 다음 설명이 깔끔하다. [Tips & Tricks for Highly Performing Android ListViews]
여기에 스크롤 성능을 더 향상시키위해 재사용하기전에 약간의 텀을 두고, 재사용 했을시 캐싱, 새로운 아이템에 대한 프리페칭등을 적용할 수도 있다. [iOS10의 프리-패칭 API로 부드러운 스크롤 증진하기, UICollectionView Tutorial: Prefetching APIs, UICollectionViewCell의 LifeCycle과 PreFetching, Texture - Intelligent Preloading(한글)]
자 다음 사진으로 정리해보자.
- Data List: 물리적 메모리
- Adapter: 메모리 맵
- ViewHolder: 페이지
가 보이는가??
React의 경우 다음을 사용가능하다. [Windowing wars: React-virtualized vs. react-window]
- React-Window: 보이는 것들만 로딩
- React-Virtualized: React-Window보다 살짝 더 무거움
- Recylerlistview: 재사용 가능, ReactNative 기반이지만 웹에서도 사용가능
다른 플랫폼의 경우 Facebook의 Litho관련 글도 흥미롭다.
- Components for Android: A declarative framework for efficient UIs
- Multithreaded rendering on Android with Litho and Infer
- Open-sourcing Sections: Declarative data handling for Litho lists
- Improving Android video on News Feed with Litho
3.1.9 React 최적화
개요
분류: Javascript
여기에서는 리엑트의 렌더링성능을 최적화시키는 방법들에 대해 다루려한다. [How to greatly improve your React app performance, React 렌더링 이해 및 최적화 (With Hook)]
우선 리엑트의 구조에 대해 아는게 좋겠죠.
대략적인 리엑트의 동작과 주의할 점은 다음 글들에서 알 수 있다.
- UI 런타임으로서의 React
- 탄력적인 컴포넌트 작성하기
- 함수형 컴포넌트와 클래스, 어떤 차이가 존재할까?
- useEffect 완벽 가이드
- React Hooks로 setInterval 선언적 만들기
리엑트 Core 팀 개발자인 Dan Abramov의 글이니 신뢰할만하다.
기타 리엑트 구조에 대해 궁금하다면
를 읽어볼 수 있다.
이외에 리엑트 동작의 시각화한 글들 목록.
- A Visual Guide to React Rendering - Cheat Sheet
- A Visual Guide to React Rendering - It Always Re-renders
- A Visual Guide to React Rendering - Props
- A Visual Guide to React Rendering - useMemo
- A Visual Guide to React Rendering - useCallback
- A Visual Guide to React Rendering - Context
- A Visual Guide to React Rendering - DOM
- A Visual Guide to React Rendering - Refs
- A Visual Guide to React Rendering - useEffect
- A Visual Guide to React Rendering - useEffect Cleanups
- A Visual Guide to Javascript for React developers - First-class functions
를 보도록 하자.
리엑트는 클래스 컴포넌트나 함수형 컴포넌트를 사용하며 둘의 사용방법이 다르기 때문에 비교가 필요하다.
보통 클래스 컴포넌트에 존재하는 라이프 사이클이 함수형 컴포넌트에는 없기 때문에 Hooks를 사용한다.
라이프사이클과 Hooks의 관계는 대략 다음과 같다.
해결방법
- 컴포넌트와 State 구조
가장 간단한 방법으로 메모이즈가 떠오르겠지만, 만능은 아니다.
우리는 항상 처음에 설계를 올바르게 했는가를 생각해볼 필요가 있다.
깊이 있는 컴포넌트까지 너무 많은 Props를 전달하는 것은 쓸데없이 재렌더링이 일어날 수 있으며 성능에 영향을 미칠수도 있다. [Crafting a high-performance TV user interface using React, Avoiding unnecessary renders with React context, React Context 알아보기]
넷플릭스의 벤치마크 결과
아 그럼, Context를 사용하면 state를 바로 전달할 수 있어 쓸데없이 렌더링이 이루어지는 것을 막을 수 있겠구나고 생각 할 수 있다.
그러나 Context API는 Context 값에 의존 할 경우, 다른 값이 변경되도 리렌더링이 발생한다. [리덕스 잘 쓰고 계시나요?]
따라서 관련이 없는 상태라면 Context를 따로 분리해줘야 하며, 위 글과 같이 상태를 위한 Context와 상태 업데이트를 위한 Context를 분리해야하기도 한다.
솔직히 상태 업데이트를 분리하도록 만들어야 하는게 올바른 패턴이라는 점에서 약간의 설계미스가 있는게 아닌가 싶다.
Context API 내부의 state들이 리엑트 state로 관리되어 깔끔하긴 하다만 컴포넌트 트리에서 분리되는게 좋지않나..
정적인 값들 위주로 사용할경우 쓰는게 맞을 듯.
따라서 윗글대로 리덕스나 리코일등을 사용하면 좋다고 생각한다.
하지만 여기서도 여전히 구조적으로 설계를 잘하면 성능상 이득은 여전히 얻을수있다. [Refactoring Components for Redux Performance, Redux isn't slow, you're just doing it wrong - An optimization guide, memo()를 하기 전에]
리팩토링 전, 후
파생된 상태의 경우 reselect같은 라이브러리를 사용하여 메모이징을 할 수 있다.
정리하자면
- 상태를 데이터베이스처럼 생각하고, 정규화를 하자. [Redux에서 Normalize 다루기, 정규화와 성능, 반정규화와 성능]
관심사 분리가 자연적으로 되며, 평평하게(Flatten)하게 유지됨
단, 너무 심한 정규화는 읽기 성능에 지장을 줄 수도 있으며, 데이터가 크다면 파티셔닝도 고려하자. - 컴포넌트의 상태구독 계층을 분리한다.
모든 값이 공유되지 않도록 하며, 상태는 아래로 내리고 내용물은 위로 끌어올린다. - 상태 업데이트는 최소한만 하며, 파생된 상태의 경우 reselector등으로 메모이징을 활용한다.
위 링크들에서 나왔던 예제코드들.
// Bad const state = { articles: [{ comments: [{ users: [{ }] }] }], ... }; // Good const state = { articles: [{ ... }], comments: [{ articleId: .., userId: ..., ... }], users: [{ ... }] }
상태는 정규화되고 평평하게.
// Bad const BigComponent = ({ a, b, c, d }) => ( <div> <CompA a={a} /> <CompB b={b} /> <CompC c={c} /> </div> ); const ConnectedBigComponent = connect( ({ a, b, c }) => ({ a, b, c }) ); // Good const ConnectedA = connect(CompA, ({ a }) => ({ a })); const ConnectedB = connect(CompB, ({ b }) => ({ b })); const ConnectedC = connect(CompC, ({ c }) => ({ c })); const BigComponent = () => ( <div> <ConnectedA /> <ConnectedB /> <ConnectedC /> </div> );
상태분할
방금전의 상태분할과 상동하는 면도 있다.
// Bad export default function App() { let [color, setColor] = useState('red'); return ( <div style={{ color }}> <input value={color} onChange={(e) => setColor(e.target.value)} /> <p style={{ color }}>Hello, world!</p> <ExpensiveTree /> </div> ); } // Good export default function App() { return ( <ColorPicker> <p>Hello, world!</p> <ExpensiveTree /> </ColorPicker> ); } function ColorPicker({ children }) { let [color, setColor] = useState("red"); // 1. 상태를 내리기 return ( <div style={{ color }}> <input value={color} onChange={(e) => setColor(e.target.value)} /> {children} // 2. 내용물은 끌어올리기 </div> ); }
상태는 내리고, 내용물은 끌어올리기
// Bad const ListItem = connect( ({ selectedItem }) => ({ selectedItem }) )(SimpleListItem); // Good const ListItem = connect( ({ selectedItem }, { itemId }) => ({ isSelected: selectedItem === itemId }) )(SimpleListItem);
상태 변경은 최소한으로
리스트 데이터 관리는 React Children And Iteration Methods를 읽어보자.
- 불변성과 상태 업데이트 조금 더 잘하기
위에서는 구조를 위주로만 설명했다.
여기서는 기타방법을 다룹니다. [리액트를 다루는 기술]
- 데이터 불변성 유지하기
- 함수형 업데이트 활용하기
- useState 대신 useReducer 활용하기
데이터 불변성 유지하기
ES6의 Spread Syntax나 바벨의 Object Spread Syntax를 이용하면 쉽게 데이터 불변성을 유지하면서 값을 업데이트할 수 있다.
// spread syntax handleClick() { this.setState(state => ({ words: [...state.words, 'marklar'], })); }; // object spread syntax function updateColorMap(colormap) { return {...colormap, right: 'blue'}; }
불변성을 유지하기 위해 앞서 이야기했던 immutable.js, immer를 사용해볼 수도 있다.
함수형을 좋아한다면 하스켈 optics와 비슷한 monocle-ts를 써보자.
함수형 업데이트 활용하기
함수형 업데이트는 몇가지 장점이 있다.
우선 업데이트 함수가 현재 상태와 같은 값을 반환하면 렌더링을 건너뛰며, 의존성을 줄이기 때문에 최적화에 도움이 된다.
마지막으로 초기 state의 경우 지연되게 만들 수 있다.
useState 대신 useReducer 활용하기
useReducer는 콜백 대신 dispatch를 전달한다. [How to avoid passing callbacks down?, How to use useReducer in React Hooks for performance optimization]
다수의 하윗값을 포함하는 복잡한 정적 로직을 만드는 경우나 다음 state가 이전 state에 의존적인 경우에 보통 useState보다 useReducer를 선호합니다. 또한 useReducer는 자세한 업데이트를 트리거 하는 컴포넌트의 성능을 최적화할 수 있게 하는데, 이것은 콜백 대신 dispatch를 전달 할 수 있기 때문입니다.
이때 dispatch는 렌더시 변화하지 않고 같다는 것을 보장하며 역시 의존성을 제거하여 필요시보다 자주 업데이트 되는 것을 방지한다.
Flux의 Dispatcher 문서에서는 다음과 같이 차이를 정의한다.
- 콜백은 이벤트를 개별적으로 구독하지 않는다. 모든 데이터 변동은 등록된 모든 콜백에 전달된다.
- 콜백이 실행될 때 콜백의 전체나 일부를 중단할 수 있다.
Redux에는 combineReducers()도 있으니 확인바란다.
- 컴포넌트 라이프사이클 / 메모이제이션
shouldComponentUpdate(): 클래스
shouldCompontnentUpdate는 현재 props와 state와 다음 props, state를 비교후, 렌더링할 것인지 말것이지 결정한다. [재조정을 피하세요]
true는 렌더링. false면 렌더링을 하지 않는다.
트위터에서는 deffering rendering을 하기도 했다. [Twitter Lite and High Performance React Progressive Web Apps at Scale]
React.PureComponent: 클래스
Purecomponent는 Shallow Compare(얕은 비교)를 이용하는 것과 같다.
export class SampleComponent extends React.Component { shouldComponentUpdate(nextProps, nextState) { return shallowCompare(this, nextProps, nextState); } render() { return <div className={this.props.className}>foo</div>; } }
그렇다면 얕은 비교는 어떻게 이루어질까?
자바스크립트를 처음 배울 때 객체와 레퍼런스들을 다루는 것과 같다.
Are javascript object variables just reference type?
s2는 s1의 포인터를 사용하는 것과 같아 같은 레퍼런스를 가르치므로 같으며,
s3는 값은 같으나 전혀 다른 객체이므로 달랐던 것처럼.
const s1 = {text: "first"}; const s2 = s1; const s3 = {text: "first"}; s1 === s2; // true s1 === s3; // false
PureComponent는 props와 state에 대해 얕은 비교를 수행한다.
만약 깊은 비교가 필요하면 react-fast-compare나 fast-deep-equal를 이용해보자.
React.memo: 함수형
React.memo는 PureComponent와 shouldComponentUpdate의 일부를 사용하도록 만들어졌다. [React.memo() 현명하게 사용하기]
첫번째 인수로 PureComponent로 사용할 컴포넌트, 두번째 인수로 같은 컴포넌트인지 확인하기 위한 비교할 콜백 함수를 넣을 수 있다.
차이라면, props에 대해서만 얕은 비교를 수행하며, shouldComponentUpdate와 정반대로
- true: 같으므로 렌더링 하지 않음
- false: 다르므로 렌더링
이다.
다음은 React.PureComponent나 React.memo를 사용하면 좋은 상황들.
- 순수 컴포넌트(Pure Component): props의 값이 같으면 렌더링이 같을 때.
- 렌더링이 빈번히 일어날 때.
- 얕은 비교를 할 필요가 있을 경우.
반대로 매 렌더링마다 달라지는 경우는 불필요한 비교과정만 늘어난다.
메모이제이션을 모든 곳에 사용하는 것은 나쁜 생각이지만,[Death by a thousand useCallbacks]
참조/ID의 재사용 때문에 선제적으로 최적화하자는 말도 있다. [Preemptive memoization in React is probably not Evil (yet)]- 이벤트 리스너와 값 메모이제이션
컴포넌트 밖에서 정의: 공통
React.PureComponent나 React.memo를 사용시 해당 컴포넌트에 의존하지 않는다면 컴포넌트 밖에 정의하면 참조 값이 같으므로 새로운 함수나 값이 생성되지 않는다. [이벤트 리스너 캐시를 이용한 React 성능 향상]
this로 바인딩: 클래스
리엑트의 클래스형 컴포넌트에서 콜백을 올바르게 사용하는 방법은 유독 까다롭다. [이벤트 처리하기, React Binding Patterns: 5 Approaches for Handling `this`, 당신이 몰랐을수도 있는 React Binding 에 관한 사실]
의외로 화살표 함수에서도 오버헤드가 발생하며, 가장 나은 방법은 데코레이터로 바인딩 표시를 해주는게 가독성에서 나은 방법이라고.. [Arrow Functions in Class Properties Might Not Be As Great As We Think]
import {boundMethod} from 'autobind-decorator' class BindMethod extends React.Compnent { constructor(props) { super(props); this.state = { isChecked: false }; } @boundMethod handleClick(e) { this.setState({ isChecked: true }); } render() { return <button onClick={this.handleClick}>바인딩</button>; } }
useCallback/useMemo: 함수형
useCallback은 이벤트 리스너를 useMemo는 값을 메모이제이션한다. [Optimize Your React Functional Components with useCallback and useMemo]
class때처럼 인라인 콜백은 매번 새로운 함수객체를 생성하므로 좋은 습관이 아니다.
useCallback을 적극 활용하자.
// Bad function fnComponent() { const [state, setState] = useState({ isChecked: false }); return return <button onClick={(e) => setState({ isChecked: true })}>인라인</button>; } // Good function fnComponent() { const [state, setState] = useState({ isChecked: false }); const handleClick = useCallback((e) => { setState({ isChecked: true }); }, [state]); return return <button onClick={handleClick}>인라인</button>; }
- 올바른 Key 사용
배열에서 올바르게 키를 지정해주는 것도 쉬운듯, 쉽지 않은 일이다. [Optimize React Performance, 아이디에 고유 값 주기]
보통의 경우, DB에서 불러올 ID 정보값을 사용하여 설정해주는게 좋다.
의외로 많이 하는 실수인 Math.random()은 성능이 나뻐지고 안전한 난수를 생성하지 않는다.
- React.lazy/React.Suspense 사용 및 동시성 모드
React.lazy를 사용하면 컴포넌트를 동적으로 불러올 함수를 등록할 수 있다.
React.Suspense로 렌더링이 준비되지 않은 컴포넌트를 불러오고, fallback으로 스피너를 사용할 수 있다.
또한 리엑트는 동시성모드로 트랜지션, 자동 배칭, 협력적 멀티태스킹 등등 많은 혁신을 도입한다.
- Ref와 비제어 컴포넌트
리엑트의 경우 기본적으로 제어 컴포넌트로 동작하여 입력을 하면 렌더링이 일어난다. [입력을 다루는 다양한 방법, Uncontrolled form validation with React]
(onChange와 state를 연결한다는 가정하에)
Dom에 빈번한 접근 또한 성능에 안좋지 않던가?
게다가 Form은 기본적으로 Dom에서 상태를 저장할 수 있기 때문에, 꼭 자바스크립트를 통해 상태를 업데이트할 필요가 없다.
Dom에 상태를 저장하고, ref를 이용하면 비제어 컴포넌트로 사용할 수 있다.
라이브러리를 원한다면 unform나 react-hook-form을 이용해보자.
한마디로 변경 가능한 값을 유지해야할 때는 ref 사용을 고려해볼 수 있다는 말이다. [A Thoughtful Way To Use React’s useRef() Hook, userStateWithRef()]
데이터 그리드를 userRef로 최적화한 사례인 How to Optimize React Performace by Using useRef Hooks을 참고해보는 것도 좋다.
- 기타 실용적 예제
토스트 UI와 체커의 데이터 그리드 시스템[반응형 시스템 개선하기(feat. TOAST UI Grid), React datagrid component 제작기 (with ES6, TypeScript)]
앞서 나왔던 넷플릭스 예제에는 인라이닝, 스타일의 static-dynamic 요소 분리와 정적 diff&apply처럼 흥미로운 예가 있지만, 컴파일타임에 처리해주는 플러그인이 있어야 하기 때문에 일반적이지는 않다.
3.2 처리 효율화
3.2.1 워커나 워클릿 활용하기
개요
분류: Javascript
웹워커는 무거운 연산들을 메인 쓰레드와 분리시켜 백그라운드에서 처리할 수 있도록 도와준다. [멀티스레드를 위한 자바스크립트 프로그래밍 웹 워커(요약), Web Worker 간단 정리하기, Web workers vs Service workers vs Worklets, The main thread is a bad place to run your code, Html5 web workers]
- 처리 집약적 수학 계산
- 대용량 데이터 세트 정렬
- 데이터 작업(압축, 인코딩/디코딩, 오디오 분석, 이미지 픽셀 변환)
- 트래픽 높은 네트워크 통신(네트워크 데이터 처리, 캐싱과 프리페칭)
- 백그라운드 I/O, 웹사이트 폴링(Polling)
등이 대표적인 예. [JavaScript 프로그램 성능 향상 ]
워커의 종류를 나누어 보자면 다음과 같다.
- 전용 워커(Dedicated Woker): 처음 생성한 스크립트에서만 사용 가능, 보통 웹워커라 함은 전용 워커를 뜻함
- 공유 워커(Shared Worker): 워커간 데이터를 공유하는 목적, 동일한 도메인 내의 여러 스크립트나 컨텍스트(tab, iframe, worker)에서 접근 가능
- 서비스 워커(Service Worker): 프록시의 역할을 하며, 네트워크 요청과 캐싱, 푸시알람, 백그라운드 동기화등에 사용[서비스 워커: 소개, 오프라인 설명서]
- 크롬 워커(Chrome Worker): 파이어폭스 독점 API로 확장기능에서 사용하고, js-ctype에 접근할 때 사용, 신경쓰지 않아도 좋다.
웹워커나 서비스 워커를 사용하면 계산이 많이 필요한 것이나 캐싱이 필요한 것들에서 큰 폭으로 성능을 높힐 수 있다.
웹 워커 성능 향상 예[Web Workers with the Angular CLI, Optimizing JavaScript Application Performance with Web Workers]
서비스 워커 성능 향상 예[Progressive Web App Libaries in Production, 앱 셸 모델, Service workers in Depth, Strategies for service worker caching]
하지만 메인(UI) 쓰레드가 아니기 때문에
- Window Object
- Document Object
- Parent Object
를 사용하지 못한다는 점은 알고 있어야 한다.
워커의 오버헤드 비용 또한 고려해야 한다. [How fast are web workers?]
워커랑 비슷하지만, 가볍고 렌더링시에도 사용할 수 있는 Worklet도 있다.
- Worklets: Worker의 초기 비용이 큰 문제를 해결하기 위한 경량버전, 저수준 렌더링 파이프 라인에 접근가능[worklet, CSS Paint API, CSS Houdini, Houdini: Demystifying CSS, Awesome CSS Houdini]
- PaintWorklet[CSS Paint API]
- AudioWorklet[Enter Audio Worklet, Audio Worklet Design Pattern]
- AnimationWorklet[Houdini's Animation Worklet]
- LayoutWorklet 로 구성
가장 큰 단점은 아직 지원하는 브라우저가 많지 않다.
한가지 주의할 점은 3D 렌더링을 WebGL과 같이 사용할 수 없으며 WebGL의 GLSL 코드를 사용해야 한다.
정리하자면
- 전용 워커: CPU 중심의 작업을 메인 스레드에서 오프로드 할 때 사용
- 공유 워커: 워커간 공유가 필요할 때 사용
- 서비스 워커: 네트워크 요청을 캐싱하거나 오프라인 작업을 위해 사용
- 워크렛: 브라우저 렌더링 파이프 라인에 연결되어, 스타일이나 레이아웃등 렌더링 프로세스에 저수준 접근이 가능
와 같다.
+.
예전에 병렬 연산을 위해 WebCL이란 표준이 있었는데 지원하는 브라우저가 없는 모양.[WebCL]
해결방법
- 기초 사용방법
가장 보편적인 전용 워커, 서비스 워커, 페인트 워크렛의 예제를 들어본다.
전용 워커
new Worker를 이용해 생성하며, postMessage와 onmessage로 통신한다. [“Off The Main Thread”]
확장기능을 만들어본 사람은 background와 content의 통신방식과 유사해 적응이 쉬울것이다.
/* main.js */ // Create worker const myWorker = new Worker('worker.js'); // Send message to worker myWorker.postMessage('Hello!'); // Receive message from worker myWorker.onmessage = function(e) { console.log(e.data); }
worker에서도 사용방법은 같다.
/* worker.js */ // Receive message from main file self.onmessage = function(e) { console.log(e.data); // Send message to main file self.postMessage(workerResult); }
특히 Offscreen Canvas가 주목받을만 하다.[크로미움 엔진과 오프스크린 캔버스 기술, OffscreenCanvas — Speed up Your Canvas Operations with a Web Worker, Web Worker-Postable 라이브러리 작성기]
서비스 워커
방금전 워커 예제처럼 서비스 워커파일을 참조해서 등록해야 한다. [How to Make your React App a Progressive Web App (PWA)]
/* main.js */ navigator.serviceWorker.register('/service-worker.js');
서비스 워커는 프록시의 역할을 하기 때문에 모든 네트워크 요청을 가로챌 수 있다.
/* service-worker.js */ // Install self.addEventListener('install', function(event) { // ... }); // Activate self.addEventListener('activate', function(event) { // ... }); // Listen for network requests from the main document self.addEventListener('fetch', function(event) { // ... });
예를 들어 캐시에서 문서를 반환하도록 만들어 오프라인 상태로 작동시킬 수도 있다.
PWA에 필수적인 요소니 꼭 알아두었으면 한다.[고성능 서비스 워커 로딩, Lazy load your images with Service Worker]
/* service-worker.js */ const cacheName = 'shell-content'; const filesToCache = [ '/css/styles.css', '/js/scripts.js', '/images/logo.svg', '/offline.html’, '/’, ]; self.addEventListener('install', function(e) { console.log('[ServiceWorker] Install'); e.waitUntil( caches.open(cacheName).then(function(cache) { console.log('[ServiceWorker] Caching app shell'); return cache.addAll(filesToCache); }) ); });
또는 이런식으로.
/* service-worker.js */ self.addEventListener('fetch', function(event) { // Return data from cache event.respondWith( caches.match(event.request); ); });
페인트 워크렛
역시나 JS 파일을 등록한다.
/* main.js */ CSS.paintWorklet.addModule('myWorklet.js');
그 후, 사용자 정의 이미지를 만드는 예. Canvas API와 유사하다.
/* myWorklet.js */ registerPaint('myGradient', class { paint(ctx, size, properties) { var gradient = ctx.createLinearGradient(0, 0, 0, size.height / 3); gradient.addColorStop(0, "black"); gradient.addColorStop(0.7, "rgb(210, 210, 210)"); gradient.addColorStop(0.8, "rgb(230, 230, 230)"); gradient.addColorStop(1, "white"); ctx.fillStyle = gradient; ctx.fillRect(0, 0, size.width, size.height / 3); } });
만든 사용자 정의 이미지는 CSS에서 적용하면 끝!!
div { background-image: paint(myGradient); }
- 라이브러리
워커들이나 워크렛을 그냥 사용하기에는 불편한 점들이 많다.
대표적인 라이브러리들을 소개해본다.
웹 워커
웹 워커를 이용하기 쉽게 만든 라이브러리
- Comlink: 구글이 만든 것으로 잘 추상화 시켜놨다. [comlink-loader]
- Greenlet: async 함수를 웹워커로 처리
- Workerize: 모듈을 웹워커로 처리
- Workly: 역시 함수나 클래스를 워커로 처리도록 만든 것
- Operative: 콜백 형식으로 처리하도록 만듦
동시성이나 병렬 처리시 좋은 라이브러리.
리엑트에서 사용하기 좋은, 또는 아이디어가 재미있는 것
- useWorker
- react-worker-image
- partytown: 제 3자 스크립트를 웹워커에서
서비스 워커
- Workbox: 구글이 PWA에 쓰라고 잘 만든 라이브러리. (sw-toolbox + sw-precache + sw-offline-google-analytics)
- UpUp: 한줄의 명령으로 오프라인 기능 제공
- offline-plugin: 서비스 워커와 앱캐시를 웹팩에서 쓰기 편하게
- StreamSaver.js: 스트리밍 받아 파일로 저장
- Manifest Generator: 푸시 알림 또는 PWA에 필요한 웹 앱 매니페스트 생성기
워크렛
- extra css: 웹사이트 꾸미기에 좋은 Paint API 라이러리
3.2.2 그래픽 최적화
개요
분류: Content, CSS, Javascript
그래픽 최적화의 핵심은 GPU를 어떻게 써먹는가이다. [하드웨어 가속에 대한 이해와 적용, Animation performance and frame rate]
하드웨어 가속은 Graphics Layer 단위로 처리되며, GPU를 이용해 이미지로 합성(Composition)해 화면에 출력한다.
다음은 하드웨어 가속 대상이다.
- CSS 3D Transform(translate3d, preserve-3d 등)이나 perspective 속성이 적용된 경우
- <video> 또는 <canvas> 요소
- CSS3 애니메이션 함수나 CSS 필터 함수
- 자식 요소가 레이어로 구성
- z-index값이 낮은 형제 요소가 레이어로 구성된 경우
그러나 하드웨어 가속은 조심히 사용해야 하는데 5가지만 뽑아보았다.
- 메모리: 많은 텍스처를 로드하면 메모리 문제가 발생할 수 있다.
특히, 모바일 장치에서 문제가 생길 수 있으며, 모든 요소에 하드웨어 가속을 적용하면 안된다. - 깜박임: 영역이 크면 화면이 깜박일 수도 있다.
- 변경: 컨텐츠 내용이 변경되면 GPU 메모리에 다시 업로드 해야한다.
- 글씨 렌더링: CPU와 CPU의 렌더링 방식으로 인해 안티얼라이싱에 영향을 주어 흐리게 표시될 수 있다. [GPU text rendering in webkit]
- 기기: 가속을 지원하지 않는 기기에서는 성능저하가 생길 수 있다.
또한 애니메이션 레이어의 컨텐츠 내용을 변경하지 않는 것이 좋다.
해결방법
웹에서 그래픽을 표현할 때 여러가지 방법이 존재한다. [A Comparison of Animation Technologies, Compare the options for Animations on the Web]
- CSS: 앞서 언급한 것. [An Introduction to Hardware Acceleration with CSS Animations, CSS GPU Animation: Doing It Right(번역), CSS 애니메이션 성능 개선 방법(reflow 최소화, will-change 사용), Why Moving Elements With Translate() Is Better Than Pos:abs Top/left, CSS 애니메이션의 성능 아는 만큼 좋아져요!]
- Canvas: javascript와 <canvas>를 이용해 그래픽을 그릴 수 있는 API.
- SMIL: SVG 애니메이션, deprecated 됨.
- WebAnimation API: 애니메이션을 만들 수 있는 자바스크립트 API
- WebGL: OpenGL(1.0: ES 2.0, 2.0: ES 3.0) 기반 API를 이용해 HTML canvas에 렌더링하는 것[WebGL 기초], 최근 WebGL 2.0이 메이져 브라우저에서 지원된다.
- WebGPU : WebGL의 뒤를 이을 표준, 아직 만들어지고 있는 중 (Apple에서는 WebGPU 라는 이름으로 사용하다가 WebMetal로 이름이 바뀜) [WebGPU, WebGPU and WSL in Safari, GPU Web]
애플의 사례를 볼때 WebGL보다 성능에서 유리한듯.
CSS와 자바스크립트의 애니메이션에 대해 다음 문서에서 간략히 다루고 있다.[CSS와 자바스크립트 애니메이션, CSS and JavaScript animation performance]
정리하자면,
- 간단한 애니메이션은 CSS를 사용
- 복잡한 것은 WebAnimation API 사용 [Animating like you just don’t care with Element.animate]
- 3D나 더욱 세밀한 제어, VR등에서 필요시 WebGL 사용
- 간단할 때 CSS의 성능이 SVG보다 낫고, deprecated 되어 사용하지 않는게 좋음 [Weighing SVG Animation Techniques(with Benchmarks)]
CSS
GPU 가속이 되도독 작성해야 한다.
현재는 opacity와 transform 정도만 지원. [AMP - 지원하는 CSS, Stick to Compositor-Only Properties and Manage Layer Count, Highperformacne Animations]
다음과 같이 대체하여 사용할 수 있다.
Motion One에 따르면
filter
,background-color
,clip-path
, SVG도 많은 브라우저에서 지원한다고 한다.element { /* Box shadow Accelerate */ box-shadow: 10px 10px black; filter: drop-shadow(10px 10px black); /* Border radius Accelerate */ border-radius: 50px; clip-path: inset(0 round 50px); }
position을 absolute나 fixed로 설정하면 주변 영역에 영향을 주지 않는다.
약간의 꼼수로 컴포지터 쓰레드로 레이어를 분리시켜 성능을 높일 수 있다.
will-change를 사용하면 애니메이션이 곧 일어날 것이라고 알려줘 응답성을 개선할 수 있다.
하지만, 레스터화 시키고 우선순위를 변경하는 것이기 때문에 신중히 사용하고 해제해야 한다. [CSS will-change 프로퍼티에 관해 알아둬야 할 것]
.moving-element { will-change: transform; }
will-change가 먹히지 않는 브라우저라면, 강제로 적용 가능하다.
.moving-element { transform: translateZ(0); }
물론 CPU와 GPU의 대역폭, GPU의 메모리, 모바일의 하드웨어 가속여부 및 리소스 크기를 따져가며 작업해야 한다.
몇가지 괜찮은 라이브러리들
- Motion One: Web Animations API로 구축되었으며 하드웨어 가속을 적극 활용
- shifty: GSAP(GreenSock Animation Platform)보다 메모리를 적게 사용
- PixiJS: 2D 라이브러리
- Three.js: 3D 라이브러리
나중에 시간이 나면 더 서술할 예정.
3.2.3 웹 어셈블리
개요
분류: wasm
웹 어셈블리는 네이티브에 가까운 성능으로 동작하는 바이너리 포맷을 제공하는 프로젝트로 C/C++, Rust 등의 컴파일 타겟으로 사용될 수 있다.
나오게 된 계기는 WebAssembly, 브라우저에 올리는 네이티브 코드를 살펴보면 재미있게 읽을 수 있다.
A cartoon intro to WebAssembly(번역)도 읽을만한 하구.
웹브라우저에서 웹 어셈블리를 위한 컴파일러를 제공하는 것도 주목할만 하다.
- 파이어폭스의 스트리밍 + 계층형 컴파일러(계층형 컴파일러가 궁금하면 닷넷문서 참고)
- 웹킷의 BBQBuild Bytecode Quickly)와 OMG(Optimized Machine-code Generator) 계층형 컴파일러
- 블링크의 Litoff 컴파일러
유니티에 따르면 상당히 성능이 좋다. [WebAssembly LoadTimes and Performance]
메인 스크린에 보이는 총시간(낮은 것이 좋음)
총점수(높은 것이 좋음)
그렇다면, 웹 어셈블리의 성능은 왜 좋을까?
- 웹 어셈블리 코드의 크기가 작음
- 웹 어셈블리의 디코딩 시간이 자바스크립트 파싱 시간보다 적음
- 이미 컴파일 되어 있는 상태라 최적화가 잘되어 있음
- 타입이나 기타 정보가 있어 최적화에 유리
- 명령어셋을 사용할 수 있음
- 직접 메모리를 관리해 GC가 필요 없음
써보려면 MDN 문서를 정독해보는 것이 좋다.
물론 DOM에 접근할 수 없기 때문에 자바스크립트를 완전 대체하기보다 CPU 성능 집약적인 작업이나 기존 C/C++ 코드를 포팅해서 사용할 때 유용할 것이다.
Dodrio나 asm-dom처럼 Virtual DOM을 웹 어셈블리로 하는 사례도 있는 듯.
현재로서 가장 좋은 성능을 내고 싶다면 WebGL + WASM(쉐이더) + Worker 조합인듯
web-sys라는 러스트 라이브러리가 잘 되어 있는 듯 하다.
언젠가는 PWA로 그래픽좋은 게임을 설치해서 플레이 할 수 있지 않을까
3.2.4 전용 페이지
개요
분류: Content
컨텐츠를 전용 페이지로 만들어 제공하면 빠르고 로드 할 수 있다.[Instant loading on mobile: should you AMP-ify / MIP-ify your website in 2018?, Facebook Instant Articles vs. Google AMP, AMP and Turbo Pages: Pros, Cons, and Implementation Results, What do Facebook Instant Articles, Apple News & Google AMP mean for publishers?]
처음에는 컨텐츠를 일정한 UI로 만들어 통일성을 만들고, 광고 수익을 챙기고, 접속율을 늘리려는 과정에서 다음과 같은 것들이 등장하였다.
- 페이스북: Instant Article [Why Is Facebook Instant Articles Faster?, Instant Articles Now Open to All Publishers, Facebooks's instant articles arrive to speed up the News Feed]
- 애플: Apple News [Apple News Document]
- 텐센트(위챗): Mini Program [Wechat Mini Program: an epic guide]
이 중 유의미한 성능과 관련된 결과를 보여주는 인스턴트 아티클을 살펴보자면
- 사이트의 CSS, Javascript, 광고 및 분석 등을 아낄 수 있음
- 복잡한 레이아웃, 인터페이스 렌더링 필요 없음
- 페이스북에서 사진, 비디오를 빠르게 로드하는 것과 동일한 기술
- 스토리에 접근할 때 스토리를 미리로드 시작
애플의 경우, 이미지 캐싱과 JSON 포맷이라 가벼워 성능에서 유리한 편.
폐쇄적인 플랫폼에서만 쓰였던 컨셉이 열려있는 플랫폼(검색엔진)에도 적용되기 시작했다.
AMP와 MIP는 동일한 기술.
AMP는 Yahoo Japan과 Sogou에서도 지원한다고 한다. [Accelerated Mobile Pages rolling out to a billion Yahoo Japan, Baidu, & Sogou users in Asia]
AMP가 성능을 높힐 수 있었던 주요 이유는 다음과 같다. [AMP는 어떻게 웹 페이지의 성능을 높일 수 있나, AMP 작동 원리]
- AMP HTML: 커스텀 엘리먼트를 사용하여 레이아웃 계산 최소화
- AMP JS: 리소스 로딩 관리(비동기, 느린 것 차단) 및 커스텀 엘리먼트 제어.
- AMP Cache: 프록시 기반 CDN으로 캐시를 이용, 모든 스크립트와 이미지를 같은 출처(Origin)에서 다운 가능
- 제한: 서드파티 CSS, JS 미허용 및 용량 제한, GPU 가속 애니메이션만 허용, 상태 및 이벤트 관리
이 페이지에서 나오는 것들이 빡시게 적용되었다는 것을 알 수 있다.
Turbo Pages의 컨셉을 살펴보면 AMP와 Instant Page 사이.
3.2.5 자바스크립트 코딩 스타일
개요
분류: Javascript
코딩 스타일을 변경하는 것만으로도 성능을 향상 시킬 수 있다.[자바스크립트는 어떻게 작동하는가: V8 엔진의 내부 + 최적화된 코드를 작성을 위한 다섯 가지 팁, 성능을 높이는 코드 스타일(다른 버전1, 다른버전2, 다른버전3), How To Write Fast, Memory-Efficient JavaScript, Let’s get those Javascript Arrays to work fast, Optimizing dynamic JavaScript with inline caches, JavaScript Garden, React-Cliff, 당신이 모르는 자바스크립트의 메모리 누수의 비밀, amark/gun - Performance, Optimiztion-killers, JS의 객체는 hash table이 아닙니다!, JavaScript's Memory Management Explained, 자바스크립트 성능 최적화(1, 2, 3, 4, 6, 7, 8)]
이를 위해 미리 알아두어야 할 지식이 있다.
- 스코프 체인(Scope Chain)
크게 함수 내부에서 접근 가능한 활성화 객체와 전역 객체로 나눌수 있다. [Js Principles Scope Chain Readme, How do JavaScript closures work under the hood]
함수에서 지역변수나 this, arguments등은 활성화 객체, 전역변수, document 등은 전역 객체다.
식별자를 찾을 때, 자신이 속한 스코프에서 찾은 후 식별자가 없으면 상위 스코프에서 찾는다.
상위 스코프로 어떻게 접근할까?
답은 바로 스코프 체인이다.
각 객체에 접근하기 위한 객체의 참조가 특정한 공간에 저장되어 있으며 이를 스코프 체인이라 한다.
실행 문맥 -> 스코프 체인 -> 활성화 객체 -> 스코프 체인 -> 전역 객체
와 같이 접근하게 된다.
- 객체 초기화와 인라인 캐싱
자바스크립트의 객체 접근은 느리다.
해시 함수를 이용해 객체 속성 값의 위치를 메모리에 저장하기 때문이다.
이는 동적으로 할당 가능해야 하는 자바스크립트의 특성 때문.
이를 보완하기 위해 자바스크립트 인터프린터는 고정적인 객체 레이아웃인 히든 클래스를 만든다.
function Point(x, y) { this.x = x; // New Hidden Class based C0 - C1 this.y = y; // New Hidden Class based C1 - C2 } const p1 = new Point(1, 2); // Initcial Hidden Class - C0
또한 두번 이상의 속성 호출에 성공하면 변하지 않았다고 가정하고, 직접 점프를 수행한다. (인라인 캐싱)
컴파일 과정을 배워본 사람, 또는 C로 코딩을 조금이라도 해본 사람이라면 인라인닝을 들어본적이 있을 것이다.
만약 다른 히든 클래스라면 인라인 캐싱이 깨지는 일이 벌어지게 된다.
내가 알기로 인라인 캐싱을 가장 잘 사용하는 라이브러리 중 하나가 most.js [Improving performance, How comes it so much faster than LoDash?]
V8엔진에서 --trace_inlining 옵션을 켰을때 다음과 같이 나온다고.
Inlined sum called from AccumulateSink.event. Inlined noop called from Observer.event. Inlined Observer.event called from AccumulateSink.event. Inlined even called from FilterMapSink.event. Inlined add1 called from FilterMapSink.event. Inlined sum called from AccumulateSink.event. Inlined noop called from Observer.event. Inlined Observer.event called from AccumulateSink.event. Inlined AccumulateSink.event called from FilterMapSink.event. Inlined even called from FilterMapSink.event. Inlined add1 called from FilterMapSink.event. Inlined sum called from AccumulateSink.event. Inlined noop called from Observer.event. Inlined Observer.event called from AccumulateSink.event. Inlined AccumulateSink.event called from FilterMapSink.event. Inlined FilterMapSink.event called from produce. 250000000000
해결방법
마이크로 최적화가 될 수 있지만..
위 지식을 기반으로 최적화된 코드 스타일을 도출 할 수 있다.
- 가비지 컬렉션
글로벌 변수는 가비지 컬렉션 대상이 아니다.
페이지를 새로 고치거나 다른 페이지로 이동하거나 탭을 닫거나 브라우저를 종료할 때 전역이 정리된다.
반면 함수 범위 변수는 변수가 범위를 벗어나면 정리된다.
함수가 종료되고 함수에 대한 참조가 더 이상 없으면 변수가 정리된다.
const globalVar = {}; // Bad // Good fucntion fn() { const localVar = {}; }
- 리터럴 할당
리터럴로 할당하는게 좋다.
배열은 리터럴로 할당하는게 빠르며,
객체는 리터럴로 할당하는게 아주 살짝 느리지만 코드의 양이 늘어나 다운로드 받는 시간과 일관성을 생각하면 리터럴이 낫다.
스트링도 리터럴이 살짝 빠르다.
const arr1 = new Array() // Bad const arr2 = [] // Good const obj1 = new Object() // Bad const obj2 = {} // Good const str1 = new String('ABCDEFGHIJKLMNOPQRSTUVWXYZ') // Bad const str2 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' // Good
단, Object의 경우 JSON.parse()를 사용하는게 더 빠름. [Faster apps with JSON.parse(Chrome Dev Summit 2019), 왜 JSON.parse로 객체를 선언하는 방법이 더 빠를까?]
- 31비트 값
V8은 객체와 숫자를 32비트로 표현한다.
객체와 숫자여부를 판단하기 위해 SMI(Small Integer, 각각 1, 0) 비트를 쓰기 때문에 31비트가 남는다.
만약 31비트를 넘는다면 double 타입으로 전환 후 새로운 객체를 생성하여 성능을 깎아먹는다.
따라서 31비트 안(2,147,483,647)에서 사용하는게 좋다.
- 문자열
짧을때는 +=, 길때는 join함수를 사용하는게 좋다.
// Short let str1 = ""; str1 += "short" // long const arr = []; arr.push("long"); const str2 = arr.join('');
- 배열
희소 배열(sparse array)은 피하는게 좋다.
값이 꽉 채워지지 않은 배열은 해시 테이블과 같으며 접근 비용이 크다.
// Bad const arr = new Array(5); const arr[2] = 3; const arr[5] = 1; // Good const arr = []; const arr.push(3); const arr.push(1);
배열의 요소를 삭제도 마찬가지.
배열의 키가 띄엄띄엄 배치되어 좋지 않다.
차라리 null로 할당하는게 낫다.
const arr = [3, 1]; delete arr[1]; // Bad arr[1] = null; // Good
배열 인덱스는 정수에 최적화 되어 있다.
const arr = []; const arr["1"] = 3; // Bad const arr[0] = 1; // Good
또한 커다란 배열을 미리 할당하지 않는게 좋다.
오히려 사용하면서 크기가 커지도록 하는 게 낫다.
상식적(?)으로 이게 말이 되냐 싶었는데.. 벤치마크 결과가 그런다니..
const let = new Array(1000000); // Bad const arr = []; // Good
push보다 접근자로 할당하는게 더 빠르다.
const arr = []; arr.push(3); // Bad arr[1] = 5; // Good
객체가 존재하는지 확인하려면 hasOwnProperty가 IndexOf보다 낫다. [Performance comparison between hasOwnProperty and IndexOf]
arr.indexOf(1000000) // Bad arr.hasOwnProperty(1000000) // Good
- 객체 접근
차이는 거의 없으나 .이 []로 접근하는 것보다 살짝 빠르다.[Javascript performance consideration. Is dot operator faster than subscript notation?]
개인적으로 IDE 지원면에서 .을 사용하는게 마음에 들더라.
const obj = {a: "test"}; obj['a']; // Bad obj.a // Good
스코프 체인 때문에 지역변수로 참조하는게 좋다. [JavaScript Prototype Chains, Scope Chains, and Performance: What You Need to Know(번역)]
// Bad const arr = []; function addFn () { arr.push("test"); } // Good const arr = []; function addFn () { const localArr = arr; localArr.push("test"); }
객체도 배열과 마찬가지로 직접 삭제는 나쁜 생각이다.
delete를 한다고 가비지 컬렉션이 일어나지 않으며, 히든클래스만 변경시켜 악영향을 끼친다.
const o = { x: 1 }; delete o.x; // Bad o.x = null; // Good
역시 히든 클래스 글을 읽으며 알아챘겠지만,
객체를 생성하고 할당할 때, 일정한 순서대로 하는게 좋다.
// Bad const p1 = new Point(1, 2); p1.a = 5; p1.b = 6; const p2 = new Point(3, 4); p2.b = 7; p2.a = 8; // Good const p3 = new Point(1, 2); p3.a = 5; p3.b = 6; const p4 = new Point(3, 4); p4.a = 7; p4.b = 8;
순서대로 할당하는게 가독성 측면에서도 바람직하기도 하다.
- 동적 속성보단 생성자 사용
객체 생성 후에 속성을 추가하면 히든 클래스가 변경되며, 최적화가 되었던 것들이 해제된다.
따라서 처음에 생성자로부터 할당하는게 좋다.
// Bad function Point(x, y) { this.x = x; this.y = y; } const p1 = new Point(1, 2); p1.a = 5; p1.b = 6; // Good function Point(x, y, a, b) { this.x = x; this.y = y; this.a = a; this.b = b; } const p1 = new Point(1, 2, 5, 6);
- 병합하기
작을때는 스프레드, 클때는 concat이 유리하다. [How to Efficiently Merge Arrays in JavaScript]
- 복제하기
Deepcopy의 경우, 아직 공식명세에는 없지만 주요 브라우저들에 구현된 structuredClone()을 사용해볼 수 있다. [structuredClone(): deeply copying objects in JavaScript]
- 상속 활용
상속을 활용하면 메모리를 아낄 수 있다. [Prototype's real world usage: optimizing memory usage, Creating Memory-Optimized Instances with Constructor Functions and Prototypes]
많은 객체를 생성해야 할 경우 메소드까지 매번 생성 시키는 것은 메모리 관리에서 비효율적이다.
실제로 이러한 이유로 fabric.js는 상속을 활용한다.
// Bad function Point(x, y) { this.x = x; this.y = y; this.add = function() { return this.x + this.y; } } const p1 = new Point(1, 2); // Good - Protype function Point(x, y) { this.x = x; this.y = y; } Point.prototype.add = function() { return this.x + this.y; } const p2 = new Point(1, 2); // Good - ES6 Classes class Point{ constructor(x, y) { this.x = x; this.y = y; } add () { return this.x + this.y; } } const p3 = new Point(1, 2);
객체 생성관련 내용 이 글들을 참고바란다. [JavaScript Best Practices — Object Creation, Details of the object model, Javascript prototype inheritance, JavaScript 다양한 상속 방법]
Protype과 Class에서 성능차가 나긴 하는데, 서로 빠르다는 말이 있어서..ㅋㅋ
그러나 중요한점!! [The performance hazards of [[Prototype]] mutation]
[[prototype]]은 수정하지 않는 걸 전제로 최적화 되어있다.
성능을 중요시 한다면 수정을 해서는 안된다.
- 객체 할당 최소화
옛날에 Promise가 구현이 안되거나 느렸을때 아주 효율적인 구현을 보여준 Bluebird 제작자의 조언이다. [Three JavaScript performance fundamentals that make Bluebird fast]
- 함수 내부에 함수 선언 X
- 객체 크기 최소화
- 계산이 아닌 함수는 일단 적용하고, lazy하게 덮어쓰며 선택적 기능을 구현
- 동일한 함수
동일한 함수를 반복적으로 사용하는 방식이 인라인 캐싱에 유리하다.
// Bad function add1(a, b) { return a + b; } function add2(a, b) { return a + b; } const a = add1(1, 2); const b = add2(3, 5); // Good function add(a, b) { return a + b; } const a = add(1, 2); const b = add(3, 5);
- 동일한 타입
여러 타입을 섞어서 쓰면 최적화에 나쁘다.
// Funciton function add(x, y) { return x+y; } add(1, 2); add('a','b'); add(my_custom_object, undefined); // Array const arr = [1, “1”, undefined, true, “true”]);
최근 자바스크립트에서는 TypedArray(형식화배열)를 지원하니 살펴보자.
TypedArray를 사용한 String의 경우 StringView를 참고하라.
- 배열 반복
이번에는 반복이다 [About JavaScript Loop Performance(forEach, for, while, etc…), Which Is the Fastest: While, For, forEach(), For…of?, How to optimize your JavaScript apps using Loops]
while >= for > for of > forEach(native) > forEach(loadsh) > each(underscore)
순이다.
Native가 빠르며, 우리가 C/C++, Java등에서도 배웠듯 더 빠른 것을 알 수 있다.
성능 특성은 반복해야 할 갯수에 따라서도 달라질 수 있다.
작은 곳에서는 For of가, 클때는 While이 유리하며
For은 평균, ForEach는 항상 느리다는 듯 하다.
그럼 map, filter, reduce 성능과 비교하면 어떨까?? [Performance-Analysis JS, for, foreach, filter, map, reduce 기능 및 성능 비교]
For이 Native 고차함수들 보다 빠름을 알 수 있다.
참고로 객체를 순환할 때 쓰는 For in문은 확연히 느린 편이며 가능하다면, 다른 구분을 사용하는게 좋다.
배열이 아니라 일반 객체로 취급하며, 반복 때마다 property에 접근해야 하기 때문이다.
요약
- 클 때: While
- 작을 때: For of
- 네이티브가 빠름
- For in은 상당히 느림
일반적으로 루프 성능이 중요한 프로그램을 짤 일이 있을까 싶으니 For in이 느리다는 점만은 기억해두는것을 추천.
참고로 길이를 지역변수에 할당하면 매번 크기를 체크할 필요가 없고, 역순으로 순회하면 변수가 아닌 상수 0과 비교하므로 약간의 이득이 있다. [실시간 서비스 경험기(배달운영시스템), Are loops really faster in reverse?]
// length를 지역 변수에, 루프 순서를 역으로 for (let i = (array.length - 1); i >= 0; i--) { // for문 내부에 배열을 지역변수에 할당 const item = array[i]; //... };
중첩된 for문 같은 경우, 큰 반복이 안쪽에 있는게 효율적이다. [Does for-loop Nesting Order Affect Performance?]
- 객체 반복
객체를 반복할 필요가 있다면 Map을 사용하는게 좋다. [JavaScript ES6 Map vs Object Performance 비교, Restoring for..in peak performance]
그러나 API로부터 받아오는 정보가 객체 타입이라던지, 일반적인 객체를 이용해야 하는 상황이 있을 수도 있다.
보통의 상황에서는 Object.keys > for in > Object.getOwnPropertyNames, Object.values, Object.entries 순이다. [How to Iterate Over JavaScript Object Entries]
그치만 더 트릭이 필요한 사람이 있을 수도 있다.
그런 분을 위해 이터레이터를 미리 컴파일 하는 트릭을 찾았다. [What's the fastest way to iterate over an object's properties in Javascript?, Faster Collection Iterators]
요약
- 보통의 경우 Map
- 객체 사용이 꼭 필요한 경우 Object.keys + for 루프
- 더 나은 성능이 필요하다면 Pre-Compiled 이터레이터 기법을 써보는게 어떨까
- 비동기/병렬 루프
비동기나 병렬로 빠르게 반복하는 방법도 존재한다. [JavaScript loops - how to handle async/await, map, reduce 함수에서 async/await 쓰기, ]
거의 차이가 안나며 오히려 확연히 느려지는 경우가 발생합니다.
map과 Promise.all의 오버헤드 때문.
프로미스나 Async는 오버헤드가 존재합니다. [The Performance Overhead of JavaScript Promises and Async Await]
그렇다면 워커를 이용해 멀티 쓰레드로 실행한다면?
훨씬 나아지는 것을 확인 할 수 있다.
요약
- 네트워크 연결같이 비동기가 꼭 필요한 경우가 아니라면 굳이 비동기적으로 반복할 필요가 없음
- 빠르게 만들고 싶다면 멀티 쓰레드로.
- 조건문
삼항연산자가 if문보다 성능이 좋다. [Is ternary operator, if-else or logical OR faster in javascript?]
최근에 JS Perf에 들어가서 측정했다.
신기한 점은 ===를 사용할 때가 가장 빨랐으며, ==를 사용할때는 확연히 느렸다는 점이다.
const a = true; let b; // 1st: 590,142,828 Ops/sec b = (a === true) ? true : false; // 2nd: 589,956,625 Ops/sec if (a === true) { b = true; } else { b = false } // 3rd: 574,265,651 Ops/sec b = (a) ? true : false; // 4th: 570,864,623 Ops/sec if (a) { b = true; } else { b = false; } // 5th: 33,590,726 Ops/sec // 94% slower b = (a == true) ? true : false; // 6th: 33,215,602 Ops/sec if (a == true) { b = true; } else { b = false; }
또한, 조건문이 많아질 때는 switch가 if보다 유리하다. [Chapter 4. Algorithms and Flow Control]
이는 고전적인 언어와 똑같은 듯.
- 느긋한 계산
지연 평가를 사용하여 성능 개선을 할 수 있다. [지연 평가(Lazy evaluation) 를 이용한 성능 개선, How to Speed Up Lo-Dash ×100? Introducing Lazy Evaluation(번역)]
보다시피 필요한 계산만 하는 것.
결과
- 정규식
정규식은 컴파일 - 시작위치 결정 - 토큰 대조 - 결과(성공, 실패) 순으로 이루어진다. [High Performance JavaScript - Chapter 5. Strings and Regular Expressions]
정규식 컴파일 시에는 매번 에러가 있는지 검사하게 되므로, 한번만 컴파일되게 하는것이 좋다.
// Bad for (let i = 0; i < 100; i++) { str.replace(/^\s+/, '').replace(/\s+$/, ''); } // Good const reg1 = /^\s+/; const reg2 = /\s+$/; for (let i = 0; i < 100; i++) { str.replace(reg1, '').replace(reg2, ''); }
정규식 작성에 있어 가장 쉬운 최적화는 보다 간단하게 만드는 것이다.
간단한 예는 각종 그룹을 하나로 만드는 것이다. [예제들]
const re = /[a-zA-Z_0-9][A-Z_\da-z]*\e{1,}/; // Bad const re = /\w+e+/; // Good
정규식을 분리시켜 불필요한 탐색이 반복되지 않도록 할 수 있다. (탐색 대상 축소)
아래 동작은 Trim을 하는데, 정규표현식은 문자열 마지막으로 건너뛰는 동작이 불가능하므로 첫번째 코드는 비효율적이다.
trim12의 경우 Faster JavaScript Trim의 최적화가 적용된 코드.
// Bad str.replace(/^\s+|\s+$/g, ""); // Good str.replace(/^\s+/, '').replace(/\s+$/, ''); // Trick function trim12 (input) { const str = input.replace(/^\s\s*/, ''); const ws = /\s/; let i = str.length; while (ws.test(str.charAt(--i))); return str.slice(0, i + 1); }
정규식 매칭 시 느려지는 주요 이유는 "일치하는 것을 찾는데" 걸리는 것이 아니라 "일치하지 않는다고 판단"하는데 시간이 소모되기 때문이다.
- 비트 다루기
전통적인 트릭이다.
관련된 글
쓸만해 보이는 라이브러리
기타 비트를 이용한 트릭은
- 비트 플래그(Bit Flags)
- 비트 필드(Bit Fields)
- 비트 마스크(Bit Masks)
- 비트 어레이(Bit Arrays)
- Enum(타입스크립트 한정)
을 살펴보자.
- Asm.js와 LLJS
자바스크립트만 이용해 정말로 극한까지 짜내보고 싶다!!는 사람은 asm.js(MDN)와 LLJS의 코딩 패턴을 살펴보면 되겠다. [asm.js: 컴파일러를 위한 low level, 고도로 최적화 가능한 JavaScript의 서브셋(번역), Asm.js: The JavaScript Compile Target, High-Performance WebGL Apps with LLJS and asm.js]
요즘 typescript 덕에 알게모르게 많이 쓰이는 strict도 어느정도 도움은 된다고 한다.
Asm.js의 작성법은 human-asm.js, LLJS의 작성법은 공식 홈페이지에서 확인바란다.
LLJS를 asm.js로 컴파일한 사람도 있다. [Compiling LLJS to asm.js, 컴파일 샘플]
댓글