"Life is Full of Possibilities" - Soul, 2020

우아한테크코스

웹 사이트의 성능을 높여보자 (1) (요청 크기 줄이기, 필요한 것만 요청하기)

m2ndy 2024. 9. 15. 21:09

 

개선 전후 성능 측정 결과

개선 전
개선 후

 

 

 

요청 크기 줄이기

 

1. 소스 코드 줄이기

 

초기 번들 사이즈 (production 빌드)

 

Home 페이지에서 불러오는 스크립트 번들 크기가 951KB였다. 웹 페이지의 권장 번들 사이즈는 250KB 미만으로, 번들 사이즈의 용량 개선이 필요했다.

 

 

1-1. minify & uglify

 

// webpack.config.js

  optimization: {
    minimize: true,
  }

 

 

Webpack v5부터는 production mode로 배포 시 기본적으로 압축과 난독화가 진행된다. TerserWebpackPlugin이 내장되어 있기 때문에, 자동으로 최적화가 이루어진다.

 

minify & uglify 이전 minify & uglify 이후 압축률
951KB 216KB 약 77%

 

 

 

그렇다면 development로 배포하면 어떨지 궁금해서 development 모드로 배포해 봤더니, 소스코드 압축률이 현저히 차이 났다.

 

개선 전
개선 후

minify & uglify 이전 (development) minify & uglify 이후 (development) 압축률 (development)
2.13MB 1.01MB 약 52%

 

 

 

1-2. CSS Minimize

 

css 파일을 최소화하기 위해 optimization.minimizer 설정에 CssMinimizerPlugin 설정을 추가했다. 이 설정은 불필요한 공백이나 주석, 줄 바꿈 등을 제거한다. 변수명도 가장 간단하게 만들어, css 코드를 가능한 작은 크기로 변환하는 기능이다.

 

1) css-minimizer-webpack-plugin 플러그인 설치

2) mini-css-extract-plugin 플러그인 설치

3) webpack.config.js 설정

const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

// ...
plugins: [
  new MiniCssExtractPlugin()
],
module: {
  rules: [
    {
      test: /\.css$/i,
      use: [MiniCssExtractPlugin.loader, 'css-loader']
    },
  ],
},
optimization: {
  minimize: true,
  minimizer: [new CssMinimizerPlugin()]
}

 

 

처음에는 optimization.minimizer를 minimizer: [new CssMinimizerPlugin()]로 지정했는데 크기가 거의 줄어들지 않았다. 오히려 1-1번 방법을 적용하기 전의 크기 근처로 돌아갔다.(875KB)😶‍🌫️

 

// webpack.config.js

optimization: {
  minimize: true,
  minimizer: [new CssMinimizerPlugin()]
}

 

 

알고 보니 minimizer 값에 '...'를 추가하여 webpack v5에 이미 존재하는 minimizer (ex. terser-webpack-plugin)를 추가해야 정상적으로 작동된다고 한다.

 

// webpack.config.js

optimization: {
  minimize: true,
  minimizer: ['...', new CssMinimizerPlugin()]
}

 

 

 

CSS Minimize 이전 CSS Minimize 이후 압축률
216KB 172KB 약 20%

 

 

 

여기서 깃허브에 배포했을 때 번들 사이즈가 60.3KB로 더 줄어드는데, 이는 깃허브에서 자동으로 gzip이 적용되어 압축되기 때문이라고 한다.

 

 

 

 

2. 이미지 크기 줄이기

 

 

 

초기의 hero.png 이미지가 10.2MB로 매우 큰 상태여서 용량을 줄여야 했다.

 

 

2-1. image-webpack-loader

 

image-webpack-loader를 이용해서 빌드 시 동적으로 이미지 압축을 진행하기로 했다.

처음에는 ImageMinimizerWebpackPlugin의 imagemin을 사용하려 했는데, Webpack 버전 이슈로 imagemin을 설치해도 인식이 안되었다. 그래서 대안을 알아보던 중 image-webpack-loader를 발견!

 

1) image-webpack-loader 설치

2) webpack의 rules에 아래 내용 추가

 

  // webpack.config.js
  
  {
    test: /\.(jpe?g|png|gif|svg)$/,
    loader: 'image-webpack-loader',
    // enforce: 'pre'는 이 로더를 다른 로더들보다 먼저 실행
    enforce: 'pre'
  },

 

 

이미지 압축 이전 이미지 압축 이후 압축률
10.2MB 1.92MB 약 81%

 

 

 

하지만 매 빌드마다 압축하기엔 시간이 오래 걸려서 이미지 파일 크기를 정적으로 줄이기로 결정했다.

 

2-2. webp 파일로 변환

 

webp 파일은 래스터 파일로 무손실 압축을 지원하면서 PNG보다 용량이 더 작기 때문에 이 파일 형식을 선택했다.

 

1) webp 변환기 다운로드 페이지 에서 운영체제에 맞는 파일을 설치한다.

2) 압축 해제한 곳에서 bin/cwebp.exe로 이동한 뒤 cmd를 열고,

3) (윈도우 명령어) .\cwebp.exe -q 50 .\{파일명}.{확장자} -o .\{파일명}.webp 를 입력한다.

- 50 : 파일 크기를 50%로 줄인다.

- -o : 출력할 webp 파일 이름을 지정한다.

 

 

 

4) 변환된 파일을 프로젝트 폴더로 이동

5) types/images.d.ts 에 declare module 추가

declare module "*.webp"

 

6) png 파일을 사용하던 곳에 webp 파일 추가

7) webpack 설정 중 file-loader에 webp 확장자명 추가

  {
    test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif|webp)$/i,
    loader: 'file-loader',
    options: {
      name: 'static/[name].[ext]'
    }
  }

 

확장자 변환 이전 확장자 변환 이후 압축률
10.2MB 374KB 약 96%

 

 

 

주의해야 할 점은 webp 파일을 지원하지 않는 브라우저가 있기 때문에, picture html tag를 통해 대체 이미지를 지정해두어야 한다.

 

source 부분이 기본 제공되고, source 태그의 이미지가 제공되지 못하는 경우 img 태그 부분이 제공된다. source 태그에서 srcSet에 있는 리소스의 타입을 지정하는데, (type="image/webp") 이 타입을 브라우저가 제공하지 못하면 source 요소는 건너뛰도록 동작한다. 참고 MDN

<picture>
  <source srcSet={heroImageWebp} type="image/webp" />
  <img className={styles.heroImage} src={heroImage} alt="hero image" />
</picture>

 

 

2-3. imagemagick

 

하지만 여전히 파일 크기는 374KB로, Lighthouse에서는 이미지 크기를 조정하라는 경고가 떴다.

 

 

 

그래서 imagemagick을 설치하여 이미지 사이즈를 조정하기로 했다.

사진 크기는 1440px로 브라우저에서 많이 사용되는 크기 중 하나로 지정했다.

 

1) imagemagick 설치

2) 설치 후 cmd에서 아래 명령어 입력 (윈도우)

magick .\hero.webp -resize 1440x hero_small.webp

 

 

 

변경 전

 

 

변경 후

 

 

리사이징 이전 리사이징 이후 압축률
374KB 117KB 약 68%

 

 

 

이제 Lighthouse에서는 gif 사이즈를 조정하라는 경고메시지만 뜬다.

 

 

2-4. gif를 mp4로 변환

 

 

 

gif를 mp4로 변환하면 압축 방식의 차이 때문에 파일 크기가 줄어든다고 한다. gif는 무손실 압축을 사용하지만, 모든 프레임이 개별적으로 저장되기 때문에 파일 크기가 크다. 하지만 mp4는 손실 압축을 사용하여 연속된 프레임 간의 차이점만 기록한다고 한다.

Home 페이지에서 사용되는 gif는 예시 이미지로 제공되는 것이기 때문에, 파일에 손실이 있어도 무방하다고 판단했다. 웹 사이트의 목적은 gif 이미지를 찾을 수 있고 사용자에게 원본을 제공하는 것이지만, 예시 이미지는 원본이 중요하지 않다고 생각하여 mp4로 변환했다.

 

1) ffmpeg 다운

2) 압축 해제한 파일의 bin/ 경로에 변환하려는 gif 파일 복사 후 cmd를 열어 아래 명령어 입력

.\ffmpeg.exe -i trending.gif -vf "crop=trunc(iw/2)*2:trunc(ih/2)*2" -b:v 0 -crf 25 -f mp4 -vcodec libx264 -pix_fmt yuv420p trending.mp4

 

  • crop=trunc(iw/2)*2:trunc(ih/2)*2 : 이미지 너비와 높이를 짝수로 맞추기. 비디오 인코딩 시 일부 코덱의 경우 짝수 크기의 해상도를 요구한다고 한다.
  • trunc(iw/2)*2 : 너비와 높이를 절반으로 나눈 뒤 정수로 내림(trunc)한 값을 2로 곱해 짝수로 맞춘다.

 

3) mp4 파일을 프로젝트로 옮긴 뒤 images.d.ts에 아래 내용 추가

declare module '*.mp4';

4) 파일 import문을 mp4로 변경

5) video, 무한 루프로 변경

  <video autoPlay loop muted playsInline className={styles.featureImage}>
    <source src={imageSrc} type="video/mp4" />
  </video>
  • muted : 소리 없음
  • playsInline : PC나 안드로이드에서 비디오태그를 볼 때는 자동재생처리 시 인라인으로 볼 수 있지만, IOS인 아이폰이나 아이패드에서 보면 전체화면으로 처리됨 ⇒ playsInline 추가 시 해결됨

6) webpack 설정에 mp4 추가

  {
    test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif|webp|mp4)$/i,
    loader: 'file-loader',
    options: {
      name: 'static/[name].[ext]'
    }
  }

 

 

결과: 

파일명 mp4 변환 이전 mp4 변환 이후 압축률
trending 1230KB 108KB 약 91%
find 1939KB 213KB 약 89%
free 1654KB 122KB 약 92%

 

 

 

mp4 변환 후 Lighthouse 측정

 

 

 

 

 


 

필요한 것만 요청하기

 

1. 페이지별 리소스 분리 : Code Splitting

 

번들 크기를 확인하는 방법은, Webpack Bundle Analyzer를 설치하면 된다.

npm install --save-dev webpack-bundle-analyzer

 

webpack 설정

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

plugins: [
  new BundleAnalyzerPlugin({
    reportFilename: 'bundle-report.html',
    excludeAssets: [/node_modules/]
  })
],

 

 

 

 

 

npx webpack으로 실행해 보면, 초기 상태는 하나의 번들로 구성되어 있다.

 

박스의 크기는 용량을 의미하기 때문에, 어느 부분을 분리해야 할지 유추할 수 있다.

 

  • stat size : 압축 전 크기
  • parsed size : 압축 후 크기
  • gzipped size : gzip이 적용되었을 때의 크기 (보통 배포되었을 때 이 정도 크기가 나온다)

 

 

 

 

Code Splitting 방법 : Lazy Loading

 

main 페이지에서는 해당 페이지에서만 사용되는 컴포넌트가 있는 번들을 가져오고, /search 페이지로 이동한 경우에는 search 페이지에서만 사용되는 번들만 가져와야 한다.

이를 위해 Lazy Loading을 적용할 수 있다. React 16 버전부터 Lazy 기능이 추가되었는데, Lazy가 적용된 컴포넌트는 렌더링 될 때까지 코드가 로드되지 않는다. 청크 파일 별로 나눌 수 있어서 소스코드 크기 최적화에 도움이 된다.

 

주의해야 할 점은, Lazy Loading을 적용한 코드는 반드시 Suspense를 적용해야 한다. Lazy를 통해 동적으로 컴포넌트를 불러오고, 불러오는 중에는 Suspense의 fallback으로 로딩 상태를 처리할 수 있어야 한다.

 

import { lazy, Suspense } from 'react';
// ... 생략

const Home = lazy(() => import('./pages/Home/Home'));
const Search = lazy(() => import('./pages/Search/Search'));

const App = () => {
  return (
    <Suspense fallback={<>로딩 중...</>}>
      <Router>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/search" element={<Search />} />
        </Routes>
      </Router>
    </Suspense>
  );
};

export default App;

 

 

Lazy Loading 적용 후 webpack bundle analyzer를 실행해보면 아래와 같이 번들이 나뉘게 된다.

 

 

 

메인 번들 사이즈의 크기도 줄어든 것을 볼 수 있다.

 

나뉘어진 번들 파일들

 

나뉘어진 번들 파일들

 

 

main 페이지에서 불러오는 번들
search 페이지에서 추가로 불러온 526, 514 번들

 

 

추가로 빌드의 고유한 해시값을 부여하기 위해 webpack 설정을 바꿨다. contenthash를 통해 S3와 CDN이 변경을 감지할 수 있다고 한다. (CD 구현할 때 꼭 필요하다)

 

  output: {
    filename: '[name].[contenthash].js',
    path: path.join(__dirname, '/dist'),
    clean: true
  },

 

 

 

2. Tree Shaking

 

Tree Shaking은 사용하지 않는 코드를 제거하는 것으로, webpack v4 이상에서는 mode가 production인 경우 자동으로 Tree Shaking이 된다. 이때, sideEffects가 없는 파일만 처리한다. sideEffects 프로퍼티는 webpack 컴파일러에게 프로젝트의 파일이 '순수'한지 나타내고, 파일을 사용하지 않는 경우 제거해도 되는지 알려준다.

 

sideEffects에 파일명을 입력하는 경우, 해당 파일 또는 모듈은 부수효과가 없다고 webpack에게 알려주는 것이다. 반면 sideEffects: false인 경우, 모든 모듈을 Tree Shaking 대상으로 포함한다는 의미이다.

 

// package.json

{
  "name": "your-project",
  "sideEffects": false
}
  • 위 코드는 sideEffects를 포함하지 않는다는 뜻으로, 사용하지 않는 export는 제거해도 된다는 것을 webpack에게 알려준다.

 

"사이드 이펙트"는 하나 이상의 export를 보여주는 것 이외에도 import할 때 특별한 동작을 수행하는 코드입니다. 예를 들면 폴리필이 있습니다. 폴리필은 전체 스코프에 영향을 미치며 일반적으로 export를 제공하지 않습니다.
[Tree Shaking 공식문서]

 

 

Tree Shaking 방법 : react-icons 라이브러리 확인

 

tree shaking 전 react-icons 라이브러리 크기는 630KB

 

 

react-icons/ai 패키지는 HelpPanel과 SearchBar에서 사용 중이었다. 라이브러리 내부를 살펴보면, 하나의 js 파일에 ai에 속하는 모든 아이콘들이 들어 있었는데, 이는 빌드 시 모든 아이콘들이 번들에 포함되어 청크의 사이즈가 커지게 된다.

 

 

react-icons/ai

 

 

시도 1 : sideEffects 적용 (실패)

 

첫 번째 시도로는 react-icons/ai 라이브러리에 부수효과가 없다는 것을 명시하고 Tree Shaking이 되도록 sideEffects: false 설정을 추가하기로 했다.

 

Tree Shaking을 위해서는 빌드 시 컴파일러가 CommonJS 모듈로 변환하지 않아야 하기 때문에, package.json의 babel 프로퍼티에 modules: false를 추가했다. 이어서 sideEffects: false도 추가했다.

 

// package.json

{
  // ...
  "babel": {
    "presets": [
      "@babel/preset-env",
      "@babel/preset-react",
      {
        "modules": false
      }
    ],
    "plugins": [
      "@babel/plugin-transform-runtime"
    ]
  },
  "sideEffects": false
}

 

 

하지만 이렇게 설정을 해도 청크의 크기가 줄어들지 않아 확인해보니, react-icons/ai 내부에 sideEffects: false 설정을 이미 적용해놨기 때문에 바깥에서 sideEffects 설정을 해도 더 이상 Tree Shaking이 되지 않는다.

 

 

 

 

시도 2 : react-icons 라이브러리 삭제 및 react-icons/all-files 라이브러리 설치

 

react-icons/all-files는 아이콘 별로 js 파일을 별도로 갖고 있어서, 빌드 시 Tree Shaking 되며 필요한 모듈만 불러올 수 있다. 따라서 더 작은 크기의 청크가 생성된다.

[react-icons/all-files npmjs]

 

react-icons/all-files 사이즈는 8.34KB

 

 

재설치 이전 재설치 이후 압축률
630KB 8.34KB 약 99%

 

 

 

 

 

 

 

 

참고 자료

https://webpack.js.org/plugins/terser-webpack-plugin/

https://developers.google.com/speed/webp/download?hl=ko

https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture#the_type_attribute

https://webpack.kr/guides/code-splitting/

https://www.npmjs.com/package/webpack-bundle-analyzer

https://webpack.kr/guides/tree-shaking/