[React] 렌더링 최적화

useMemo와 useCallback

React에서 제공하는 Hook API 중 하나이다.

컴포넌트의 렌더링 성능을 최적화하는 역할을 한다.

 

 

컴포넌트는 언제 렌더링 될까요?

 

자신의 state가 변경되거나, 

부모에게서 받은 props가 변경 되었을 때마다 다시 렌더링 된다.

 

심지어 자식 컴포넌트에서 렌더링 최적화를 위한 별도의 코드를 추가하지 않으면,

부모에게서 받는 props가 변경되지 않았더라도 다시 리렌더링 된다.

 

예시 코드

https://codesandbox.io/s/angry-bash-ovd8pk?file=/src/App.js 

 

angry-bash-ovd8pk - CodeSandbox

angry-bash-ovd8pk by leehwarang using react, react-dom, react-scripts

codesandbox.io

 

App.js

import "./styles.css";
import React, { useState } from "react";

export default function Parent() {
  const [number, setNumber] = useState(1);
  const age = 1;

  return (
    <div className="App">
      <button
        onClick={() => {
          setNumber(number + 1);
        }}
      >
        Click
      </button>
      <Child age={age} />
    </div>
  );
}

const Child = React.memo((props) => {
  console.log("Child component");
  console.log(props.age);
  return <div>Child</div>;
});

 

Parent라는 함수 컴포넌트와 Child라는 함수 컴포넌트가 있다.

 

Parent 컴포넌트에서는 useStatenumber 라는 상태값을 만들고, 버튼 클릭 시 setNumber를 통해 상태값을 변경한다.

그럼 Parent 컴포넌트는 다시 렌더링 된다.

➡️ Child 컴포넌트도 다시 렌더링 된다.

 

Child 컴포넌트에는 age 값을 넘긴다. age는 상태값이 아닌 일반적인 값이다. 

그렇기 때문에 Parent가 다시 렌더링 될 때 age 값은 변하지 않는다.

🙅‍♀️ Child 컴포넌트는 다시 렌더링될 필요가 없다!

 

 

함수형 컴포넌트를 최적화하기 전에 알아야 할 것들

 

함수 컴포넌트는 그냥 함수다. jsx를 반환할 뿐이다.

함수 컴포넌트가 렌더링 된다는 것은 누군가(부모 컴포넌트)가 그 함수를 호출하여 실행되는 것이다.

함수가 실행될 때마다 내부에 선언되어 있던 표현식(변수 등)들도 매번 다시 계산된다.

 

예시 코드

https://codesandbox.io/s/frosty-pine-nvhewx?file=/src/App.js 

 

frosty-pine-nvhewx - CodeSandbox

frosty-pine-nvhewx by leehwarang using react, react-dom, react-scripts

codesandbox.io

 

App.js

import React, { useState, useCallback } from "react";
import Info from "./Info";
import "./styles.css";

const App = () => {
  const [color, setColor] = useState("");
  const [movie, setMovie] = useState("");

  const onChangeHandler = useCallback((e) => {
    if (e.target.id === "color") setColor(e.target.value);
    else setMovie(e.target.value);
  }, []);

  return (
    <div className="App">
      <div>
        <label>
          What is your favorite color of rainbow ?
          <input id="color" value={color} onChange={onChangeHandler} />
        </label>
      </div>
      <div>
        What is your favorite movie among these ?
        <label>
          <input
            type="radio"
            name="movie"
            value="Marriage Story"
            onChange={onChangeHandler}
          />
          Marriage Story
        </label>
        <label>
          <input
            type="radio"
            name="movie"
            value="The Fast And The Furious"
            onChange={onChangeHandler}
          />
          The Fast And The Furious
        </label>
        <label>
          <input
            type="radio"
            name="movie"
            value="Avengers"
            onChange={onChangeHandler}
          />
          Avengers
        </label>
      </div>
      <Info color={color} movie={movie} />
    </div>
  );
};

export default App;

 

Info.js

import React, { useMemo } from "react";
import "./styles.css";

const getColorKor = (color) => {
  console.log("getColorKor");
  switch (color) {
    case "red":
      return "빨강";
    case "orange":
      return "주황";
    case "yellow":
      return "노랑";
    case "green":
      return "초록";
    case "blue":
      return "파랑";
    case "navy":
      return "남";
    case "purple":
      return "보라";
    default:
      return "레인보우";
  }
};

const getMovieGenreKor = (movie) => {
  console.log("getMovieGenreKor");
  switch (movie) {
    case "Marriage Story":
      return "드라마";
    case "The Fast And The Furious":
      return "액션";
    case "Avengers":
      return "슈퍼히어로";
    default:
      return "아직 잘 모름";
  }
};

const Info = ({ color, movie }) => {
  const colorKor = useMemo(() => getColorKor(color), [color]);
  const movieGenreKor = useMemo(() => getMovieGenreKor(movie), [movie]);

  return (
    <div className="info-wrapper">
      제가 가장 좋아하는 색은 {colorKor} 이고, <br />
      즐겨보는 영화 장르는 {movieGenreKor} 입니다.
    </div>
  );
};

export default Info;

 

App 함수 컴포넌트는 colormovie라는 state 값을 가지고 있다.

무지개 색 중 가장 좋아하는 색이 무엇인지 입력할 수 있다.

radio 버튼을 통해서 가장 좋아하는 영화를 선택할 수 있다.

그리고 각각 input 마다 동일한 onChangeHandler 라는 onChange함수가 걸려 있다.

Info 자식 컴포넌트에 colormovie 상태값을 props로 내려준다.

 

Info 함수 컴포넌트에서 color, movie 값을 받으면 getColorKor, getMovieGenreKor 함수를 통해 한글 이름으로 변환한다.

 

App 함수 컴포넌트에서 state 값이 변경되면 Info 함수 컴포넌트가 다시 렌더링 되는 것을 확인할 수 있다.

 

state값이 color, moviemovie만 변경이 된다면?

🙅‍♀️ color state에 대한 계산은 다시 할 필요가 없다.

const colorKor = useMemo(() => getColorKor(color), [color]);

위와 같이 useMemo Hook 을 사용하여 color 값이 변경될 때만 함수가 실행되도록 할 수 있다.

 

 

공식 문서에서 useMemo에 대해 어떻게 설명하는지 살펴보자.

https://ko.reactjs.org/docs/hooks-reference.html#usememo

 

Hooks API Reference – React

A JavaScript library for building user interfaces

ko.reactjs.org

useMemo

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

메모이제이션된 값을 반환합니다.

 

“생성(create)” 함수와 그것의 의존성 값의 배열을 전달하세요. useMemo는 의존성이 변경되었을 때에만 메모이제이션된 값만 다시 계산 할 것입니다. 이 최적화는 모든 렌더링 시의 고비용 계산을 방지하게 해 줍니다.

 

useMemo로 전달된 함수는 렌더링 중에 실행된다는 것을 기억하세요. 통상적으로 렌더링 중에는 하지 않는 것을 이 함수 내에서 하지 마세요. 예를 들어, 사이드 이펙트(side effects)는 useEffect에서 하는 일이지 useMemo에서 하는 일이 아닙니다.

 

배열이 없는 경우 매 렌더링 때마다 새 값을 계산하게 될 것입니다.

 

useMemo는 성능 최적화를 위해 사용할 수는 있지만 의미상으로 보장이 있다고 생각하지는 마세요. 가까운 미래에 React에서는, 이전 메모이제이션된 값들의 일부를 “잊어버리고” 다음 렌더링 시에 그것들을 재계산하는 방향을 택할지도 모르겠습니다. 예를 들면, 오프스크린 컴포넌트의 메모리를 해제하는 등이 있을 수 있습니다. useMemo를 사용하지 않고도 동작할 수 있도록 코드를 작성하고 그것을 추가하여 성능을 최적화하세요.

주의

의존성 값의 배열은 함수에 인자로 전달되지는 않습니다. 그렇지만 개념적으로는, 이 기법은 함수가 무엇일지를 표현하는 방법입니다. 함수 안에서 참조되는 모든 값은 의존성 값의 배열에 나타나야 합니다. 나중에는 충분히 발전된 컴파일러가 이 배열을 자동으로 생성할 수 있을 것입니다.

eslint-plugin-react-hooks 패키지의 일부로써 exhaustive-deps 규칙을 사용하기를 권장합니다. 그것은 의존성이 바르지 않게 정의되었다면 그에 대해 경고하고 수정하도록 알려줍니다.

 

추가로 useCallback까지 살펴보자

https://ko.reactjs.org/docs/hooks-reference.html#usecallback

 

Hooks API Reference – React

A JavaScript library for building user interfaces

ko.reactjs.org

useCallback

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);

메모이제이션된 콜백을 반환합니다.

 

인라인 콜백과 그것의 의존성 값의 배열을 전달하세요. useCallback은 콜백의 메모이제이션된 버전을 반환할 것입니다. 그 메모이제이션된 버전은 콜백의 의존성이 변경되었을 때에만 변경됩니다. 이것은, 불필요한 렌더링을 방지하기 위해 (예로 shouldComponentUpdate를 사용하여) 참조의 동일성에 의존적인 최적화된 자식 컴포넌트에 콜백을 전달할 때 유용합니다.

 

useCallback(fn, deps)은 useMemo(() => fn, deps)와 같습니다.

주의

의존성 값의 배열이 콜백에 인자로 전달되지는 않습니다. 그렇지만 개념적으로는, 이 기법은 콜백 함수가 무엇일지를 표현하는 방법입니다. 콜백 안에서 참조되는 모든 값은 의존성 값의 배열에 나타나야 합니다. 나중에는 충분히 발전된 컴파일러가 이 배열을 자동적으로 생성할 수 있을 것입니다.

eslint-plugin-react-hooks 패키지의 일부로써 exhaustive-deps 규칙을 사용하기를 권장합니다. 그것은 의존성이 바르지 않게 정의되었다면 그에 대해 경고하고 수정하도록 알려줍니다.

 

 

이제, App.js 파일에서 onChangeHandleruseCallback으로 감싸보자.

🤔 왜 useCallback으로 감싸야 할까?

  const onChangeHandler = useCallback((e) => {
    if (e.target.id === "color") setColor(e.target.value);
    else setMovie(e.target.value);
  }, []);

setColor, setMovie 가 실행이 되면 App 컴포넌트가 리렌더링 된다.

App 컴포넌트가 리렌더링 될 때마다 onChangeHandler 함수가 선언이 되고, 실행이 된다.

state가 변경 될 때마다 onChangeHandler 함수를 새로 만들 필요가 없다.

 

✔️ onChangeHandler 함수를 useCallback 으로 감싸게 되면, 해당 컴포넌트가 리렌더링 되어도 함수가 새로 생성되지 않는다.

 

✨ useCallback을 사용하지 않고도 함수의 재생성을 막을 수 있는 또 다른 방법이 있다.

Info 컴포넌트를 살펴보면, 함수들이 함수형 컴포넌트 안에 있지 않고 함수형 컴포넌트 밖에 있는 것을 확인할 수 있다.

함수 이외에도, state값으로 관리하지 않아도 되는 값들은 함수형 컴포넌트 밖으로 빼내면 재생성을 막을 수 있다.

⚠️ state 값에 접근해야하는 함수는 함수형 컴포넌트 안에 위치해야 한다.

 

React.memo

useMemo, useCallback컴포넌트 내에서 값과 함수를 메모이제이션하는 방법이었지만,

React.memo컴포넌트 자체를 메모이제이션 한다.

 

 

🤷‍♀️ 컴포넌트는 언제 렌더링 될까요?

자신의 state가 변경되거나,

부모에게서 받은 props가 변경 되었을 때마다 다시 렌더링 된다.

 

심지어 자식 컴포넌트에서 렌더링 최적화를 위한 별도의 코드를 추가하지 않으면,

부모에게서 받는 props가 변경되지 않았더라도 다시 리렌더링 된다.

 

➡️ 심지어 자식 컴포넌트에서 렌더링 최적화를 위한 별도의 코드를 추가하는 것이 React.memo를 사용하는 것!

 

 

공식 문서 

https://ko.reactjs.org/docs/react-api.html#reactmemo

 

React 최상위 API – React

A JavaScript library for building user interfaces

ko.reactjs.org

React.memo

const MyComponent = React.memo(function MyComponent(props) {
  /* props를 사용하여 렌더링 */
});

React.memo는 고차 컴포넌트(Higher Order Component)입니다.

 

컴포넌트가 동일한 props로 동일한 결과를 렌더링해낸다면, React.memo를 호출하고 결과를 메모이징(Memoizing)하도록 래핑하여 경우에 따라 성능 향상을 누릴 수 있습니다. 즉, React는 컴포넌트를 렌더링하지 않고 마지막으로 렌더링된 결과를 재사용합니다.

 

React.memo는 props 변화에만 영향을 줍니다. React.memo로 감싸진 함수 컴포넌트 구현에 useState, useReducer 또는 useContext 훅을 사용한다면, 여전히 state나 context가 변할 때 다시 렌더링됩니다.

 

props가 갖는 복잡한 객체에 대하여 얕은 비교만을 수행하는 것이 기본 동작입니다. 다른 비교 동작을 원한다면, 두 번째 인자로 별도의 비교 함수를 제공하면 됩니다.

function MyComponent(props) {
  /* props를 사용하여 렌더링 */
}
function areEqual(prevProps, nextProps) {
  /*
  nextProps가 prevProps와 동일한 값을 가지면 true를 반환하고, 그렇지 않다면 false를 반환
  */
}
export default React.memo(MyComponent, areEqual);

이 메서드는 오직 성능 최적화를 위하여 사용됩니다. 렌더링을 “방지”하기 위하여 사용하지 마세요. 버그를 만들 수 있습니다.

주의

class 컴포넌트의 shouldComponentUpdate() 메서드와 달리, areEqual 함수는 props들이 서로 같으면 true를 반환하고, props들이 서로 다르면 false를 반환합니다. 이것은 shouldComponentUpdate와 정반대의 동작입니다.

 

 

예시 코드

https://codesandbox.io/s/angry-bash-ovd8pk?file=/src/App.js 

 

angry-bash-ovd8pk - CodeSandbox

angry-bash-ovd8pk by leehwarang using react, react-dom, react-scripts

codesandbox.io

import "./styles.css";
import React, { useState } from "react";

export default function Parent() {
  const [number, setNumber] = useState(1);
  const age = 1;

  return (
    <div className="App">
      <button
        onClick={() => {
          setNumber(number + 1);
        }}
      >
        Click
      </button>
      <Child age={age} />
    </div>
  );
}

const Child = React.memo((props) => {
  console.log("Child component");
  console.log(props.age);
  return <div>Child</div>;
});

위의 코드에서 Child 컴포넌트의 props가 변하지 않으면 Child 내부가 변하지 않으니 재렌더링 될 필요가 없다.

➡️ Child 컴포넌트를 React.memo로 감쌀 필요가 있다.

 

 

Code Splitting

SPA(Single Page Application)을 최적화 하기 위한 방법 중 하나.

Dynamic import, React.lazy() 를 활용한다.

 

React 프로젝트를 실행 후 소스 > static/js > bundle.js 을 살펴볼 수 있다. 이 파일에는 Webpack이 만들어주는 모든 코드가 담겨있다.

React 프로젝트가 실행될 때 최초로 bundle.js만 받아오고 있는데, 파일 하나가 크면 첫 로드할 때 시간이 오래 걸릴 수 있다.

➡️ 번들 파일을 여러 개로 분리한다면 원하는 부분은 빠르게 로딩할 수 있다.

 

공식 문서

https://ko.reactjs.org/docs/code-splitting.html

 

코드 분할 – React

A JavaScript library for building user interfaces

ko.reactjs.org

번들링

대부분 React 앱들은 Webpack, Rollup 또는 Browserify 같은 툴을 사용하여 여러 파일을 하나로 병합한 “번들 된” 파일을 웹 페이지에 포함하여 한 번에 전체 앱을 로드 할 수 있습니다.

 

예시

App

// app.js
import { add } from './math.js';

console.log(add(16, 26)); // 42
// math.js
export function add(a, b) {
  return a + b;
}

 

Bundle

function add(a, b) {
  return a + b;
}

console.log(add(16, 26)); // 42

주의

실제 번들은 위 예시와는 많이 다르게 보일 겁니다.

Create React App이나 Next.js, Gatsby 혹은 비슷한 툴을 사용한다면 여러분이 설치한 앱에서 Webpack을 같이 설치했을 겁니다.

 

이런 툴을 사용하지 않는다면 여러분이 스스로 번들링을 설정해야 합니다. 이 경우 Webpack의 설치하기 문서와 시작하기 문서를 참조해 주세요.

 

 

코드 분할

번들링은 훌륭하지만 여러분의 앱이 커지면 번들도 커집니다. 특히 큰 규모의 서드 파티 라이브러리를 추가할 때 실수로 앱이 커져서 로드 시간이 길어지는 것을 방지하기 위해 코드를 주의 깊게 살펴야 합니다.

 

번들이 거대해지는 것을 방지하기 위한 좋은 해결방법은 번들을 “나누는” 것입니다. 코드 분할은 런타임에 여러 번들을 동적으로 만들고 불러오는 것으로 Webpack, Rollup과 Browserify (factor-bundle) 같은 번들러가 지원하는 기능입니다.

 

코드 분할은 여러분의 앱을 “지연 로딩” 하게 도와주고 앱 사용자에게 획기적인 성능 향상을 하게 합니다. 앱의 코드 양을 줄이지 않고도 사용자가 필요하지 않은 코드를 불러오지 않게 하며 앱의 초기화 로딩에 필요한 비용을 줄여줍니다.

 

 

import()

앱에 코드 분할을 도입하는 가장 좋은 방법은 동적 import() 문법을 사용하는 방법입니다.

 

Before

import { add } from './math';

console.log(add(16, 26));

After

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

Webpack이 이 구문을 만나게 되면 앱의 코드를 분할합니다. Create React App을 사용하고 있다면 이미 Webpack이 구성이 되어 있기 때문에 즉시 사용할 수 있습니다. Next.js 역시 지원합니다.

 

코드 분할 가이드를 참조하세요. Webpack 설정은 가이드에 있습니다.

 

Babel을 사용할 때는 Babel이 동적 import를 인식할 수 있지만 변환하지는 않도록 합니다. 이를 위해 @babel/plugin-syntax-dynamic-import를 사용하세요.

 

import를 분할하면 static/js 폴더에 다음과 같이 나타난다.

 

 

하나의 bundle.js 파일만 만들어지지 않고 math와 관련된 bundle 파일이 새로 만들어진 것을 확인할 수 있다.

 

이제 리액트 컴포넌트 자체를 dynamic import 하는 방법을 살펴보자.

 

React.lazy

React.lazy 함수를 사용하면 동적 import를 사용해서 컴포넌트를 렌더링 할 수 있습니다.

 

Before

import OtherComponent from './OtherComponent';

After

const OtherComponent = React.lazy(() => import('./OtherComponent'));

MyComponent가 처음 렌더링 될 때 OtherComponent를 포함한 번들을 자동으로 불러옵니다.

 

React.lazy는 동적 import()를 호출하는 함수를 인자로 가집니다.

이 함수는 React 컴포넌트를 default export로 가진 모듈 객체가 이행되는 Promise를 반환해야 합니다.

 

lazy 컴포넌트는 Suspense 컴포넌트 하위에서 렌더링되어야 하며, Suspense는 lazy 컴포넌트가 로드되길 기다리는 동안 로딩 화면과 같은 예비 컨텐츠를 보여줄 수 있게 해줍니다.

import React, { Suspense } from 'react';

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

fallback prop은 컴포넌트가 로드될 때까지 기다리는 동안 렌더링하려는 React 엘리먼트를 받아들입니다. 

Suspense 컴포넌트는 lazy 컴포넌트를 감쌉니다.

하나의 Suspense 컴포넌트로 여러 lazy 컴포넌트를 감쌀 수도 있습니다.

import React, { Suspense } from 'react';

const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <section>
          <OtherComponent />
          <AnotherComponent />
        </section>
      </Suspense>
    </div>
  );
}

 

Avoiding fallbacks

Any component may suspend as a result of rendering, even components that were already shown to the user. In order for screen content to always be consistent, if an already shown component suspends, React has to hide its tree up to the closest <Suspense> boundary. However, from the user’s perspective, this can be disorienting.

Consider this tab switcher:

import React, { Suspense } from 'react';
import Tabs from './Tabs';
import Glimmer from './Glimmer';

const Comments = React.lazy(() => import('./Comments'));
const Photos = React.lazy(() => import('./Photos'));

function MyComponent() {
  const [tab, setTab] = React.useState('photos');
  
  function handleTabSelect(tab) {
    setTab(tab);
  };

  return (
    <div>
      <Tabs onTabSelect={handleTabSelect} />
      <Suspense fallback={<Glimmer />}>
        {tab === 'photos' ? <Photos /> : <Comments />}
      </Suspense>
    </div>
  );
}

In this example, if tab gets changed from 'photos' to 'comments', but Comments suspends, the user will see a glimmer. This makes sense because the user no longer wants to see Photos, the Comments component is not ready to render anything, and React needs to keep the user experience consistent, so it has no choice but to show the Glimmer above.

However, sometimes this user experience is not desirable. In particular, it is sometimes better to show the “old” UI while the new UI is being prepared. You can use the new startTransition API to make React do this:

function handleTabSelect(tab) {
  startTransition(() => {
    setTab(tab);
  });
}

Here, you tell React that setting tab to 'comments' is not an urgent update, but is a transition that may take some time. React will then keep the old UI in place and interactive, and will switch to showing <Comments /> when it is ready. See Transitions for more info.

 

Error boundaries

네트워크 장애 같은 이유로 다른 모듈을 로드에 실패할 경우 에러를 발생시킬 수 있습니다. 이때 Error Boundaries를 이용하여 사용자의 경험과 복구 관리를 처리할 수 있습니다. Error Boundary를 만들고 lazy 컴포넌트를 감싸면 네트워크 장애가 발생했을 때 에러를 표시할 수 있습니다.

import React, { Suspense } from 'react';
import MyErrorBoundary from './MyErrorBoundary';

const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));

const MyComponent = () => (
  <div>
    <MyErrorBoundary>
      <Suspense fallback={<div>Loading...</div>}>
        <section>
          <OtherComponent />
          <AnotherComponent />
        </section>
      </Suspense>
    </MyErrorBoundary>
  </div>
);

 

어떤 기준으로 코드를 나누는 게 좋을까?

➡️ Route 기준으로!

 

Route-based code splitting

앱에 코드 분할을 어느 곳에 도입할지 결정하는 것은 조금 까다롭습니다. 여러분은 사용자의 경험을 해치지 않으면서 번들을 균등하게 분배할 곳을 찾고자 합니다.

 

이를 시작하기 좋은 장소는 라우트입니다. 웹 페이지를 불러오는 시간은 페이지 전환에 어느 정도 발생하며 대부분 페이지를 한번에 렌더링하기 때문에 사용자가 페이지를 렌더링하는 동안 다른 요소와 상호작용하지 않습니다.

React.lazy를 React Router 라이브러리를 사용해서 애플리케이션에 라우트 기반 코드 분할을 설정하는 예시입니다.

import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  </Router>
);

 

Route 기준으로 코드 스플릿을 하면, 

사용자가 들어간 페이지의 코드만 불러오기 때문에, 모든 코드가 한 파일에 있는 것보다 속도가 개선될 수 있다.