프론트엔드 성능 최적화 가이드

Tasker·2023년 10월 28일
0

개발

목록 보기
2/4

1. 책을 읽게 된 경위

관심사

왜 읽게 되었나?

  관심사가 맞았다. 개발을 배우고 프로덕트를 만들다 보니 흠칫하는 순간들이 있었는데, 그 대부분의 순간에는 "왜 이렇게 느리지?", "왜 화면이 깜빡이지?" 같은 문제들이 있었고 최적화와 관련된 이슈였다. 그래서 최적화 관련 책을 읽고 싶었다.

왜 이 책인가?

  현업에서 뛰고 있고, 실무적인 내용을 담고 있으며, 내 지식 수준에서 한 단계 나아갈 수 있는 책을 찾고자 교보문고에 갔었고 마침 그런 책이 있어서 사게 되었다. 꼭 이 책이 아니어도 상관 없었지만, 내 눈높이에서 가장 도움이 될 책이라고 판단했다.


2. N회독 후기

당분간은 두고두고 읽을 책

실무적인 내용을 담고 있다!

  보통 어렵다고 하는 책들은 학문적인 내용이 주를 이룬다. 그런데, 이 책은 거의 다 실무적이 내용을 포함하고 있었다.

체득하고 체화해야할 책

  처음으로 Network Panel을 봤을 때가 기억이 난다. 아직 익숙하지 않아서 사용 빈도가 낮았고, 머리로는 알고 있었지만 생각이 안나서 문제 해결이 오래 걸렸다. 그러나 지금은 무의식으로도 쓰게 되고, 어떨 때는 하루 종일 켜놓고 작업하기도 한다. 이 책의 내용도 같은 성향을 띈다. 모르면, 안 쓰겠지만 쓰다 보면 알게 모르게 더 좋은 프로덕트를 뽑아낼 수 있을 것이다.


3. 톺아보기

서론

책은 Create React App을 기반으로 설명하지만, 일부 최적화 기법을 제외하면 어느 웹 프레임워크에서든 활용이 가능하다.

성능 최적화를 하는 이유

  • 서비스 사용자에게 더 나은 UX를 제공한다.
  • 가입률과 전환율은 높이고 이탈율은 낮춘다.

최적화 방식

  • 로딩 성능: code-split, pre-load, lazy-load, cdn, compression, file-format, font, cache, ...
  • 렌더링 성능: lifecycle, critical-render-path, layout-shift, memoization, bottleneck, ...

분석 방법

  • 번들 파일 분석: webpack-bundle-analyzer
  • 개발자 도구: chrome-dev-tool panels,
  • 리액트 분석: react-dev-tools

1장, 블로그 서비스 최적화

로딩 성능 최적화: 이미지 사이즈 최적화, 코드 분할, 텍스트 압축
렌더링 성능 최적화: 병목 코드 최적화

이미지 사이즈 최적화

  네트워크 트래픽이 증가해 서비스 로딩이 지연된다.

  • 레티나 디스플레이는 같은 공간(픽셀)에 더 많은 픽셀을 그릴 수 있기 때문에, 너비 기준으로 두 배 정도 큰 이미지를 사용하는 것이 적절하다.
  • 자체적으로 가지고 있는 정적 이미지라면 사진 편집 툴을 이용하여 직접 조절하면 된다.
  • API를 통해 받아오는 경우에는 Cloudinary나 Imgix 같은 CDN을 사용하여 조절한다.

코드 분할

  SPA(Single Page Application)의 특성상 모든 React 코드가 하나의 JS 파일로 번들링되어 로드되기 때문에, 첫 페이지 진입 시 당장 필요하지 않는 코드가 다소 포함되어 있다. 페이지별로 분할하는 경우, 모듈별로 분할하는 경우 등 다양한 방식으로 분할할 수 있다. 핵심은 불필요한 코드 또는 중복되는 코드 없이 적절한 사이즈의 코드가 적절한 타이밍에 로드되도록 한다.

  • Dynamic import: 런타임에 해당 모듈을 로드시킨다. Promise 형태로 모듈을 반환해준다. 그래서 렌더링 되기 전의 상태 때문에 Lazy함수와 Suspense를 함께 사용해야 한다. React.lazy(() => import('...'))

텍스트 압축

  HTML, CSS, JS 같은 리소스는 다운로드 전에 서버에서 미리 압축할 수 있다. 그러면 원래 사이즈보다 더 작은 사이즈로 다운로드할 수 있어 웹 페이지가 더 빠르게 로드된다. 보통 웹서버에서 텍스트 압축을 진행한다.

  • Content-Encoding
      텍스트 압축 방식은 Gzip과 Deflate가 있는데, Gzip이 더 좋은 압축률을 제공한다.

병목 코드 최적화

  한 로직을 처리하는 데에 오래 걸리는 부분을 찾아서 개선한다.

크롬 개발자 도구 - Network Panel

  모든 네트워크 트래픽을 상세하게 알려 준다. 어떤 리소스가 어느 시점에 로드되는지, 해당 리소스의 크기 등을 확인할 수 있다.

  • Content-Encoding
      Network의 Headers에 Content-Encoding: gzip이 보인다. 이 항목이 없는 경우 텍스트 압축이 적용되어 있지 않다는 말이다.

크롬 개발자 도구 - Performance Panel

  웹 페이지가 로드될 때, 실행되는 모든 작업을 보여 준다. 리소스가 로드되는 타이밍 뿐만이 아니라, 브라우저의 메인 스레드에서 실행되는 JS를 차트 형태로 볼 수 있다. 어떤 JS 코드가 느린 작업인지 확인할 수 있다.

  • CPU chart
      시간에 따라 CPU가 어떤 작업에 리소스를 사용하고 있는지 비율로 보여준다. JS는 노란색, Render/Layout 작업은 보라색, Painting 작업은 초록색, 기타 시스템 작업은 회색으로 표시된다.
  • Network chart
      CPU 차트 밑에 막대 형태로 표시된다. 대략적인 네트워크 상태를 보여준다. 위쪽의 진한 막대는 우선순위가 높은 네트워크 리소스를, 아래쪽 옅은 막대는 우선순위가 낮은 네트워크 리소스를 보여준다.
  • Screenshot
      서비스가 로드되는 과정을 보여준다.
  • Network Timeline
      네트워크 요청을 시간 순서에 따라 보여 준다.
  • Frames Section
      화면의 변화가 있을 때마다 스크린샷을 찍어서 보여 준다.
  • Timings Section
      리액트 각 컴포넌트의 렌더링 시간을 측정한 것.
  • Main Section
      메인 스레드에서 실행되는 작업을 Flame Chart로 보여 준다. Flame Chart는 작업 스택을 계층형으로 시각화해서 보여주며, 막대가 아래쪽에 있을 수록 상위 작업을 나타낸다.

크롬 개발자 도구 - Lighthouse Panel

  성능을 측정하고 개선 방향을 제시해 주는 자동화 툴이다. Device로 Mobile을 선택하면 모바일 사이즈의 화면과 느린 CPU, Network로 검사를 진행한다. 측정된 항목은 여섯가지 지표(metrics)에 가중치를 적용해 점수를 내는데, 이 지표를 Web Vitals라고 부른다.

  • FCP(First Contentful Paint)
      페이지가 로드될 때 DOM 콘텐츠의 첫 번째 부분을 렌더링 하는 데 걸리는 시간을 나타내는 지표. 총점에서 10% 가중치를 가진다.
  • SI(Speed Index)
      페이지 로드 중에 콘텐츠가 시각적으로 표시되는 속도를 나타내는 지표. 총점에서 10% 가중치를 가진다.
  • LCP(Largest Contentful Paint)
      화면 내에 있는 가장 큰 이미지나 텍스트 요소가 렌더링되기까지 걸리는 시간을 나타내는 지표. 총점에서 25% 가중치를 가진다.
  • TTI(Time to Interactive)
      사용자가 페이지와 상호 작용이 가능한 시점까지 걸리는 시간을 측정한 지표. 총점에서 10%의 가중치를 가진다.
  • TBT(Total Blocking Time)
      페이지가 클릭, 키보드 입력 등의 사용자 입력에 응답하지 않도록 차단된 시간을 총합한 지표. 총점에서 30%의 가중치를 가진다.
  • CLS(Cumulative Layout Shift)
      페이지 로드 과정에서 발생하는 예기치 못한 레이아웃 이동을 측정한 지표. 총점에서 15%의 가중치를 가진다.
  • Opportunities Section
      페이지를 더욱 빨리 로드하는 데 잠재적으로 도움되는 제안을 나열한다.
  • Diagnostics Section
      로드 속도와 직접적인 관계는 없지만 성능과 관련된 기타 정보를 보여준다.
  • Emulated Desktop
      CPU 성능을 어느 정도 제한하여 검사를 진행했는지를 나타낸다. Lighthouse를 쓰면 그냥 페이지를 로드하는 것보다 더 느린 이유 두 개 중 하나다.
  • Emulated Desktop
      Networking throttling으로 네트워크 속도를 제한하여 어느 정도 고정된 네트워크 환경에서 성능을 측정한다. Lighthouse를 쓰면 그냥 페이지를 로드하는 것보다 더 느린 이유 두 개 중 하나다.

Webpack-bundle-analyzer

  완성된 번들 파일 중 불필요한 코드가 어떤 코드이고, 번들 파일에서 어느 정도의 비중을 차지하고 있는지 확인할 수 있다. 세팅에는 eject가 필요하며, eject 없이 세팅하려면 cra-bundle-analyzer를 사용하면 된다.

CDN(Content Delivery Network)

  물리적 거리의 한계를 극복하기 위해 사용자와 가까운 곳에 콘텐츠 서버를 두는 기술을 의미한다. 그 중 이미지 CDN은 이미지에 특화된 CDN이라고 볼 수 있다. 이미지를 사용자에게 보내기 전에 특정 형태로 가공하고 이미지 사이즈를 줄이거나, 특정 포맷으로 변경하는 등의 작업이 가능하다. 다음과 같이 사용된다. http://cdn.image.com?src=[img src]&width=240&height=240

serve

  정적 파일을 주기 위한 웹서버. -u 옵션은 텍스트 압축을 하지 않겠다는 옵션이고, -s 옵션은 SPA 서비스를 위해 매칭되지 않는 주소는 모두 index.html로 보내겠다는 옵션이다. 만약 단일 서버가 아닌 여러 서버를 사용하고 있다면 Nginx와 같은 게이트웨이 서버에 공통적으로 적용할 수 있다.


2장, 올림픽 통계 서비스 최적화

이 장에서는 아래와 같은 내용을 다룬다.

  • CSS 애니메이션 최적화
  • 컴포넌트 지연 로딩
  • 컴포넌트 사전 로딩
  • 이미지 사전 로딩

Lazy Loading

  분할된 코드를 필요한 시점에 로드되도록 한다. 예를 들어, 사진 갤러리에 사용하는 image-gallery.js라이브러리가 있다면, 갤러리가 표출되는 시점에 번들을 로드하도록 한다. 코드 스플릿 Dynamic import를 사용해서 버튼 mouseender시, 혹은 마운트 이후 등. 원하는 지점을 설정한다.

  • 지연 로딩의 예시는 아래와 같다.
const handleMouseEnter = () => {
	const component = import('./components/...');
}
<div onMouseEnter={handleMouseEnter} />

Preload

  필요한 시점보다는 먼저 코드를 로드하여 해당 코드를 지연 없이 사용할 수 있도록 한다.

  • 사진이 로드되기 전에 이상한 형태로 깨져 있을 때 사용한다.
  • 보통 네트워크를 통해 무언가(분리된 라이브러리 혹은, 이미지 등)를 로드하는 경우 사용한다.
  • 이미지 프리 로드 예시는 아래와 같다. 해당 컴포넌트가 뜨기 전 다른 컴포넌트에서 실행한다. 이렇게 사전 로딩이 가능한 이유는 브라우저가 캐싱해두기 때문이다. 먼저 보여지고, 먼저 필요한 부분을 먼저 로딩하는 게 좋다.
useEffect(() => {
	const component = import('./components/ImageModal');
    
    const img = new Image();
    img.src = 'https://...';
}, []);

CSS Animation 최적화

  분석할 때는 개발자 도구의 Performance Panel의 CPU 설정을 6x slowdown으로 설정하면 더 잘 확인할 수 있다. HTML은 Critical Rendering Path 또는 Pixel Pipeline라고 불리우는 과정을 거쳐서 렌더링 되어 이를 손봐야 한다.

  • 브라우저 렌더링 과정: DOM + CSSOM > Render Tree > Layout > Paint > Composite
  • DOM + CSSOM - HTML/CSS리소스 다운로드, 리소스 Parsing, Tree구조의 Object Model 생성
  • Render Tree - DOM + CSSOM 결합된 트리
  • Layout - 화면 구성 요소의 위치나 크기를 계산하고 해당 위치에 요소를 배치하는 작업
  • Paint - 색을 채워 넣는 작업. 여러 개의 레이어로 나눠서 작업하기도 한다.
  • Composite - 레이어를 하나로 합치는 단계
  • 하드웨어 가속 - Reflow는 모든 렌더링 경로를 재실행한다. Repaint는 레이아웃 단계만 생략하고 실행한다. 둘 다 피하는 방법도 존재한다. transform이나 opacity는 별도의 레이어로 분리하고 작업을 CPU가 아닌 GPU에 위임하여 처리한다. 이를 하드웨어 가속이라고 한다. 작동은 CSS 속성마다 다른데, csstrigers.com 에서 속성을 확인할 수 있다.
    • transform: translate();는 처음부터 레이어를 분리하지 않고 변화가 일어나는 순간 레이어를 분리한다. 반면에 transform: translate3d(); 또는 scale3d()와 같은 3d 속성들, 혹은 will-change속성은 처음부터 레이어를 분리해 두기 때문에 변화에 더욱 빠르게 대처할 수 있다. 물론 레이어가 너무 많아지면 그만큼 메모리를 많이 사용하기 때문에 주의해야 한다.

3장, 홈페이지 최적화

이 장에서는 아래와 같은 내용을 다룬다.

  • 이미지 지연 로딩
  • 이미지 사이즈 최적화
  • 폰트 최적화
  • 캐시 최적화
  • 불필요한 CSS 제거

크롬 개발자 도구 - Coverage Panel

  웹 페이지를 렌더링하는 과정에서 어떤 코드가 실행되었는지 보여 준다. 특정 파일에서 극히 일부의 코드만 실행되었다면 불필요한 코드가 많이 포함되어 있을 수 있다.

이미지 압축 도구

  • Squoosh: 이미지 포맷이나 사이즈 등을 변경할 수 있다.
  • PurgeCSS: 사용하지 않는 CSS를 제거해준다. CLI를 통해 실행할 수도 있고 Webpack과 같은 Bundler에 플러그인을 추가하여 사용할 수도 있다.

Intersaction Observer

  브라우저에서 제공하는 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>

동영상 최적화

  동영상 콘텐츠의 특성상 파일 크기가 크기 때문에 당장 재생이 필요한 앞부분을 먼저 다운로드한 뒤 순차적으로 나머지 내용을 다운로드한다.

  • 동영상 압축: 동영상 최적화는 이미지 최적화와 비슷하게, 가로 세로 사이즈를 줄이고 압축 방식을 변경한다.
  • 화질 줄이기: 동영상이 메인 콘텐츠가 아닌 서비스에서는 이 최적화 기법을 적용해도 무방하다.
  • Media.io: 이 서비스를 활용하면 동영상을 압축할 수 있으며, WebP와 같은 구글 포맷인 WebM으로도 변환 가능하다. 사용은 아래와 같다.
<video>
	<source src="..." type="video/webm" />
	<source src="..." type="video/mp4" />
</video>
  • 압축으로 저하된 화질 보완: 화질을 높일 수는 없다. 하지만, 사용성을 높일 수는 있다. 동영상에 패턴을 넣거나 동영상에 패턴을 씌우는 방법이다. 가장 효과적은 방법은 filter: blur(10px)다.

폰트 최적화

  폰드가 다운로드되기 전에 글자가 표출되면 깜빡이는 모습이 생기는데, 이 현상은 페이지가 느리다는 느낌을 줄 수 있고, 다른 요소를 밀어낼 수도 있다.

  • FOUT(Flash of Unstyled Text): 다운로드 여부와 상관없이 텍스트를 보여준 후 폰트가 다운로드되면 폰트를 적용한다.
  • FOIT(Flash of Invisible Text): 폰트가 완전히 다운로드되기 전까지 텍스트 자체를 보여주지 않는다. 이 경우 Fade In을 적용해서 FOIT 시간을 메운다.
  • 폰트 크기 줄이기
    • TTF 포맷은 파일 크기가 크기 때문에, 압축률이 좋은 WOFF(Web Open Font Format)를 사용한다. 더 나아가서 더 향상된 WOFF2 압축 방식을 적용한 포맷을 사용할 수도 있다. 물론, 호환성은 다 다르니 확인이 필요하다. 파일 크기 순서는 아래와 같다.
      EOT > TTF/OTF > WOFF > WOFF2
    • Subset Font는 모든 문자가 아닌, 일부 문자의 폰트 정보만 가지고 있는 것이다. Data-URI 형태로 CSS 파일에 포함할 수도 있다. 이는, data scheme이 접두어로 붙은 문자열 형태의 데이터인데, 이 상태로 App.css에 넣으면 별도의 네트워크 로드 없이 App.css에서 폰트를 사용할 수 있다. 아래는 예시다.
   @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는 당연히 최신 리소스를 로드한다.

  • 메모리 캐시: RAM에 저장하는 방식.
  • 디스크 캐시: 파일 형태로 디스크에 저장하는 방식.
  • Cache-Control: Network Panel Response Header의 Cache-Control은 서버에서 설정되며, 리소스를 얼마나 캐시할지 판단한다. 대표적으로 아래 5가지 값이 조합된다. 단순 새로고침 -> Memory cache가 많음. 브라우저 완전 종료 후 재접속 -> 메모리가 지워져서 Disk cache가 많음
    • no-cache: 캐시를 사용하기 전 서버에 캐시된 리소스를 사용해도 되는지 검사 후 사용. 일반적인 HTML 캐시 설정이다.
    • no-store: 캐시 사용 안 함
    • public: 모든 환경에서 캐시 가능
    • private: 브라우저 환경에서만 캐시 사용. 외부 캐시 서버에서는 사용 불가능해서 웹서버 <-> 브라우저 사이의 중간 캐시 서버에서는 캐시가 안 된다.
    • max-age: 캐시의 유효 시간

4장, 이미지 갤러리 최적화

이 장에서는 아래와 같은 내용을 다룬다.

  • 이미지 지연 로딩
  • 레이아웃 이동 피하기
  • 리덕스 렌더링 최적화
  • 병목 코드 최적화

병목 코드 최적화

  병목 코드를 찾아서 Memoization을 적용한다.

React Developer Tools(Profiler)

  React Developer Tools는 두 가지로 나뉜다. Profiler Panel, Components Panel. 그 중 Profiler Panel은 리액트 프로젝트를 분석하여 얼마만큼의 렌더링이 발생하였고 어떤 컴포넌트가 렌더링되었는지, 그리고 어느 정도의 시간이 소요됐는지를 FlameChart로 보여 준다.

Layout Shift

  레이아웃 이동을 발생시키는 원인은 다양하다.

  • 사이즈가 미리 정의되지 않은 요소
  • 사이즈가 미리 정의되지 않은 광고 요소
  • 동적으로 삽입된 콘텐츠: 이미지를 다운로드하기 전까지 이미지 사이즈가 어떤지 알 수 없다.
  • 웹 폰트(FOIT, FOUT)

반응형의 경우 이미지 사이즈를 알 수 없으므로, 이미지는 비율로 설정해서 공간을 잡아 두면 된다. 아래는 예시다.

<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%;
}

react-lazyload

  이미지 지연로딩 라이브러리로, Intersaction Observer API와 동일한 기능이지만 라이브러리를 이용해서 빠르게 구현할 수 있다.

<LazyLoad>
	<img src="..." />
</LazyLoad>

이미지 뿐 아니라, 일반 컴포넌트도 사용 가능하나, 이미지 로드하는데에 걸리는 시간 때문에 처음에는 이미지가 보이지 않고 시간이 지나야 보인다는 단점이 있는데, 이를 offset 옵션을 통해 해결할 수 있다. 얼마나 미리 이미지를 로드할지 결정할 수 있다. 아래는 화면으로 부터 1000px만큼 미리 로드하는 예시이다.

<LazyLoad offset={1000}>
	<img src="..." />
</LazyLoad>

리렌더링 최적화

  리덕스는 리덕스 상태를 구독하여 상태가 변했을 때를 감지하고 리렌더링한다. useSelector의 인자로 넣은 함수의 반환 값이 이전 값과 같다면 리렌더링을 하지 않고, 다르면 영향이 있다고 판단하여 리렌더링 합니다. 여기서 여러 컴포넌트라 리렌더링 되는 이슈를 해결할 수 있는 방법은 크게 두 가지가 있다.

  • 객체를 새로 만들지 않도록 반환 값을 나누는 방법
const bgColor = useSelector(state => state.imageModal.bgColor);
  • Equality Function을 사용하는 방법
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은 객체를 얕은 비교하는 함수다.

profile
..ㅁ

0개의 댓글