FEB:)DAIN

[우테코-FE] 성능 최적화하기 본문

코딩/공부

[우테코-FE] 성능 최적화하기

얌2 2023. 9. 3. 22:10
728x90

1. 요청 크기 줄이기

1-1. 소스코드 크기 줄이기 - minify/uglify 

플러그인 적용 전

 

MiniCssExtractPlugin, CssMinimizerPlugin 적용 후

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

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

1-2. 이미지 크기 줄이기 (✨10MB → 141KB✨)

  • gif → mp4 : https://cloudconvert.com/gif-to-mp4
  • mp4 압축 : https://www.freeconvert.com/video-compressor/download
  • png → webp : https://cloudconvert.com/png-to-webp
  • webp 압축 : https://tinypng.com/
  • 리사이즈: https://imageresizer.com/

아래처럼 plugin(image-minimizer-webpack-plugin)을 사용할 수도 있었지만, 파일이 4개밖에 없기도 했고 수동으로 하면 화질 저하를 최대한 막을 수 있지 않을까 싶어서 수동으로 변경했다.
❗️gif 파일도 'webp'로 설정해 주면 된다.

  new ImageMinimizerPlugin({
        deleteOriginalAssets: false,
        minimizer: {
          implementation: ImageMinimizerPlugin.imageminGenerate,
          options: {
            plugins: [['webp', { quality: 10 }]] // 0 ~ 100
          }
        }
      }),

+ CloudFront 압축 설정

Brotli 지원하는 곳은 Brotli 압축, 아니면 Gzip 압축
 

2. 필요한 것만 요청

html에 있는 font 관련 코드 제거

    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      rel="stylesheet"
      href="https://fonts.googleapis.com/css2?family=Josefin+Sans:ital,wght@0,400;0,700;1,400;1,700&display=fallback"
    />

미션에 필요한 font, weight, style만 들고와주었다. 그리고 보통 font display는 swap으로 하는 경우가 많은데, fallback이 좀 더 사용성이 좋은 것 같아서 fallback을 적용해 주었다. (폰트 다운로드 속도가 빠를 경우, Layout Shift가 발생하지 않는다)
✍🏻 
- swap: 대체 폰트를 먼저 보여주고, 웹 폰트가 모두 다운로드되면 대체 폰트 → 웹 폰트로 바꿔 보여주는 방식이다.
- fallback: 폰트 다운로드 속도가 빠를 경우, 바로 웹 폰트를 보여주고 속도가 느릴 경우, 대체 폰트를 보여주다가 다운로드가 완료되면 웹 폰트로 바꿔 보여주는 방식으로 보인다.

2-1. 페이지별 리소스 분리

react lazy 사용

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

import Loading from './components/Loading/Loading';

const ComponentSuspense = (component: JSX.Element) => {
  return <Suspense fallback={<Loading />}>{component}</Suspense>;
};

const App = () => {
  return (
    <Router>
      <NavBar />
      <Routes>
        <Route path={ROUTE_PATH.MAIN_PAGE} element={<Home />} />
        <Route path={ROUTE_PATH.SEARCH_PAGE} element={ComponentSuspense(<Search />)} />
      </Routes>
      <Footer />
    </Router>
  );
};

처음에는 Home만 lazy 적용을 하지 않았다. 이렇게 하면 Search 페이지로 바로 접속할 경우, bundle main에 포함된 Home 관련 module도 모두 가져오게 된다. 하지만 Search 페이지로 바로 접속할 확률이 현저히 낮다고 생각했다. (특히 이번 미션은 Home으로 접속되는 링크로만 들어올 것이기 때문에...) 따라서 Home의 초기 로딩이 느려지는 것을 막기 위해 Search에만 React lazy를 사용했다. 
 
Search만 lazy 

Home, Search lazy

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

import Loading from './components/Loading/Loading';

const App = () => {
  return (
    <Router>
      <NavBar />
      <Suspense fallback={<Loading />}>
        <Routes>
          <Route path={ROUTE_PATH.MAIN_PAGE} element={<Home />} />
          <Route path={ROUTE_PATH.SEARCH_PAGE} element={<Search />} />
        </Routes>
      </Suspense>
      <Footer />
    </Router>
  );
};

export default App;

하지만 렌더링 속도를 비교해보니 React lazy가 영향을 크게 주진 않았다. 아마도 Home이 그렇게 크지 않기 때문인 것 같다.

Search만 lazy 적용 vs Home, Search 모두 lazy 적용

Webpage Test에서 테스트를 해봐도 1초 이상 차이나지는 않는데... 조금이라도 빠른 게 좋지 않을까?
필요한 것만 요청하느냐 vs 초기 렌더링 속도를 조금이라도 줄이느냐 고민이었다🤔
무조건 둘 중 하나가 정답인 것은 아니기에 이번 미션을 기준으로 선택하기로 했다.
이번 미션은 바로 Home으로 들어오는 사용자가 99%일 것이라 판단, 최초 렌더링을 조금이라도 빠르게 하는 게 좋을 것 같아 Search만 lazy 적용하기로 했다.
만약 실제 memegle 서비스라면 Search 페이지에 핵심 기능이 있어 이 부분을 즐겨찾기 해놓고 Search로 바로 접속하는 사용자도 꽤 될 것이라 Home, Search 둘다 lazy 적용을 해줬을 것 같다.

2-2. 아이콘 패키지 Tree Shaking

webpack5에서는 (usedExports true이기 때문) react-icons를 써도 자동으로 tree shaking(사용하는 것만 빌드)해준다.

⚠️ 아래처럼 common js 설정이 되어있다면 tree shaking 되지 않으니 주의할 것

// tsconfig.json
  "compilerOptions": {
    "module": "CommonJS"
  },

하지만 이미 @react-icons/all-files를 설치한 뒤에 그 사실을 알았기 때문에 @react-icons/all-files를 사용해주었다.😂

(+ 그리고 @react-icons/all-files가 더 사이즈가 작다)

yarn add @react-icons/all-files
# or
npm install @react-icons/all-files --save
// example
import { AiOutlineInfo } from '@react-icons/all-files/ai/AiOutLineInfo';
import { AiOutlineClose } from '@react-icons/all-files/ai/AiOutlineClose';

 

3. 같은 건 매번 새로 요청하지 않기

3-1. CloudFront 캐시 설정

설정값

최대 TTL은 1년, 기본 TTL은 하루, 최소 TTL은 1초로 설정했다. (기본 캐시 설정)
 

mp4, webp 등 이미지, 비디오 파일은 max-age를 1년으로 설정해 주었다. 많이 바뀌지 않을 것이기 때문이다.

js, css 파일은 하루로 설정해주었다. 미션이 끝나서 바로바로 변경되는 것을 확인할 필요가 없어, 하루정도면 적당할 것 같다고 생각했다.

응답 헤더 정책도 설정해주었는데, index.html은 변경사항이 거의 없을 것이라 생각해 1년으로 정했다.
 

3-2. GIPHY의 trending API를 Search 페이지에 들어올 때마다 새로 요청하지 않도록 개선

 

 

  getTrending: (function () {
    const getCurrentUTC = () => {
      const currentUTC = new Date(Date.now()).toUTCString();
      const currentWithoutTime = currentUTC.replace(/\d{2}:\d{2}:\d{2}/, '');

      return currentWithoutTime;
    };

    let cacheStore: GifImageModel[] | null = null;
    const cachedDate: string = getCurrentUTC();

    return async function (): Promise<GifImageModel[]> {
      if (cacheStore !== null && cachedDate === getCurrentUTC()) {
        return cacheStore;
      }

      try {
        const gifs: GifsResult = await fetch(TRENDING_GIF_API).then((res) => res.json());

        cacheStore = convertResponseToModel(gifs.data);

        return cacheStore;
      } catch (e) {
        return [];
      }
    };
  })(),

클로저를 이용해 cacheStore에 gifs.data를 저장해 두었다.
그리고 캐싱 타임을 설정해 주었는데, 이는 UTC 기준으로 날짜가 변경되면 api를 요청하도록 했다.

Trending Api가 하루마다 바뀌기 때문에 이에 맞춰 캐싱 타임을 설정했다.

4. 최소한의 변경만 일으키기

4-1. 검색 결과 > 추가 로드 시 추가된 목록만 새로 렌더 - React memo

type GifItemProps = Omit<GifImageModel, 'id'>;

const GifItem = ({ imageUrl = '', title = '' }: GifItemProps) => {
  return (
    <div className={styles.gifItem}>
      <img loading="lazy" className={styles.gifImage} src={imageUrl} />
      <div className={styles.gifTitleContainer}>
        <div className={styles.gifTitleBg} />
        <h4 className={styles.gifTitle}>{title}</h4>
      </div>
    </div>
  );
};

export default memo(GifItem);

4-2. Layout Shift 없이 애니메이션 발생시키기

❗more tools > rendering > Layout Shift Regions클릭해서 확인 가능
 
right, top... → transfrom: translate(X, Y)
transform은 reflow가 일어나지 않고 repaint만 발생하기 때문이다.
 
font가 swap 될 때 폰트가 많이 달라서 그런지 Layout Shift가 발생했다.
 
개선 전

파란색 부분이 Layout Shift가 발생하는 부분이다.
 
개선 후


 

4-3. Frame Drop 방지 ((Chrome DevTools 기준) Partially Presented Frame 역시 최소로 발생) 

커서

스크롤

성능(performance) 탭에서 녹화 후 Frames를 확인하면 Partially Presented Frame이 발생하지 않는 것을 확인할 수 있다.
 
 
최종 bundle 크기

 

그 외...

렌더링 최적화도 진행했다. 사용하면서 성능이 저하될 것이라고 생각했기 때문이다.

Home 페이지에서 memegle(Home으로 향하는 버튼)을 클릭했을 때, Home인데도 불구하고 재렌더링이 계속 발생
(Search 페이지에서 start search 버튼을 클릭했을 때도 동일한 현상 발생)
 

이제는 Home 페이지에서 memegle(Home으로 향하는 버튼)을 클릭하면 재렌더링이 발생하지 않는다!
 
 

엔터를 계속 누르고 있으면 재렌더링/api 요청이 엄청나게 발생
onKeyPress → onKeyUp으로 바꿔주었다. 엔터를 누르고 뗄 때 이벤트가 발생하게 하여 과도한 재렌더링/Search api 요청을 방지했다.


onKeyUp으로 바꿔주는 것은 간단 해결 방법이고, 확실하게 막기 위해서는(연타할 수도 있기 때문) api 요청하는 부분에서 같은 keyword는 이전 gifs를 들고 오도록 하는 코드가 필요하다.

 

개선 전

lighthouse - Desktop

 

성능
bundle 사이즈 크기
Webpage Performance Test Result - Fast 3G (Paris)

 

개선 후

웹 사이트 -  https://d1wnj85errrmqw.cloudfront.net/

 

memegle - gif search engine for you

 

d1wnj85errrmqw.cloudfront.net

테스트
https://www.webpagetest.org/result/230907_AiDc18_4XZ/

 

WebPageTest Performance Test Results

Check out these web performance test results on WebPageTest.org:

www.webpagetest.org

728x90