관심사
관심사가 맞았다. 개발을 배우고 프로덕트를 만들다 보니 흠칫하는 순간들이 있었는데, 그 대부분의 순간에는 "왜 이렇게 느리지?", "왜 화면이 깜빡이지?" 같은 문제들이 있었고 최적화와 관련된 이슈였다. 그래서 최적화 관련 책을 읽고 싶었다.
현업에서 뛰고 있고, 실무적인 내용을 담고 있으며, 내 지식 수준에서 한 단계 나아갈 수 있는 책을 찾고자 교보문고에 갔었고 마침 그런 책이 있어서 사게 되었다. 꼭 이 책이 아니어도 상관 없었지만, 내 눈높이에서 가장 도움이 될 책이라고 판단했다.
당분간은 두고두고 읽을 책
보통 어렵다고 하는 책들은 학문적인 내용이 주를 이룬다. 그런데, 이 책은 거의 다 실무적이 내용을 포함하고 있었다.
처음으로 Network Panel을 봤을 때가 기억이 난다. 아직 익숙하지 않아서 사용 빈도가 낮았고, 머리로는 알고 있었지만 생각이 안나서 문제 해결이 오래 걸렸다. 그러나 지금은 무의식으로도 쓰게 되고, 어떨 때는 하루 종일 켜놓고 작업하기도 한다. 이 책의 내용도 같은 성향을 띈다. 모르면, 안 쓰겠지만 쓰다 보면 알게 모르게 더 좋은 프로덕트를 뽑아낼 수 있을 것이다.
책은 Create React App을 기반으로 설명하지만, 일부 최적화 기법을 제외하면 어느 웹 프레임워크에서든 활용이 가능하다.
로딩 성능 최적화: 이미지 사이즈 최적화, 코드 분할, 텍스트 압축
렌더링 성능 최적화: 병목 코드 최적화
네트워크 트래픽이 증가해 서비스 로딩이 지연된다.
SPA(Single Page Application)의 특성상 모든 React 코드가 하나의 JS 파일로 번들링되어 로드되기 때문에, 첫 페이지 진입 시 당장 필요하지 않는 코드가 다소 포함되어 있다. 페이지별로 분할하는 경우, 모듈별로 분할하는 경우 등 다양한 방식으로 분할할 수 있다. 핵심은 불필요한 코드 또는 중복되는 코드 없이 적절한 사이즈의 코드가 적절한 타이밍에 로드되도록 한다.
React.lazy(() => import('...'))
HTML, CSS, JS 같은 리소스는 다운로드 전에 서버에서 미리 압축할 수 있다. 그러면 원래 사이즈보다 더 작은 사이즈로 다운로드할 수 있어 웹 페이지가 더 빠르게 로드된다. 보통 웹서버에서 텍스트 압축을 진행한다.
한 로직을 처리하는 데에 오래 걸리는 부분을 찾아서 개선한다.
모든 네트워크 트래픽을 상세하게 알려 준다. 어떤 리소스가 어느 시점에 로드되는지, 해당 리소스의 크기 등을 확인할 수 있다.
Content-Encoding: gzip
이 보인다. 이 항목이 없는 경우 텍스트 압축이 적용되어 있지 않다는 말이다. 웹 페이지가 로드될 때, 실행되는 모든 작업을 보여 준다. 리소스가 로드되는 타이밍 뿐만이 아니라, 브라우저의 메인 스레드에서 실행되는 JS를 차트 형태로 볼 수 있다. 어떤 JS 코드가 느린 작업인지 확인할 수 있다.
성능을 측정하고 개선 방향을 제시해 주는 자동화 툴이다. Device로 Mobile을 선택하면 모바일 사이즈의 화면과 느린 CPU, Network로 검사를 진행한다. 측정된 항목은 여섯가지 지표(metrics)에 가중치를 적용해 점수를 내는데, 이 지표를 Web Vitals라고 부른다.
완성된 번들 파일 중 불필요한 코드가 어떤 코드이고, 번들 파일에서 어느 정도의 비중을 차지하고 있는지 확인할 수 있다. 세팅에는 eject가 필요하며, eject 없이 세팅하려면 cra-bundle-analyzer를 사용하면 된다.
물리적 거리의 한계를 극복하기 위해 사용자와 가까운 곳에 콘텐츠 서버를 두는 기술을 의미한다. 그 중 이미지 CDN은 이미지에 특화된 CDN이라고 볼 수 있다. 이미지를 사용자에게 보내기 전에 특정 형태로 가공하고 이미지 사이즈를 줄이거나, 특정 포맷으로 변경하는 등의 작업이 가능하다. 다음과 같이 사용된다. http://cdn.image.com?src=[img src]&width=240&height=240
정적 파일을 주기 위한 웹서버. -u 옵션은 텍스트 압축을 하지 않겠다는 옵션이고, -s 옵션은 SPA 서비스를 위해 매칭되지 않는 주소는 모두 index.html로 보내겠다는 옵션이다. 만약 단일 서버가 아닌 여러 서버를 사용하고 있다면 Nginx와 같은 게이트웨이 서버에 공통적으로 적용할 수 있다.
이 장에서는 아래와 같은 내용을 다룬다.
- CSS 애니메이션 최적화
- 컴포넌트 지연 로딩
- 컴포넌트 사전 로딩
- 이미지 사전 로딩
분할된 코드를 필요한 시점에 로드되도록 한다. 예를 들어, 사진 갤러리에 사용하는 image-gallery.js
라이브러리가 있다면, 갤러리가 표출되는 시점에 번들을 로드하도록 한다. 코드 스플릿 Dynamic import를 사용해서 버튼 mouseender시, 혹은 마운트 이후 등. 원하는 지점을 설정한다.
const handleMouseEnter = () => {
const component = import('./components/...');
}
<div onMouseEnter={handleMouseEnter} />
필요한 시점보다는 먼저 코드를 로드하여 해당 코드를 지연 없이 사용할 수 있도록 한다.
useEffect(() => {
const component = import('./components/ImageModal');
const img = new Image();
img.src = 'https://...';
}, []);
분석할 때는 개발자 도구의 Performance Panel의 CPU 설정을 6x slowdown으로 설정하면 더 잘 확인할 수 있다. HTML은 Critical Rendering Path 또는 Pixel Pipeline라고 불리우는 과정을 거쳐서 렌더링 되어 이를 손봐야 한다.
transform
이나 opacity
는 별도의 레이어로 분리하고 작업을 CPU가 아닌 GPU에 위임하여 처리한다. 이를 하드웨어 가속이라고 한다. 작동은 CSS 속성마다 다른데, csstrigers.com 에서 속성을 확인할 수 있다. transform: translate();
는 처음부터 레이어를 분리하지 않고 변화가 일어나는 순간 레이어를 분리한다. 반면에 transform: translate3d();
또는 scale3d()
와 같은 3d 속성들, 혹은 will-change
속성은 처음부터 레이어를 분리해 두기 때문에 변화에 더욱 빠르게 대처할 수 있다. 물론 레이어가 너무 많아지면 그만큼 메모리를 많이 사용하기 때문에 주의해야 한다.이 장에서는 아래와 같은 내용을 다룬다.
- 이미지 지연 로딩
- 이미지 사이즈 최적화
- 폰트 최적화
- 캐시 최적화
- 불필요한 CSS 제거
웹 페이지를 렌더링하는 과정에서 어떤 코드가 실행되었는지 보여 준다. 특정 파일에서 극히 일부의 코드만 실행되었다면 불필요한 코드가 많이 포함되어 있을 수 있다.
브라우저에서 제공하는 API로, 특정 요소를 관찰하면 알려 주는 옵저버 패턴 클래스다. 성능 면에서 scroll 이벤트로 판단하는 것보다 훨씬 효율적이다. 리소스 관리 때문에 아래와 같이 사용된다.
useEffect(() => {
const options = {};
const callback = (entries, observer) => {...};
const observer = new IntersactionObserver(callback, options);
observer.observe(imgRef.current);
return () => observer.disconnect();
}, []);
이미지 지연 로딩에서는 아래와 같이 사용된다.
useEffect(...
const callback = (entreis, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.src = entry.target.datset.src; // data-src -> src 이동
observer.unobserve(entry.target);
}
});
}
...);
<img data-src={props.image} ref={imgRef} />
투명도가 필요하면 무손실 png, 불필요하면 손실 jpg를 사용하는 게 보편적이나, WebP는 무손실, 손실 두 경우를 모두 제공한다. 하지만, WebP는 호환성 이슈가 있다. 호환성 이슈가 있으면 아래 처럼 사용하면 지원되는 이미지를 찾아 로딩한다.
# 뷰포트에 따라 구분
<picture>
<source media="(min-width:650px)" srcset="...jpg" />
<source media="(min-width:465px)" srcset="...jpg" />
<img src="...jpg" alt="Flowers" />
</picture>
# 이미지 포맷에 따라 구분
<picture>
<source srcset="...avif" type="image/avif" />
<source srcset="...webp" type="image/webp" />
<img src="...jpg" alt="Flowers" />
</picture>
동영상 콘텐츠의 특성상 파일 크기가 크기 때문에 당장 재생이 필요한 앞부분을 먼저 다운로드한 뒤 순차적으로 나머지 내용을 다운로드한다.
<video>
<source src="..." type="video/webm" />
<source src="..." type="video/mp4" />
</video>
filter: blur(10px)
다.폰드가 다운로드되기 전에 글자가 표출되면 깜빡이는 모습이 생기는데, 이 현상은 페이지가 느리다는 느낌을 줄 수 있고, 다른 요소를 밀어낼 수도 있다.
EOT > TTF/OTF > WOFF > WOFF2
@font-face {
font-family: '...';
src: url('data:font/woff2;charset=utf-8;base64,d09w...') format('woff2'),
url('./assets/fonts/subset-...woff') format('woff'),
url('./assets/fonts/subset-...ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
웹에서 사용하는 캐시는 메모리 캐시와 디스크 캐시. 크게 두 가지로 구분된다. HTML이 캐시되면 캐시된 HTML에서 이전 버전의 자바스크립트나 CSS를 로드하게 되므로 최신 버전의 웹 서비스를 제공하지 못한다. 하지만, JS나 CSS는 파일명에 해시를 함께 가지고 있어서(main.bb8aac28.chunk.js)
코드가 변경되면 해시도 변경되어 완전히 다른 파일이 되어 버린다. HTML만 최신 상태라면 JS나 CSS는 당연히 최신 리소스를 로드한다.
이 장에서는 아래와 같은 내용을 다룬다.
- 이미지 지연 로딩
- 레이아웃 이동 피하기
- 리덕스 렌더링 최적화
- 병목 코드 최적화
병목 코드를 찾아서 Memoization을 적용한다.
React Developer Tools는 두 가지로 나뉜다. Profiler Panel, Components Panel. 그 중 Profiler Panel은 리액트 프로젝트를 분석하여 얼마만큼의 렌더링이 발생하였고 어떤 컴포넌트가 렌더링되었는지, 그리고 어느 정도의 시간이 소요됐는지를 FlameChart로 보여 준다.
레이아웃 이동을 발생시키는 원인은 다양하다.
반응형의 경우 이미지 사이즈를 알 수 없으므로, 이미지는 비율로 설정해서 공간을 잡아 두면 된다. 아래는 예시다.
<div class="wrapper">
<img class="image" src="..." />
</div>
.wrapper {
position: relative;
width: 160px;
padding-top: 56.25%; /* 16:9 비율 */
}
.image {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
혹은 아래의 방법을 사용할 수 있다.
.wrapper {
width: 100%;
aspect-ratio: 16 / 9; // 그러나 호환성 이슈 존재.
}
.image {
width: 100%;
height: 100%;
}
이미지 지연로딩 라이브러리로, Intersaction Observer API와 동일한 기능이지만 라이브러리를 이용해서 빠르게 구현할 수 있다.
<LazyLoad>
<img src="..." />
</LazyLoad>
이미지 뿐 아니라, 일반 컴포넌트도 사용 가능하나, 이미지 로드하는데에 걸리는 시간 때문에 처음에는 이미지가 보이지 않고 시간이 지나야 보인다는 단점이 있는데, 이를 offset 옵션
을 통해 해결할 수 있다. 얼마나 미리 이미지를 로드할지 결정할 수 있다. 아래는 화면으로 부터 1000px만큼 미리 로드하는 예시이다.
<LazyLoad offset={1000}>
<img src="..." />
</LazyLoad>
리덕스는 리덕스 상태를 구독하여 상태가 변했을 때를 감지하고 리렌더링한다. useSelector의 인자로 넣은 함수의 반환 값이 이전 값과 같다면 리렌더링을 하지 않고, 다르면 영향이 있다고 판단하여 리렌더링 합니다. 여기서 여러 컴포넌트라 리렌더링 되는 이슈를 해결할 수 있는 방법은 크게 두 가지가 있다.
const bgColor = useSelector(state => state.imageModal.bgColor);
const { modalVisible, bgColor, src, alt } = useSelector(
state => ({
modalVisible: state.imageModal.modalVisible,
bgColor: state.imageModal.bgColor,
src: state.imageModal.src,
alt: state.imageModal.alt,
}),
shallowEqual
)
shallowEqual은 객체를 얕은 비교하는 함수다.