프론트엔드 성능 최적화 가이드라는 책을 읽었다. 해당 책에서 유용한 내용들을 요약해서 정리하고,
추가적인 자료도 넣고자 한다.
크롬 브라우저에서 ctrl-shift-i를 누르면 열리는 그 창이다.
특정 리소스가 어떻게 로드되는지를 확인할 수 있다.
스로틀링을 걸 수 있으며, Fast 3g는 다운로드 1500kbps, 업로드 750kbps, Slow 3g는 다운로드 780kbps, 업로드 330kbps이다. 커스텀 스로틀링을 걸 수도 있다. (커스텀 스로틀링에서 레이턴시는 대략 20 정도로 건다)
Network 패널의 리소스를 클릭하면 리소스의 주소, header, response 등의 정보를 확인할 수 있다.
리소스가 로드되는 어떻게 로드되는지 뿐만 아니라 실행중인 자바스크립트 파일들의 스택도 보여준다. 어떤 코드들이 성능을 저하시키는지 확인할 수 있다.
CPU 작업량과 어떤 작업인지,
프레임 변화가 있을 때마다의 렌더링 결과,
자바스크립트 컴포넌트들의 timings 플레임 차트(리액트 17 이상 부터는 리액트 개발자 도구를 설치해야 거기서 봐야 함!)
어떤 작업에 얼만큼의 시간이 소요되었는지에 대한 summary
구글에서 만든 성능 측정 도구이다. 성능 측정의 결과를 Web Vitals라 부른다
PERFORMANCE: 총점
FCP(First Contentful Paint): Dom 콘텐츠의 첫번째 부분을 렌더링하는데 걸리는 시간 - 10%
LCP(Largest Contentful Paint): 가장 큰 이미지 혹은 텍스트가 렌더링될 때의 시간 - 25%
TTI(Time to Interactive): 사용자와 상호작용이 가능해지는 시점까지의 시간 - 10%
Total Blocking Time(TBT): 사용자의 클릭, 키보드 등의 인풋을 차단하는 시간, FCP와 TTI 사이의 시간을 의미한다. 30%의 가중치
Cumulative Layout Shift(CLS): 예기치 못한 레이아웃 이동 등을 측정한 지표. 가령 이미지가 로딩되면서 레이아웃이 이동되는 등 - 15%의 가중치
성능 점수 및 지표 결과를 아래로 내리면 Opportunities(문제점)와 Diagnostics(해결방안)가 나온다.
React Developer Tools을 설치한 후, 개발자 도구의 Components에서 Higlight updates when components render.를 체크하면 컴포넌트가 리렌더링 될 때 확인할 수 있다.
나머지는 벨로퍼트님 강의자료 보면서 적용하면 된다.
UseMemo
UseCallback
React.memo
useSelector 최적화
특히 useSelector가 반환하는 값이 범객체라면, 각 컴포넌트는 마운트 될 때에 새로운 객체를 반환받는 것이므로 state가 바뀐 것으로 인식하여 자신과 그 자식요소 전부를 리렌더링하게 된다. useSelector가 원시값을 반환하도록 수정하거나, 비구조화 할당을 통해 원시값을 반환받도록 할 수 있다.
혹은 책 212페이지에 소개한 대로 shallowFn을 넣어 해결할 수 있다. 공식문서 리액트 리덕스에서 제공하는 shallowEqual를 shallowFn으로 넣으면, 얕은 비교를 통해 값이 바뀌었을 때만 반환해준다.
Properly size images는 픽셀에 비해 지나치게 큰 이미지가 있을 때에 뜨는 진단 결과이다.
가령 300px의 정사각형 안에 4000px이 들어가는 것은 적절하지 않다.
애플은 아이폰의 레티나 디스플레이와 함께 'pixel ratio'라는 개념을 제시했다. 가령 480 × 320 픽셀을 갖는 아이폰3gs와 960 x 640을 갖는 아이폰 4는 ppi가 2배 차이나고, 같은 넓이의 공간에 4배의 픽셀이 들어간다. 이때 아이폰4에 pixel ratio를 2로 적용하면, 아이폰4는 아이폰3gs와 같은 웹 픽셀을 같은 같은 ui를 가지게 되지만 실제로 물리적인 픽셀은 4배가 들어나는 식이다.
요컨데, 이러한 고해상도 디스플레이와 pixel ratio를 위해서 너비(웹픽셀) 300px 정사각형 안에 너비(물리적 크기) 600px의 사진을 넣으면 된다.
여러 API에서 CDN을 통한 사이즈 최적화를 지원한다. 이 경우 uri의 파라미터를 넣어주면 된다.
가령 https://images.unsplash.com/photo-1534534
와 같은 이미지 주소가 있다면 https://images.unsplash.com/photo-1534534?w=600&h=600&q=80&fm=jpg&fit=crop
와 같은 방식이다.
webp는 웹에 최적화된 이미지이다.
PNG와 동일한 수준의 화질에,
용량은 JPG나 PNG의 1/20 수준으로 적다.
무손실 압축과 손실 압축을 모두 지원해 손실압축까지 한다면 1/40 수준으로 용량이 줄기도 한다.
https://squoosh.app에서 webp 변환을 손쉽게 할 수 있다.
노드 환경에서 실시간으로 변환시키기 위해서는 web.dev의 해당 글을 참조하여 imagemin-webp-webpack-plugin을 설치해 사용하자.
뷰포트의 크기에 따라 이미지를 넣기 위해서는 스택 오버플로의 이 예시를 확인해보자.
<img src="flower_500px.jpg"
srcset="flower_750px.jpg 1.5x, flower_1000px.jpg 2x"
width="500px" alt="flower" />
<picture>
<source media="(min-width: 45em)" srcset="flower_1000px.jpg">
<source media="(min-width: 32em)" srcset="flower_750px.jpg">
<img src="flower_500px.jpg" alt="flower">
</picture>
최근의 브라우저에서는 img태그와 picture 태그를 모두 지원한다.
이때 picture 태그는 img 태그와 달리 브라우저의 지원 확장자에 따른 이미지 표현도 가능하다.
<picture>
<source media="(min-width: 45em)" srcset="img/flower_1000px.webp" type="image/webp">
<source media="(min-width: 45em)" srcset="img/flower_1000px.jpg" type="image/jpeg">
<source media="(min-width: 32em)" srcset="img/flower_750px.webp" type="image/webp">
<source media="(min-width: 32em)" srcset="img/flower_750px.jpg" type="image/jpeg">
<img src="img/flower_500px.jpg" alt="flower">
</picture>
.wrapper {
position: relative;
width: 160px;
padding-top: 56.25% /* 16대 9비율로 패딩값을 준다 */
}
.image {
position: absolute; /* wrapper의 사이즈로 absolute;로 위에 띄운다 */
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.wrapper {
width: 100%;
aspect-ratio: 16 / 9;
}
.image {
width: 100%;
height: 100%;
}
책에서는 외부 라이브러리를 사용하는 방법이 나와있는데,
그 밖의 방법들을 적어보고자 한다.
이미지 로딩을 하는 방법은 기본적으로 이미지의 위치가 스크롤 안인지를 파악하는 것이다. 그 말인 즉슨 스크롤을 할 때마다 이벤트가 발생하여 오히려 연산이 증가할 수 있음을 의미한다.
이에 대한 대안으로 Intersection Observer API는 2016년 크롬 51부터 지원하게 되었다. Intersection, 즉 요소와 화면이 교차할 때에, Intersection이 발생하고, Observing이 시작된다. 이를 통해 연산을 크게 줄일 수 있다.
1은 이미지가 화면의 영역에 완전히 들어온 경우, 0은 이미지가 교차를 시작한 경우, 0.5는 이미지가 화면에 50% 들어온 경우 등을 의미한다.
Yoon's devlog 자바스크립트 예시, NUKEGUYS BLOG 리액트 예시, J4J Storage 리액트 + 플레이스 홀더 예시
그 중 betterprogramming의 예시가 간결하여 퍼왔다.
import React, { useState, useRef } from 'react';
import classnames from 'classnames';
import { useIntersection } from './intersectionObserver';
import './imageRenderer.scss';
const ImageRenderer = ({ url, thumb, width, height }) => {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const imgRef = useRef();
useIntersection(imgRef, () => {
setIsInView(true);
});
const handleOnLoad = () => {
setIsLoaded(true);
};
return (
<div
className="image-container"
ref={imgRef}
style={{
paddingBottom: `${(height / width) * 100}%`,
width: '100%'
}}
>
{isInView && (
<>
<img
className={classnames('image', 'thumb', {
['isLoaded']: !!isLoaded
})}
src={thumb}
/>
<img
className={classnames('image', {
['isLoaded']: !!isLoaded
})}
src={url}
onLoad={handleOnLoad}
/>
</>
)}
</div>
);
};
export default ImageRenderer;
Intersection Observer API는 무한 로딩에도 구현된다.
placeholder 이미지를 지정할 때에,
wrapper 이벤트의 배경화면 자체를 placeholder이미지로 만든 후, 이미지 태그로 그 위를 덮어 씌우면 된다.
하지만 loading여부에 따라 이미지를 교체하고 싶은 경우, onLoad 이벤트를 활용하면 된다.
엘리먼트가 로딩되고난 후에 load 이벤트를 발생시킨다.
스택 오버플로우의 이 예시를 보자
function Test() {
const [loading, setLoading] = useState(true);
return <>
<div style={{display: loading ? "block" : "none"}}>
{showLoader()}
</div>
<div style={{display: loading ? "none" : "block"}}>
<img
className="w-full"
src={item?.image}
onClick={() => setId(item?.id)}
onLoad={() => setLoading(false)} />
</div>
</>;
}
이미지 객체를 생성하여 이미지를 미리 캐싱할 수 있다.
마이구미의 HelloWorld 의 코드를 가져왔다.
function preloading (imageArray) {
let n = imageArray.length;
for (let i = 0; i < n; i++) {
let img = new Image();
img.src = imageArray[i];
}
}
preloading([
'1.png',
'2.png',
'3.png'
])
이러한 프리로딩을 mouseenter 이벤트와 함께 결합하면 고화질의 이미지를 미리 로딩시키는 방식으로 이용할 수 있다.
아래는 책 96페이지에 나온 모달창 사전로딩이다
function app(){
const [showModal, setShowModal] = useState(false)
const handleMouseEnter = () => {
const component = import('./components/ImageModal')
}
return (
<div className = 'App'>
...
<ButtonModal>
onClick={()=>{
setShowModal(true)
}}
onMouseEnter={handleMouseEnter}>
올림픽 사진 보기
</ButtonModal>
...
</div>
);
}
(mouseOver와 mouseEnter의 차이는, mouseOver는 마우스가 요소 위에서 움직일 때마다 이벤트를 발생시킨다. mouseEnter는 마우스가 요소 위에 들어왔을 때 이벤트를 발생시킨다.)
Next.js의 태그 중 하나인 Image 태그를 이용하면 플레이스 홀더, 레이지 로딩, 이미지 사이즈 최적화 등 여러가지 이미지 최적화를 자동으로 해준다.
INGG. 블로그를 참조하면 된다.
위 블로그에 언급되었으나 예시가 없는 base64 이미지 활용법은 sangbooom 벨로그를 참조하자
책 50페이지의 내용이다.
웹팩 번들을 사용하면 자바스크립트를 여러개의 chunk로 스트리밍 한다.
만일 특정 청크파일이 지나치게 크다면 로딩이 지연된다.
npm install webpack-bundle-analyzer
를 이용하여 각 청크 파일의 내용물을 확인할 수 있으...나
CRA로 만든 리액트 프로젝트는 웹팩이 숨겨져 있다.
npm install --save-dev cra-bundle-analyzer
로 설치하고
npx cra-bundle-analyzer
로 실행하면 eject명령 없이도 cra의 번들파일과 그 패키지들을 확인할 수 있다.
import { add } from './math'
console.log('1 + 4 = ', add(1 , 4))
위와 같이 ESM을 import하는 경우 빌드시에 모두 평가되어 번들링된다.
import(moduleName)
구문은 모듈을 비동기적으로 호출한다. 런타임에 실행되며, Promise로 모듈을 반환한다.
Promise로 반환되는 모듈을 받기 위해 리액트의 Lazy함수와 Suspense모듈(18버전 이상)을 제공한다. 다양한 예시는 bbaa3218님의 벨로그에 실려있다. 또한 자세한 import 조건 및 실제 사용예는 code-bebop님 벨로그에 담겨있다.
import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
import Spinner from './items/Spinner'
//import Login from './pages/Login';
//import Main from './pages/Main';
//import Search from './pages/Search';
//import Setting from './pages/Setting';
const Main = lazy(() => import('./pages/Main'));
const Login = lazy(() => import('./pages/Login'));
const Search = lazy(() => import('./pages/Search'));
const Setting = lazy(() => import('./pages/Setting'));
function App() {
return (
<div>
<Suspense fallback={<Spinner text='페이지를 불러오는'/>}>
<Routes>
<Route exact path='/' component={Main} />
<Route path='/login' component={Login} />
<Route path='/setting' component={Setting} />
<Route path='/search/query=:word' component={Search} />
</Routes>
</Suspense>
</div>
);
}
export default App;
위와 같이 page를 동적으로 import하면 chunk파일의 뒷부분으로 컴포넌트들이 옮겨지는 것을 확인할 수 있다. 이후 각 페이지를 접속할 때에, 해당 페이지가 포함된 청크파일을 로딩하게 된다.
CRA의 npm run serve 스크립트에 여러 옵션을 줄 수 있는데 이때 -u 옵션을 주면 텍스트를 압축하지 않는다. -u 옵션이 없으면 텍스트를 gzip으로 압축하여 응답하게 된다.
책 152페이지 내용이다.
FOUT(Flash of Unstyled Text)는 엣지에서 사용되는 방식으로, 일단 기본 폰트로 렌더링하고 폰트가 로딩되면 스타일을 씌우는 방식이다.
FOIT(Flash of Invisible Text)는 폰트가 없으면 일정 시간(3초) 동안 텍스트를 보여주지 않다가, 3초 이후부터 기본 폰트로 보여주고, 이후 폰트가 로딩되면 스타일을 씌우는 방식이다.
중요한건 폰트를 빨리 로딩시켜야 한다는 점. 폰트 로딩이 느리면 크롬이나 사파리를 쓰는 모바일 이용자들이 이탈할 것이다.
https://transfonter.org에서 폰트의 확장자를 WOFF와 WOFF2로 변환할 수 있다. WOFF는 TTF포맷 대비 1/3, 최신 브라우저에서 지원하는 WOFF2는 약 1/4 정도의 용량을 지원한다.
브라우저에 따라 다른 폰트를 적용하자.
@font-face{
font-family: BMYUONSUNG;
src: url('./assets/fonts/BMYUONSUNG.woff2) format('woff2'),
url('./assets/fonts/BMYUONSUNG.woff) format('woff'),
url('./assets/fonts/BMYUONSUNG.ttf) format('truetype');
}
한편 위 사이트에서 Characters를 지정하여 subset폰트를 만들 수 있다. 특정 문자열, 로고, 배너 등에서만 사용되는 폰트라면 Characters를 지정하여 용량을 줄이자.
또한 위 사이트에서 'Base64 encode' 옵션을 켜면 Data-URI 형태로 반환된다. font-face의 url에 Data-URI를 넣으면 CSS파일이 로드되기 전에 폰트가 적용된다.
@font-face룰의 font-display 색인을 통해 FOUT, FOIT를 직접 지정할 수 있다. 이중 'font-display: fallback;'은 FOIT 방식으로 진행하되, 0.1초 후에도 폰트가 로딩되지 않았으면 폰트가 로딩된 후에도 기본 폰트를 유지하는 옵션이다. 폰트가 갑자기 바뀌거나 순차적으로 바뀌는 등의 사용자 경험을 없앨 수 있다.
@font-face{
font-family: BMYUONSUNG;
src: url('./assets/fonts/BMYUONSUNG.woff2) format('woff2'),
url('./assets/fonts/BMYUONSUNG.woff) format('woff'),
url('./assets/fonts/BMYUONSUNG.ttf) format('truetype');
}
책 155페이지 예제이다. 폰트가 다운로드되면 글자가 fade-in하도록 만든다.
@font-face{
font-family: BMYUONSUNG;
src: url('./assets/fonts/BMYUONSUNG.woff2) format('woff2'),
url('./assets/fonts/BMYUONSUNG.woff) format('woff'),
url('./assets/fonts/BMYUONSUNG.ttf) format('truetype');
font-display: block;
}
import FontFaceObserver from 'fontfaceobserver'
const font = new FontFaceOberser('BMYUONSUNG')
function App(){
const [isFontLoaded, setIsFontLoaded] = useState(false)
useEffect(()=>{
font.load(null, 2000)
.then(()=> {
console.log('폰트 로딩됨')
setIsFontLoaded(true)
})
.catch(()=> console.log('2초 초과됨'))
}, [])
return <div style={{opacity: isFontLoaded? 1:0, transition: 'opacity 0.3s ease'}}>아무텍스트</div>
PurgeCSS는 클래스명을 분석하여 사용되지 않은 CSS 클래스는 제거해주는 파일이다.
npm install --save-dev purgecss
이후 PurgeCSS 공식 문서를 이용해 분석할 확장자들을 넣어주자. 공식 문서의 두 번째 방법이 가장 적절하다.
"scripts": {
"postbuild": "purgecss --css build/static/css/*.css --content build/index.html build/static/js/*.js --output build/static/css"
},
만일 테일윈드CSS 등에 사용하는 경우 'lg:m-8'과 같은 특수 문자로 인해 스타일이 깨질 수 있다. 이 경우 Tailwind CSS의 최적화 가이드를 참조하거나, 38LARAVEL 블로그 처럼 정규 표현식으로 처리해주면 된다.
책 170페이지에 있는 내용이다.
run build
를 한 후
serve.js 파일을 살펴보자.
이때 setHeaders
를 아래와 같이 수정한다.
const header = {
setHeaders: (res, path) => {
if(path.endsWith('.html')){
res.setHeader('Cache-Control', 'no-cache')
} else if(path.endsWith('.js') || path.endsWith('.css') || path.endsWith('.webp'))
{
res.setHeader('Cache-Control', 'public, max-age=3153600')
} else {
res.setHeader('Cache-Control', 'no-store')
}
},
}
max-age는 캐시의 유효 시간이다.
HTML을 no-cache로 해두면 서버에 매번 요청을 보내 해당 캐시를 재사용해도 되는지를 확인하여 최신 상태를 유지할 수 있다.
css, js, webp는 max-age를 1년으로 설정하여 사실상 영구적으로 사용하게 된다. 만일 HTML에 변하면, 요청되는 CSS, JS, webp도 자연스레 변할 것이므로 이들 파일에 반영구적인 캐시 기간을 적용할 수 있는 것이다.
그 외의 파일들에는 no-store를 적용했는데, 이는 캐시를 사용하지 않겠다는 의미이다.
리플로우는 위의 모든 과정을 다시 실행한다.
리페인트는 위의 과정 중 레이아웃 단계를 건너 뛰고 1, 2, 4, 5 단계를 실행한다. 레이아웃 단계를 생략하기 때문에 그나마 빠르게 실행된다.
리플로우와 리페인트를 일으키는 대표적인 속성들은 아래와 같다. 출처
position | width | height | left | top |
right | bottom | margin | padding | border |
border-width | clear | display | float | font-family |
font-size | font-weight | line-height | min-height | overflow |
text-align | vertical-align | white-space |
background | background-image | background-position | background-repeat | background-size |
border-radius | border-style | box-shadow | color | line-style |
outline | outline-color | outline-style | outline-width | text-decoration |
visibility |
GPU를 이용해 애니메이션을 동작하면 특정 레이어를 분리하여 GPU에게 위임한다.
이 특정 레이어는 transform: translate()(변화가 일어날 때에 레이어가 분리됨)
, transform: translate3d()
, scale3d()
등이며, will-change 속성을 통해 애니메이션 발생을 예약해두어 레이어를 분리해둘 수 있다.
GPU를 이용해 애니메이션을 동작하면 리페인트 과정에서 다음과 같이 성능 향상이 일어나는데
1. 해당 레이어를 제외한 나머지 DOM, CSSOM 모델의 연산 값이 같으므로 recalculating 시간이 줄어든다. (사실 최신 브라우저는 모두 이런 식으로 작동하긴 한다.)
2. paint 동작이 GPU에 위임되어 paint가 일어나지 않는다.
결과적으로 위의 렌더링 과정 중 1, 2,(4는 gpu가 처리), 5의 방식으로 빠르게 처리된다.
단 하드웨어 가속을 사용할 때에 유의할 점이 있다.
1. GPU에게 요소를 위임하는 것은 메모리를 필요로 한다.
2. 많은 모바일 기기가 GPU로 전환하는 과정에서 Crash를 일으킨다.
https://minoo.medium.com/번역-5가지-리액트-애니메이션-장-단점-비교-react-animations-in-depth-884ff6e61b88 해당 예시를 살펴보자
class App extends Component {
state = {
disabled: true,
}
onChange = (e) => {
const length = e.target.value.length;
if (length >= 4) {
this.setState(() => ({ disabled: false }))
} else if (!this.state.disabled) {
this.setState(() => ({ disabled: true }))
}
}
render() {
const label = this.state.disabled ? 'Disabled' : 'Submit';
return (
<div className="App">
<button
style={Object.assign({}, styles.button, !this.state.disabled && styles.buttonEnabled)}
disabled={this.state.disabled}
>{label}</button>
<input
style={styles.input}
onChange={this.onChange}
/>
</div>
);
}
}
const styles = {
input: {
width: 200,
outline: 'none',
fontSize: 20,
padding: 10,
border: 'none',
backgroundColor: '#ddd',
marginTop: 10,
},
button: {
width: 180,
height: 50,
border: 'none',
borderRadius: 4,
fontSize: 20,
cursor: 'pointer',
transition: '.25s all',
},
buttonEnabled: {
backgroundColor: '#ffc107',
width: 220,
}
}
한편 Poiemaweb에서는 자바스크립트 애니메이션과 css 애니메이션의 차이를 아래와 같이 서술했다.