React Performance

mochang2·2023년 12월 10일
0

FE

목록 보기
11/18

0. 공부하게 된 계기

성능을 향상시키는 적이 있냐는 질문을 많이 들어봤다.
미리 방법을 공부한 뒤 프로젝트에 언제든지 도입할 수 있도록 해보고자 한다.

지금부터 진행하는 테스트는 CRA(v5.0.1), npm run eject를 실행한 코드를 이용했다.
webpack은 v5이다.

1. re-rendering

여기서 re-rendering은 컴포넌트가 화면에 다시 그려진다는 것을 이야기하는 것이 아니다.
Virtual DOM이 React diffing algorithm을 통해 이전 렌더링과 변화가 생겼을 수도 있으니 확인해봐라 표시이다.
즉, 함수형 컴포넌트에서의 호출(=재실행)되는 개념이다.
따라서 불필요한 re-rendering이 많이 쌓이면 화면 변화가 느려질 수 있다.

어떤 컴포넌트는 re-rendering이 발생하지 않아도 다른 컴포넌트가 re-rendering이 발생해 형태가 깨질 수도 있다.
이때 해당 컴포넌트의 상태가 변화하지 않아도 re-paint될 수 있다.

1) memoization

when to use memoization in react 이런 글들은 수두룩하지만 너무 추상적인 표현인 것 같다.
직접 성능을 비교할 방법을 알아보고 싶었다.
이를 위해 re-rendering 시간을 측정하기 위한 3가지 툴을 비교해봤다.

  1. benchmark
  • 장점
    • 다운로드 횟수가 많으며 자료가 많음.
    • 컴포넌트 반복 실행 가능.
    • 최소 반복 횟수 지정 가능.
  • 단점
    • 서드 파티 라이브러리 필요.
    • 업데이트된지 오래됨.
    • 정확한 반복 횟수는 지정 못 함.
  1. react-performance-testing
  • 장점
    • 최근 업데이트됨.
    • 코드 변경 없이 test 파일로 실행 가능.
  • 단점
    • 서드 파티 라이브러리 필요.
    • 렌더링 시간이 특정 시간 이내인지 파악이 가능하나, 반복적인 렌더링이 정확히 얼마나 걸리는지 파악 못 함(가능하다고 하더라도 별도의 스크립트 파일 필요해보임).
    • 다운로드 횟수 적음.
    • ~예제 코드의 wait() 부분이 실행 안 됨. 버전 문제인가...~
  1. react developer tools
  • 장점
    • 서드 파티 라이브러리 설치 필요 없음.
    • UI로 파악 가능.
  • 단점
    • 반복적인 실행과 렌더링 시간 파악은 수동으로 가능.
    • 브라우저 성능, 네트워크 상태에 따라 렌더링 시간이 천차만별.

자료 많은게 짱이라고 생각하고, 반복 작업에 자동화를 생각했을 때 benchmark가 제일 낫다고 생각했다.

아래는 내가 짠 예제 코드이다.
shell script도 이용해보고, 브라우저가 아닌 node.js로도 돌려봤지만 이게 제일 나은 대안인 것 같다.

// index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from 'App';
import { WithMemoization, WithOutMemoization } from 'components/Test';

const root = ReactDOM.createRoot(document.getElementById('root'));

if (process.env.NODE_ENV === 'development') {
  const Benchmark = require('benchmark');
  const suite = new Benchmark.Suite();

  function performanceCheck1() {
    // @testing-library/react의 render는 매번 새롭게 컴포넌트를 생성하기 때문에
    // 메모이제이션이 안 되는 듯
    // root.render를 사용
    root.render(<WithMemoization />);
  }

  function performanceCheck2() {
    root.render(<WithOutMemoization />);
  }

  function compare(benchmark1, benchmark2) {
    // elapsed 기준으로 내림차순
    return benchmark2.times.elapsed - benchmark1.times.elapsed;
  }

  suite
    .add('w memo', performanceCheck1, { minSamples: 50 })
    .add('wo memo', performanceCheck2, { minSamples: 50 })
    .on('cycle', function (event) {
      // add eventlistener
      // name, operations/second(Hz), runned samples 출력
      console.log(String(event.target));
    })
    .on('complete', function () {
      // console.log('Fastest is ' + suite.filter('fastest').map('name'))
      // 공식 문서 방법
      // Hz(1초에 해당 컴포넌트가 몇 번 실행될 수 있는지를 알려줌)를 비교

      // 리렌더링에 걸리는 시간을 알기 위해 다른 방법으로 표현
      console.log(suite);
      suite.sort(compare);
      console.log('Sort by elapsed time', suite.map('name').join(' '));

      root.render(
        <React.StrictMode>
          <App />
        </React.StrictMode>
      );
    })
    .run({ async: true });
}

root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

이해를 돕기 위해 찍은 스크린샷이다.
suite 객체는 다음과 같은 구조를 가지고 있다.

suite

// components/Test.js
import { useMemo } from 'react';

const length = 10000000;

export function WithMemoization() {
  const value = useMemo(
    () =>
      Array.from({ length }, (_, i) => Math.floor(i / 1000)).reduce(
        (acc, curr) => acc + curr,
        0
      ),
    []
  );

  return <div>{value}</div>;
}

export function WithOutMemoization() {
  const value = Array.from({ length }, (_, i) => Math.floor(i / 1000)).reduce(
    (acc, curr) => acc + curr,
    0
  );

  return <div>{value}</div>;
}

위 결과를 실행하면 메모이제이션을 한 컴포넌트는 10초 내외의 elapsed time을, 메모이제이션을 하지 않는 컴포넌트는 100초 내외의 elapsed time을 가진다.

극단적인 결과를 보이기 위해 위와 같이 코드를 짰다.
실제로는 컴포넌트에 대략 n번 정도 re-rendering이 될 거 같다~ 라고 가정하고 minSamples를 조정해야 한다.
특정 횟수보다 적게 re-rendering되는 컴포넌트는 오히려 메모이제이션을 활용하는 것이 더 느리기 때문이다(예전에 증명한 글을 봤는데 그 글을 못 찾겠다. 그런데 어찌보면 당연한 말이다. 메모이제이션이 일종의 캐싱과 같은 역할을 하기 때문이다).
또한 메모리는 무제한으로 사용 가능한 자원이 아니기 때문에 배포 단계에서 react 성능을 체크할 수 있는 사이트나 툴을 다시 이용해보는 것이 좋겠다.

dependency compare

위 방법을 통해 memo, useCallback, useMemo 등의 메모이제이션을 활용하는 함수, 훅의 성능을 테스트할 수 있었다.
다만 확실히 학습하고자 언제 re-rendering 되는지 dependency를 조금 더 뜯어봤다.

dependency가 변경되면 re-rendering될 때 훅으로 감싼 코드를 다시 실행한다.
react는 dependency 변경을 확인하기 위해 shallow compare한다(Object.is()로 비교한다고 한다).
이를 확인하고자 다음 코드로 테스트했다.

function App() {
  const [count, setCount] = useState(0);
  const value = useMemo(() => {
    console.log('re-render');
    return count * 2;
  }, [count]);

  return (
    <div className="App">
      <header>{count}</header>
      <main>
        <button onClick={() => setCount(count + 1)}>+1</button>
      </main>
      <footer>{value}</footer>
    </div>
  );
}

위에는 당연히 버튼 누를 때마다 're-render'이 출력된다.
하지만 아래는 그렇지 않다.

function App() {
  let count = 1;
  const value = useMemo(() => {
    console.log('re-render');
    return count * 2;
  }, [count]);

  return (
    <div className="App">
      <header>{count}</header>
      <main>
        <button onClick={() => count++}>+1</button>
      </main>
      <footer>{value}</footer>
    </div>
  );
}

// 또는

function func() {
  // 만약 App 내부에 선언하면, useMemo가 버튼을 누를 때마다 re-rendering됨
}

function App() {
  const [count, setCount] = useState(0);
  const value = useMemo(() => {
    console.log('re-render');
    return count * 2;
  }, [func]);

  return (
    <div className="App">
      <header>{count}</header>
      <main>
        <button onClick={() => setCount(count + 1)}>+1</button>
      </main>
      <footer>{value}</footer>
    </div>
  );
}

React.memo Deep compare

에서 봤듯이 react에서 모든 비교는 shallow comparison이라고 생각해도 된다(불변성 유지!).
React.memo도 마찬가지이다.

그래서 아래와 같이 deep compare을 도와주게 하는 방법이 존재한다.

function moviePropsAreEqual(prevMovie, nextMovie) {
  return (
    prevMovie.title === nextMovie.title &&
    prevMovie.releaseDate === nextMovie.releaseDate
  );
}

const MemoizedMovie = memo(Movie, moviePropsAreEqual);

github issue에 다음과 같은 글이 있다.

You should always use React.memo LITERALLY, as comparing the tree returned by the Component is always more expensive than comparing a pair of props properties)

TOAST UI에 나와있듯이 props가 자주 변경되지 않는 컴포넌트면 사용하는 것이 좋다고 한다.

여기서 궁금증이 생겼다.
애초에 렌더링된 컴포넌트는 메모리에 있는 것이기 때문에 React.memo의 효율성을 따지기 위해서는 단순히 re-rendering이 효과적인지 따지는 것이 아니라 props comparison의 성능과 메모이제이션의 성능을 비교해야 하지 않을까?
만약 deep compare한다면 React.memo의 성능은 어떻게 될까?

다음과 같이 코드를 작성한 뒤 benchmark를 이용해 테스트했다.

export function ParentComponent1() {
  // react.memo를 사용한 컴포넌트를 자식으로 가짐
  const [count, setCount] = useState(0);

  return (
    <div className="App">
      <header>I'm parent</header>
      <main>
        <button onClick={() => setCount(count + 1)}>+1</button>
        <div>{count}</div>
        <MemoizedComponent
          count1={1}
          // props 수십 개 선언
        />
      </main>
    </div>
  );
}

export function ParentComponent2() {
  // react.memo를 사용하지 않은 컴포넌트를 자식으로 가짐
  const [count, setCount] = useState(0);

  return (
    <div className="App">
      <header>I'm parent</header>
      <main>
        <button onClick={() => setCount(count + 1)}>+1</button>
        <div>{count}</div>
        <ChildComponent
          count1={1}
          // props 수십 개 선언
        />
      </main>
    </div>
  );
}

function ChildComponent({ ...rest }) {
  return <div>I'm child</div>;
}

function compareProps(prevProps, nextProps) {
  // not best practice
  // 일부러 느리게 비교하기 위해서 만듦
  const prevPropsKeys = Object.keys(prevProps);
  const nextPropsKeys = Object.keys(nextProps);

  return (
    prevPropsKeys.length === nextPropsKeys.length &&
    prevPropsKeys.every((key) => prevProps[key] === nextProps[key]) &&
    nextPropsKeys.every((key) => prevProps[key] === nextProps[key])
  );
}

const MemoizedComponent = memo(ChildComponent, compareProps);

결과는 97번 렌더링하는데 메모이제이션을 사용한 컴포넌트가 11.367s, 메모이제이션을 사용하지 않은 컴포넌트가 11.178s 걸렸다.
미세하지만 메모이제이션을 사용하지 않은 게 더 유리했다.
물론 이 예시에서는 일부러 느린 compare 함수를 사용했으며 ChildComponent가 더 무거웠다면 다른 결과가 나왔을 것이다.

따라서 React.memo를 사용하기 전 해당 컴포넌트의 props가 자주 변경되는지와 자주 변경되지 않는다면 메모이제이션이 compare보다 값어치가 있는지를 확인해야 한다는 결론을 얻었다.

참고) class 컴포넌트의 extends React.PureComponent

React.memo랑 거의 똑같다.
class component의 shouldComponentUpdate 라이프사이클에서 this.props가 이전 렌더링과 바뀌었는지 얕은 비교를 통해 결정함으로써 업데이트 여부를 결정한다.
React.memo와 마찬가지로 리렌더링 비용 vs this.props 비교 비용을 비교하며 쓰는 것이 좋다.

수도 코드 비스무리하게 쓰면 아래 정도로 표현할 수 있다.

class Component extends React.Component {
  shouldComponentUpdate() {
    // isPropChanged = 얕은 비교의 결과
    return isPropChanged
      ? true // do render
      : false; // do not render
  }
}

2) key props

해당 내용과 관련된 자세한 것은 virtual DOM 파트에 기록해놨다.

간단히만 정리하자면 key는 모든 JSX가 기본적으로 갖는 property 값이다.
전역적으로 unique할 필요는 없지만 형제 요소들 간에는 unique해야 한다.

보통 return에서 Array.map을 이용할 때 많이 사용되기 때문에 index를 key값으로 주는 경우가 많다.
일반적인 경우에서는 문제가 되지 않지만 해당 데이터가 수정될 때 다시 정렬되는 과정을 거친 후에 rendering되는 데이터라면 재조정 과정에서 휴리스틱 알고리즘의 이점을 얻지 못한다.
이럴 때는 형제 요소 간 노드의 순서가 변해도 unique하게 노드를 추적할 수 있는 id 값 등을 주는 것이 좋다.

3) state 분리

리액트는 기본적으로 재귀적으로 컴포넌트를 렌더링 한다.
그러므로, 부모가 렌더링 되면 자식도 렌더링 된다.
렌더링 그 자체로는 문제가 되지 않는다.
렌더링은 리액트가 DOM의 변화가 있는지 확인하기 위한 절차일 뿐이다.
그러나 렌더링은 시간이 소요되며, UI 변화가 없는 불필요한 렌더링은 시간을 소비한다.

부모에서 관리하던 state를 자식 요소로 내린다면 형제 요소 간의 불필요한 re-rendering을 발생시키지 않을 수 있다.

// 분리 이전
function App() {
  const [data, setData] = useState<DataType[] | null>(null);
  const [categoryOption, setCategoryOption] = useState(DEFAULT_OPTION);
  const [searchText, setSearchText] = useState('');
  const [page, setPage] = useState(1); // 이 state 분리

  useEffect(() => {
    async function fetch() {
      const {
        data: { data },
      } = await api.get('/');

      setData(data);
    }

    fetch();
  }, []);

  return (
    <>
      <Filter>
        <Selection />{' '}
        {/* categoryOption, setCategoryOption를 전달받는 컴포넌트 */}
        <SearchInput /> {/* searchText, setSearchText를 전달받는 컴포넌트 */}
      </Filter>
      {faqs ? (
        <>
          <Table />
          {/* 필터링된 데이터를 토대로, 해당 페이지의 있는 데이터들을 보여주는 부분. 별도 컴포넌트 분리x */}
          <Pagination />
          {/* page, setPage를 전달받는 컴포넌트 */}
        </>
      ) : (
        <Loading />
      )}
    </>
  );
}

categoryOption, searchText가 변경될 때 Table가 re-render되는 것은 어쩔 수 없다.
하지만 page가 변경되면서 DataList가 re-render될 때, Filter가 re-render될 필요는 없다.

// 분리 이후
function App() {
  // ... 동일
  return (
    <>
      <Filter>{/* 동일 */}</Filter>
      {faqs ? (
        <FaqList
          faqs={faqs}
          categoryOption={
            categoryOption === DEFAULT_OPTION ? '' : categoryOption
          }
          searchText={searchText}
        />
      ) : (
        <Loading />
      )}
    </>
  )
}

function FaqList({ data, categoryOption, searchText }: DataListProps) {
  const filteredData = data.filter(
    (datum) =>
      datum.category.includes(categoryOption) &&
      datum.title.includes(searchText)
  )

  const [page, setPage] = useState(1)

  return (
    <>
      <Table rowCount={PER_PAGE_COUNT + 1}>
        {HEADERS.map((header, index) => (
          <HeaderCell key={index}>{header}</HeaderCell>
        ))}
        {currentPageFaqs(filteredData, page).map((datum) => (
          <DataCell key={datum.no} />
        ))}
      </Table>
      <Pagination {/* some props */} />
    </>
  )
}

2. 리소스 크기 줄이기

1) minify

빌드 산출물의 리소스 크기 자체를 줄이는 방법이다.
JS나 CSS에서 여백, 빈칸, 주석 등을 제거하고 이름이 긴 변수는 짧게 줄임으로써 브라우저에 전달되는 코드 양을 줄인다.

style 최소화

webpack을 사용한 CSS minify는 terser-webpack-plugin을 사용하거나 css-minimizer-webpack-plugin을 사용할 수 있다.

나는 styled-components를 많이 사용하기 때문에 해당 방법에 대해서만 정리하고자 한다.
원래는 babel-plugin-styled-components을 설치한 뒤 바벨 설정 파일을 수정해야 하지만 v4부터 babel macro라는 것을 제공한다고 한다.
사용법은 엄청 간단하다.

import styled from 'styled-components';

// 아래처럼만 바꾸기

import styled from 'styled-components/macro';

(스크린샷을 안 찍었지만 실제로 아래 방법을 이용하면 번들 크기가 줄어들었다)

comment 제거

CRA에서 기본적인 설정이 되어 있다.

module.exports = function (webpackEnv) {
  return {
    // ...
    optimization: {
      minimize: isEnvProduction,
      minimizer: [
        new TerserPlugin({
          terserOptions: {
            // ...
            output: {
              comments: false,
              // format: { comments: false } 로도 가능
            },
          },
          extractComments: false, // 추가. 하면 LICENSE 파일이 생기지 않음.
        }),
      ],
    },
  };
};

remove comments

위는 주석을 제거하기 전, 아래는 주석을 제거한 후이다.

참고) source map 제거
처음에는 <hash>.js.map 파일에 일부 주석이 그대로 남아 있어서 terser plugin이 이상한가 싶었다~해당 프로젝트 github issue도 다 뒤져보고 소스 코드도 다 뒤져봤다 ㅠㅠ~.
알고보니 해당 이름의 파일은 source map 파일이고 진짜 빌드 파일은 <hash>.js 파일이었다.

source map 파일은 브라우저 내에서 원본 소스 코드를 확인할 수 있도록 도와주는 디버깅 파일이다.
따라서 다음과 같은 이유로 실제 배포 시에는 제거되어야 한다.

  1. 내부 코드가 노출된다. 난독화한 이유가 없어진다.
  2. 메모리 부족 이슈가 발생할 수 있다. 이는 devtool 옵션을 source-map이 아닌 (development 모드에서 사용되는)cheap-module-source-map으로 바꿔서 해결할 수도 있다.

CRA는 간단히 package.json을 바꿔서 해결할 수 있다.

{
  "scripts": {
    "bulld": "GENERATE_SOURCEMAP=false react-scripts build",
    // 또는
    "build": "react-scripts build && rm build/static/**/*.map"
  }
}

webpack 설정을 바꿀 수도 있다.
간단히 devtool: false 설정하면 된다.

2) mocking 코드 제거

처음에는 mockServiceWorker.js 파일이 빌드 산출물에 minify도 되지 않고 그대로 포함돼서 없앨 방법을 찾아봤다.
github issue를 확인해보니 public 폴더에 있는 것은 그대로 복사되며, build에 포함된다고 해도 성능에는 문제가 없다고 한다.
index.html에서 해당 파일을 다운로드 하지 않으므로 당연한 말이었다.

그래도 혹여 편-안하지 않다면 단순히 package.json의 scripts 부분에 수동으로 지워주게끔 추가해주자.

3) console.~~ 제거

CRA에서는 기본적으로 옵션이 설정되어 있지 않다.
다음 옵션으로 제거 가능하다.

module.exports = function (webpackEnv) {
  return {
    // ...
    optimization: {
      minimize: isEnvProduction,
      minimizer: [
        new TerserPlugin({
          terserOptions: {
            compress: {
              drop_console: true,
            },
          },
        }),
      ],
    },
  };
};

4) 안 쓰는 node_modules 제거

CRA does not have devDependency에서 알 수 있듯이 빌드할 때 이미 react 동작에 필요한 모듈들만 남는다고 한다.
npm prune --production을 통해 해당 동작을 진행하는 것 같다.
실제로 안 쓰는 패키지 마구 설치한 뒤 build를 해도 산출물 용량이 동일하다.

5) tree shaking

tree shaking이란 코드를 빌드할 때 실제로 쓰지 않는 코드들을 제외한다는 의미이다.

side effect

tree shaking을 하기 전에 side effect를 알아야 한다.
사용하지 않더라도 다른 코드에 영향을 끼칠 수 있다고 판단하여 빌드 산출물에 포함하는 경우가 있다.
다음과 같은 경우이다.

  1. 전역 함수를 사용하는 경우(Object, Math, String, RegExp 등)
  2. 함수 실행 코드에서 멤버 변수를 변경하고 반환하는 경우
  3. class 내부에서 static property를 사용하는 경우(단, static method는 side effect 발생하지 않음)

아래는 webpack3까지는 side effect라고 분류했는데 4부터 tree shaking을 자동으로 진행한다.

  1. import한 모듈을 사용한 함수를 사용하지 않는 경우
  2. import한 모듈을 다시 export default한 경우

webpack이 볼 때는 side effect지만 개발자가 볼 때는 아닌 경우가 있다.
이런 경우 package.json에 이를 명시해줌으로써 export를 제거할 수 있다.

{
  // ...
  "sideEffects": false, // 모든 모듈에서 side effect가 없는 경우
  "sideEffects": ["./src/somewhere"] // side effect가 존재하는 파일. regexp 사용 가능
}

import 됐지만 사용 안되는 모듈 제거

아래 내용은 단순히 참고만 하자. import한 외부 라이브러리에 따라, 상황에 따라 다를 수 있다. 상황에 맞게 설정하자.

다음과 같은 순서를 적용해야 한다.

  1. ES6 모듈을 적용한다.

모든 모듈은 import, export 키워드를 사용한다.
따라서 commonJS는 tree shaking이 되지 않는다.
babel 설정도 추가해야 한다.

// babel.config.js
{
  //
  "presets": [
    [
        "@babel/preset-env",
        {
            "modules": false
        }
    ],
],
}
  1. side effect를 추가한다.

테스트해볼 코드는 다음과 같다.

한참 헤맨 부분인데 외부 라이브러리로 테스트하려고 했는데 lodashmoment는 commonJS를 사용해 tree shaking이 제공되지 않는다고 한다.

// App.jsx
import { ParentComponent1, ParentComponent2 } from './components/Memotest';
import dayjs from 'dayjs';
import * as utils from './lib/utils';

function App() {
  const today = utils.getToday();

  return (
    <div className="App">
      <header className="App-header">
        <ParentComponent1 />
        <ParentComponent2 />
      </header>
      <main>{today}</main>
    </div>
  );
}

// lib/utils.js
export const getToday = () => {
  return new Date().toISOString().slice(0, 10);
};

export const getDate = (datetime: string) => {
  return datetime.split(' ')[0];
};

위 코드에서 dayjsutils.getDate는 사용되고 있지 않다.
tree shaking의 효과를 보기 위해 다음과 같은 순서로 실행했다.

결과 1
tree shaking 미적용

1

결과 2
tree shaking 적용, "sideEffects": false, import * as utils from './lib/utils
tree shaking 적용, "sideEffects": false, import { getToday } from './lib/utils

2,3

결과 3
tree shaking 미적용, import dayjs from 'dayjs 삭제, import { getToday } from './lib/utils
tree shaking 미적용, import dayjs from 'dayjs 삭제, import * as utils from './lib/utils

tree shaking 적용 x, 사용 안 하는 모듈 전부 삭제, default import

4,5

두 가지 결론을 얻었다.

  1. tree shaking을 안 쓰는 코드가 삭제가 더 효과적이다(TODO: 이는 내가 한 실험에서만 편향된 결과일 수 있다. 실전에서 테스트해보고 내용을 추가하자).
  2. default로 import하는 것과 부분 import가 차이가 없다. 이는 webpack4부터 자동으로 최적화해줘서라고 한다.

side effects 실험
side effects가 효과가 있는지에 대한 실험은 stack overflow에 있으니 여기를 참고하자.
결론: side effects를 가진 모듈이 적을수록 번들 사이즈가 줄어든다.

6) 사진 포맷 변경

내가 입사하기 전에 회사에서 사용하는 이미지 포맷을 전부 webp로 전환한 적이 있다.
그리고 마음을 전해요 사이드 프로젝트 진행 시에도 (귀찮지만...) 서버에서 사진을 webp로 전환했다.
사진 용량을 줄임으로써 로딩 속도, 부하 등을 최소화하기 위해서이다.

webp는 구글에서 만든 새로운 이미지 포맷이다.
구글에 따르면 다음과 같은 장점이 있다.

WebP는 웹에서 이미지에 우수한 무손실 및 손실 압축을 제공하는 최신 이미지 형식입니다. 웹마스터와 웹 개발자는 WebP를 사용하여 더 작고 풍부한 이미지를 만들어 웹을 더 빠르게 만들 수 있습니다.
WebP 무손실 이미지는 PNG에 비해 크기가 26% 더 작습니다. WebP 손실 이미지는 동등한 SSIM 품질 색인에서 비슷한 JPEG 이미지보다 25~34% 더 작습니다.
무손실 WebP는 22% 추가 바이트의 비용으로 투명성을 지원(알파 채널이라고도 함)합니다. 손실 RGB 압축이 허용되는 경우 손실 WebP는 투명도도 지원하며, 일반적으로 PNG에 비해 3배 더 작은 파일 크기를 제공합니다.
손실, 투명도, 투명도는 모두 애니메이션 WebP 이미지에서 지원되며 GIF 및 APNG에 비해 축소된 크기를 제공할 수 있습니다.

caniuse에 따르면 23년 6월 현재 대부분의 브라우저에서 webp 형식을 제공한다.
~IE는 어차피 알바 아니니까...~

caniuse-webp

7) font

개념

  • typeface: 서체. 글꼴 전체를 의미. 굵기나 스타일 등이 포함됨(Helvetica).
  • font: 글꼴. 단일 굵기와 스타일을 포함(10px Helvetica bold).
  • web font: 웹에서 사용 가능한 font. 사용자 로컬에 font가 없어도 브라우저가 외부 static file을 불러와서 페이지에 적용시켜 보여줄 수 있음. 다만 기본적으로 무거워서 UX를 저해시키는 요소가 존재.

format

woff2, woff를 쓰자
여러 포맷 중 일반적으로 woff2 > woff > ttf > eot > svg 순으로 파일 크기가 작아 로딩 속도가 빠르다.

@font-face {
  font-family: hanna-11-years;
  src: url('../fonts/bm-hanna-11-years.woff2') format('woff2'), // 브라우저에 선언된 순서대로 지원 가능한 파일 형식을 다운로드 하기 때문에 woff2를 가장 먼저 선언
    url('../fonts/bm-hanna-11-years.woff') format('woff'); // format은 브라우저가 이해할 수 있도록 써주는 습관을 가지자
}

만약 시스템에 설치된 font로도 충분하다면 local(sans-serif) 등으로 명시를 해 font를 다운로드 받지 않게 할 수도 있다.

caniuse woff2, woff에 따르면 23년 7월 현재 대부분의 브라우저에서 woff 형식을 제공하고 woff2도 IE와 opera mini를 제외하고 모든 브라우저에서 지원한다.

font-display

  • Flash of Invisible Text(FOIT): 브라우저가 font를 다운로드하기 전에 font가 보이지 않는 현상. 대부분의 모던 브라우저의 기본 속성.
  • Flash of Unstyled Text(FOUT): 브라우저가 font를 다운로드하기 전에 font가 적용되지 않은 글자가 보이는 현상. IE 등의 브라우저의 기본 속성.

글에 적힌 내용이 중요하다면 FOUT 방식이 UX에 더 좋은 영향을 끼칠 것이다.
이럴 때 font-display: swap을 추천한다.

  • auto: 브라우저 기본 옵션 적용.
  • swap: web font가 로딩되기 전까지 fallback font로 글자를 보여주는 것.
  • block: 3초 이전까지 web font가 로딩되지 않으면 글자를 보여주지 않다가 web font가 로딩되면 글자를 보여줌. web font가 3초 이후에 로딩되면 3초 이후부터는 fallback font를 보여주다가 web font 로딩 이후 web font를 적용해서 보여줌.
  • fallback: 0.1초 정도 글자가 보이지 않는 블록이 발생하며, 이후에는 fallback font를 보여줌. web font가 3초 이내로 다운로드 되지 않는다면, web font 다운로드와 상관없이 앞으로 계속 fallback font가 보여짐.
  • optional: 우선 0.1초 동안 글자가 보이지 않고 그 후 fallback font로 전환. web font를 다운로드하지만 브라우저가 네트워크 상태를 파악해 네트워크의 연결 상태가 안 좋으면 web font의 다운로드가 완료되어도 캐시에 저장만 하고 전환은 하지 않음.

preload

특정 리소스를 미리 로드하면 현재 페이지에 중요하다고 확신하기 때문에 브라우저가 검색할 때보다 더 빨리 가져오고 싶다고 알리는 기능이다.

참고

subset font

subset font는 불필요한 글자를 제거하고 사용할 글자만 남겨둔 폰트이다.
영어는 26개 알파벳으로 이루어져 있고 영문 폰트에는 대소문자를 포함해 총 72자의 글자가 필요하지만 한글은 자음, 모음의 조합으로 구성되어 있기 때문에 모든 경우를 조합하면 한글의 글자 수는 11,172자나 된다.
그래서 일반적으로 한글 폰트 파일은 영문 폰트 파일보다 용량이 크다.
"갂, 갃, 갅, 갆, 갋, 갌, 갍, 갎, 갏, 갘" 등 거의 사용되지 않는 불필요한 글자를 폰트에서 제거하고 사용할 글자만 남겨 둔 subset font는 기본 font보다 용량이 작다.

font fallback

참고로 이 부분은 성능과는 크게 관련이 없다.
단순히 UX 향상에 도움이 되는 부분인데 ~그냥 공부한 김에 같이 추가하고 있다.~

모든 폰트마다 높낮이, 기본 사이즈 등이 달라서 폰트 적용 전과 후에 Layout Shift가 발생하는 경우가 있다.
아래 사진을 보면 subtitle 영역에 web font 적용 전후로 Layout이 다른 것을 알 수 있다(미세하지만 카카오톡 로고와 네이버 로고에 차이가 있다).

before web font loaded after web font loaded

font 설명

폰트에는 위와 같은 속성이 있기 때문이다.

  • Ascent: measures the furthest distance that a font’s glyphs extend above the baseline.
  • Descent: measures the furthest distance that a font’s glyphs extend below the baseline.
  • Line gap(leading): measures the distance between successive lines of text.

아래와 같은 방식으로 선언하면 된다.

@font-face {
  font-family: hanna-11-years;
  src: url('../fonts/bm-hanna-11-years.woff2') format('woff2'), url('../fonts/bm-hanna-11-years.woff')
      format('woff');
}

@font-face {
  font-family: 'hanna-11-years fallback';
  src: local('Segoe UI'), local('Roboto'), local(sans-serif); // ChatGPT에게 물었을 때 한나는 11살체와 비슷한 너~낌의 font들이란다
  ascent-override: 80%;
  descent-override: 20%;
}

참고)
https://vertical-metrics.netlify.app/ 에서 font를 업로드하면 ascent, descent, units per em을 구할 수 있다.
사이드 프로젝트에서 적용해봤으나 육안으로 달라지는 것은 모르겠어서(여전히 CLS 발생) 브라우저 지원 범위인지, 한글은 지원이 안 되는지, 수치가 잘못됐는지, override 속성이 지원 안되는 폰트인지 찾아봤으나 이것들이 원인이 아니었다.
폰트마다 가지고 있는 font-weight, font-stretch 등의 속성 또한 반영이 돼서 생기는 문제였다.
~결국 내가 만든 폰트도 아니고 배민 폰트 관련돼서 더 공개된 내용도 없어서 override 속성 적용으로 CLS 해결은 포기했다.~

8) 압축

TODO: 적용해본 뒤 추가하기

3. 초기 렌더링 빠르게 하기

1) additional HTML wrapper 최소화

컴포넌트 구역을 나누기 위해 의미없는 <div> 등으로 감싸는 것보다 <React.Fragment>로 감쌈으로써 화면에 그릴 element를 최소화할 수 있다.
단순히 구역을 나눌 때는 해당 방법을 적용할 수 있겠지만, 컴포넌트를 감싸는 wrapper까지 바꿀 필요는 없다고 생각한다.
페이지마다 동일한 스타일을 보장해야되는 컴포넌트일 경우 특히 그렇다.

2) SSR

CSR vs SSR로 나눠서 작성하는 것은 너무 길어질 것 같다.
정말 좋은 참고 자료이다: https://www.youtube.com/watch?v=YuqB8D6eCKE&t=549s

  • CSR
    • 장점
      • 화면 깜박임이 없음
      • 초기 로딩 이후 구동 속도가 빠름
      • TTV(Time To View)와 TTI(Time To Interact) 사이 간극이 없음
      • 서버 부하 분산
    • 단점
      • 초기 로딩 속도가 느림
      • SEO에 불리
  • SSR
    • 장점
      • 초기 구동 속도가 빠름
      • SEO에 유리함
    • 단점
      • 화면 깜빡임이 있음
      • TTV와 TTI 사이 간극이 있음
      • 서버 부하가 있음

CSR에서 렌더링에 대한 부하를 클라이언트가 나눴던 것을 다시 SSR을 통해 서버에게 부담을 전가할 수 있다.
대신 초기 렌더링 때 많은 정적 파일을 클라이언트에 주지 않아도 된다.

https://medium.com/jspoint/a-beginners-guide-to-react-server-side-rendering-ssr-bf3853841d55 를 참고하면 Express로 SSR을 구현하는 방법이 상세하게 나와 있다.
하지만 설정이 너무 복잡하고 백엔드에도 react, babel 관련된 모듈들을 설치해야 한다.
공부 목적으로는 상관없겠지만 백엔드와 프론트엔드가 하는 일을 분리하기 위해서라도 나는 Next와 같은 SSR 프레임워크를 쓰는 게 맞다고 생각한다.

3) code splitting

code splitting은 SPA라 해도 해당 페이지에서 필요한 모듈만 브라우저에서 로딩할 수 있도록 코드를 chunk 단위로 분해하는 것이다.

참고 chunk
chunk란 하나의 덩어리라는 뜻으로, 코드 스플리팅 시 생성되는 JS 파일 조각을 의미한다.

require.ensure

require.ensure(
  ['dependency'],
  function (require) {
    var module = require('경로 또는 모듈'); // 이렇게 require해서 사용하면 됨
  },
  'chunk name'
);

코드 아무 곳에서 require.ensure로 시작하는 함수를 호출할 수 있다.
commonJS 방식이므로 이런 것만 있다는 것만 알고 넘어가겠다.

import

webpack을 이용한 code splitting 방식이다.
@babel/plugin-syntax-dynamic-import 모듈을 설치하여 바벨 설정에 적용한 뒤 다음과 같이 코드를 짠다.

import('./math').then((math) => {
  console.log(math.add(16, 26));
});

CRA를 하면 기본적으로 사용할 수 있는 방식이라고 한다.
해당 방식을 사용하면 catch를 붙여서 에러 핸들링을 할 수 있는 장점이 있다.
또한 webpack이 해당 코드가 필요할 때마다 비동기적으로 로딩해주므로 추가적으로 설정할 부분이 적다.

webpack chunk 파일에 대한 캐시 방법에 대해서는 https://www.zerocho.com/category/Webpack/post/58ad4c9d1136440018ba44e7 를 참고하자.
hash와 chunkhash의 옵션을 제공한다.

React.lazy()

react에서 기본적으로 제공해주는 React.lazy()가 있다.
공식 문서에서 3가지 상황에 대해서 추천한다.

  1. App 최상단에서 page 컴포넌트(routing)
  2. user interaction으로 화면에 보이는 컴포넌트
  3. 페이지가 길어서 처음 viewport에 나오지 않는 컴포넌트
테스트

나는 간단하게 page routing에 대해 code splitting을 적용해봤다.

코드는 엄청 간단하다.
기존 코드를 주석처리하고 pages에서 import한 컴포넌트를 lazy로 감싸줬다.
아쉽게도 아직까지는 lazy로 감싸는 컴포넌트는 default export된 컴포넌트에 대해서만 기능을 제공한다고 한다.

import { Suspense, lazy } from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
// import {
//   Coupon,
//   Faq,
//   FaqCategoryManagement,
//   FaqDetail,
//   FaqRegister,
//   FaqUpdate,
//   Inquiry,
//   Main,
//   Notice,
//   Page404
// } from 'pages'
import ROUTE from 'routes/routeMap';

const Coupon = lazy(() => import('pages/Coupon'));
const Faq = lazy(() => import('pages/Faq'));
const FaqCategoryManagement = lazy(() => import('pages/FaqCategoryManagement'));
const FaqDetail = lazy(() => import('pages/FaqDetail'));
const FaqRegister = lazy(() => import('pages/FaqRegister'));
const FaqUpdate = lazy(() => import('pages/FaqUpdate'));
const Inquiry = lazy(() => import('pages/Inquiry'));
const Main = lazy(() => import('pages/Main'));
const Notice = lazy(() => import('pages/Notice'));
const Page404 = lazy(() => import('pages/Page404'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>loading...</div>}>
        <Routes>
          <Route path={ROUTE.main} element={<Main />} />
          <Route path={ROUTE.coupon} element={<Coupon />} />
          <Route path={ROUTE.notice} element={<Notice />} />
          <Route path={ROUTE.faqRegister} element={<FaqRegister />} />
          <Route path={ROUTE.faqUpdate} element={<FaqUpdate />} />
          <Route path={ROUTE.faqDetail} element={<FaqDetail />} />
          <Route path={ROUTE.faq} element={<Faq />} />
          <Route
            path={ROUTE.faqCategoryManagement}
            element={<FaqCategoryManagement />}
          />
          <Route path={ROUTE.inquiry} element={<Inquiry />} />
          <Route path="*" element={<Page404 />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

lazy loading을 적용하는 부분을 <Suspense>로 감싸준다.
fallback은 chunk 파일을 불러오는 도중 보여줄 로딩과 같은 요소를 넣는다.
에러 핸들링은 여기를 참고하자.

코드를 수정한 후 빌드하니 다음과 같은 chunk 파일들이 엄청 많이 생겼다.

build result

빌드 산출물 비교

  1. Chrome Dev Tools
    ctrl + shift + p를 눌러 'show coverage'를 검색하면 현재 페이지에서 쓰이지 않지만 파싱된 코드의 비율을 볼 수 있다.

before code splitting(coverage)

after code splitting(coverage)

위 사진은 code spliiting 전, 아래 사진은 code splitting 후이다.
(예제 코드가 <Main>은 비어있는 컴포넌트고 <Faq>만 구현된 거라 좀 편향되긴 했지만) 99%나 되던 비율이 46%까지 떨어졌다.

  1. @babel/webpack-bundle-analyzer
    eject 없이 webpack-bundle-analyzer 사용하기를 참조했다.

before bundle analyzer

after bundle analyzer

841KB나 되던 main 번들이 689KB까지 줄어들었다.

4. 서버측 응답 최적화하기

TODO: 다뤄본 부분이 아니라서 서비스 배포한 뒤에 추가하자

CDN
web worker
content compression

5. 성능 측정 방법

1) 성능과 관련된 키워드

  • FPS(Frame Per Second)
    • 초당 화면에 그려지는 프레임의 개수. 60 이상이 사람이 느끼기에 불편하지 않은 수치라고 함.
  • FTTB(First Time To Byte)
    • HTTP를 요청했을 때 처음 byte(정보)가 브라우저에 도달하는 시간. 작을수록 유저 경험과 SEO에 유리.
  • LCP(Largest Content Paint)
    • 사용에 불편이 없을 정도로 주요 컨텐츠들이 로드된 시점.
  • FID(First Input Delay)
    • 버튼 클릭 등 interaction이 발생할 때, 그에 따른 반응이 일어나는데 얼만큼의 시간이 걸리는지.
  • CLS(Cumulative Layout Shift)
    • 레이아웃이 얼마나 안정되어 있는지를 나타내는 지표. 텍스트를 읽고 있는데 갑자기 높이를 차지해 버린 이미지 때문에 스크롤을 내리는 등 차례차례 다른 시점에 생성되는 요소들 때문에 자꾸만 요소들이 기존의 위치에서 벗어나는 일 등이 발생하면 수치가 커짐.

1) Chrome Devtools

공식 문서가 너무 잘 나와 있어서 간단히 정리만 하겠다.

  1. 녹화를 한다.
  2. CPU 윗 파트(FPS)가 붉은 줄이거나 CPU 파트가 보라색, 초록색으로 가득차 있다면 해당 부분을 위주로 분석한다.
  3. Main 파트를 확인한 뒤 spread를 클릭하면 bottleneck과 해당 bottleneck을 발생시키는 코드 위치를 파악할 수 있다.

예시 코드는 다음 부분에서 bottleneck이 발생한다.

app.update = function (timestamp) {
  for (var i = 0; i < app.count; i++) {
    var m = movers[i]; // movers = document.querySelectorAll('.mover')
    if (!app.optimize) {
      var pos = m.classList.contains('down')
        ? m.offsetTop + distance
        : m.offsetTop - distance;
      if (pos < 0) pos = 0;
      if (pos > maxHeight) pos = maxHeight;
      m.style.top = pos + 'px';
      if (m.offsetTop === 0) {
        m.classList.remove('up');
        m.classList.add('down');
      }
      if (m.offsetTop === maxHeight) {
        m.classList.remove('down');
        m.classList.add('up');
      }
    } else {
      var pos = parseInt(m.style.top.slice(0, m.style.top.indexOf('px')));
      m.classList.contains('down') ? (pos += distance) : (pos -= distance);
      if (pos < 0) pos = 0;
      if (pos > maxHeight) pos = maxHeight;
      m.style.top = pos + 'px';
      if (pos === 0) {
        m.classList.remove('up');
        m.classList.add('down');
      }
      if (pos === maxHeight) {
        m.classList.remove('down');
        m.classList.add('up');
      }
    }
  }
  frame = window.requestAnimationFrame(app.update);
};

위 코드에서 비효율성을 초래하는 부분은 높이를 확인할 때 pos를 확인하냐 offsetTop을 확인하냐의 차이인 것 같다.
혹시 offsetTop과 관련된 비효율성이 있는지 싶어서 검색해봤는데 해당 내용은 없다.
결국 모든 mover에 대해 offsetTop을 알기 위해 지속적으로 DOM에 접근해서 느려진 것 같다.

FPS

원래 FPS가 performance 탭에 바로 존재한다고 하는데, (정확히 언제인지는 모르겠지만) 언젠가부터 사라진 것 같다.
지금은 확인하기 위해서는 ctrl + shift + p를 누른 후 'show frame per second'를 입력하면 현재 화면의 FPS를 확인할 수 있다.

fps

2) React Developer Tools

블로그에 사용법은 상세히 나와 있다.
Profiler와 General을 이용하면 어느 컴포넌트를 렌더링할 때 느린지, 어떤 state가 변경될 때 불필요한 렌더링이 발생하는지 알 수 있다.

하지만 블로그 예시처럼 두 개의 컴포넌트를 직접적으로 비교하는 것은 자주 사용하지 못할 것 같다.
테스트하기 위해 수정해야될 코드가 너무 많기 때문이다.
그리고 렌더링 시간이 매번 달라지기 때문에 절대적인 렌더링 시간보다 bottleneck을 찾는데 사용해야 될 것 같다.

참고 및 사진 출처

https://im-developer.tistory.com/198
https://ui.toast.com/weekly-pick/ko_20190731
https://medium.com/naver-fe-platform/webpack%EC%97%90%EC%84%9C-tree-shaking-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-1748e0e0c365
https://helloinyong.tistory.com/305
https://jforj.tistory.com/166
https://huns.me/development/2265
https://developers.google.com/speed/webp?hl=ko
https://yceffort.kr/2021/06/ways-to-faster-web-fonts
https://whales.tistory.com/66
https://developer.chrome.com/blog/font-fallbacks/
https://www.youtube.com/watch?v=YuqB8D6eCKE&t=549s

profile
개인 깃헙 repo(https://github.com/mochang2/development-diary)에서 이전함.

0개의 댓글