goongoguma's blog

Use React.memo() wisely

사용자들은 빠르고 반응형으로 만들어진 UI를 좋아합니다. 100 밀리초 미만의 UI응답은 순식간이지만 100에서 300 밀리초 사이의 지연은 인지가 가능합니다.

UI 성능을 향상시키기 위해서, 리액트는 고차함수(HOC)인 React.memo()를 제공합니다. React.memo()가 컴포넌트를 감싸게 되면, 리액트는 감싸여진 컴포넌트의 렌더된 결과물을 메모리에 기억하고 있음으로써(memoize) 불필요한 렌더링을 건너뜁니다.

이 글은 언제 React.memo()를 사용해서 성능을 향상 시킬지, 그리고 비교적 덜 중요하지만 불필요한 사용을 경고하기 위해 쓰여졌습니다.

덧붙여서 알고있으면 유용한 메모이제이션(memoization) 팁도 알려드리겠습니다.

1. React.memo()

DOM을 업데이트 하기로 결정하였다면, 첫번째로 리액트는 컴포넌트를 렌더한뒤, 전에 렌더된 결과들과 비교를 합니다. 만약에 렌더된 결과들이 다르다면, 리액트는 DOM을 업데이트 합니다.

현재 vs 전의 렌더 결과물의 비교는 빠릅니다. 하지만 어떤 상황에서는 이러한 비교 속도를 더 향상시킬 수 있습니다.

컴포넌트가 React.memo()로 감싸여 있을때, 리액트는 컴포넌트를 렌더하고 결과를 메모리에 저장합니다(메모이제이션). 다음 렌더가 시작되기 전에, 만약 새로운 props의 값이 같다면, 리액트는 메모리에 저장되어있는 결과물을 재사용하고 다음 렌더링을 건너뜁니다.

실제 작동하는 메모이제이션 예시를 보시겠습니다. 함수 컴포넌트인 Movie는 React.memo()에 감싸여 있습니다:

export function Movie({ title, releaseDate }) {
  return (
    <div>
      <div>Movie title: {title}</div>
      <div>Release date: {releaseDate}</div>
    </div>
  );
}

export const MemoizedMovie = React.memo(Movie);

React.memo(Movie)는 메모이제이션이된 새로운 MemoizedMovie 컴포넌트를 반환합니다.

MemoizedMovie 컴포넌트는 Movie 컴포넌트와 같은 내용을 반환하지만, 한가지 차이점이 존재합니다 - MemoizedMovie의 렌더는 메모리에 저장되어있다는 겁니다(memoized). 리액트는 titlereleaseDate props의 값이 렌더중에 같다면 메모리에 저장되어 있는(memoized) 내용들을 재사용합니다:

// First render - MemoizedMovie IS INVOKED.
<MemoizedMovie 
  title="Heat" 
  releaseDate="December 15, 1995" 
/>

// Second render - MemoizedMovie IS NOT INVOKED.
<MemoizedMovie
  title="Heat" 
  releaseDate="December 15, 1995" 
/>

데모를 보시고 콘솔창을 살펴보세요. 리액트는 MemoizedMovie 컴포넌트를 딱 한번 렌더합니다. Movie 컴포넌트가 매번 다시 렌더되는것과 반대로 말이죠.

여기서 성능 향상 을 얻을 수 있습니다: 메모리에 저장되어있는 컨텐츠를 재사용 함으로써, 리액트는 컴포넌트의 렌더링을 건너뛰고 가상돔에서 차이점을 체크하지 않습니다.

클래스 기반 컴포넌트에서 PureComponent를 사용해 같은 기능을 구현할 수 있습니다.

1.1 Custom equality check of props

기본적으로 React.memo()는 props와 props의 객체들의 얕은 비교를 합니다.

props 비교를 설정하기 위해서 React.memo()의 두번째 인자에 props가 동등한지 체크할 수 있는 함수를 사용할 수 있습니다.

React.memo(Component, [areEqual(prevProps, nextProps)]);

areEqual(prevProps, nextProps)함수는 만약에 prevPropsnextProps가 같다면 반드시 true를 반환합니다.

예를들어, Movie 컴포넌트의 props가 같다고 설정해보겠습니다.

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

const MemoizedMovie2 = React.memo(Movie, moviePropsAreEqual);

moviePropsAreEqual() 함수는 prev와 next props가 같다면 true를 반환합니다.

2. When to use React.memo()

memo-1

2.1 Component renders often with the same props

컴포넌트를 React.memo()로 감싸기에 좋을때는 같은 props를 가진 컴포넌트가 자주 렌더링 될 것으로 예상될때가 좋습니다.

컴포넌트가 동일한 props를 가졌지만 계속 렌더링이 되는 일반적인 상황은 부모 컴포넌트에 의해 강제로 렌더링될때 입니다.

위에서 만든 Movie 컴포넌트를 재사용 해보겠습니다. 새로운 부모 컴포넌트인 MovieViewsRealtime은 영화의 조회수를 실시간 업데이트로 보여줍니다.

function MovieViewsRealtime({ title, releaseDate, views }) {
  return (
    <div>
      <Movie title={title} releaseDate={releaseDate} />
      Movie views: {views}
    </div>
  );
}

어플리케이션은 정기적으로 백그라운드에서 매 초마다 서버를 폴링해서 MovieViewsRealtime 컴포넌트의 views 프로퍼티를 업데이트 합니다.

// Initial render
<MovieViewsRealtime 
  views={0} 
  title="Forrest Gump" 
  releaseDate="June 23, 1994" 
/>

// After 1 second, views is 10
<MovieViewsRealtime 
  views={10} 
  title="Forrest Gump" 
  releaseDate="June 23, 1994" 
/>

// After 2 seconds, views is 25
<MovieViewsRealtime 
  views={25} 
  title="Forrest Gump" 
  releaseDate="June 23, 1994" 
/>

// etc

매번 views prop이 새로운 숫자로 업데이트 될 때마다, MovieViewsRealtime 컴포넌트는 렌더됩니다. 또한 MovieViewsRealtime 컴포넌트의 렌더링은 title과 releaseDate의 값이 바뀌지 않았음에도 불구하고 Movie 컴포넌트의 렌더링을 발생시킵니다.

이런 상황에서 Movie 컴포넌트에 메모이제이션을 적용시킬 수 있습니다.

MovieViewsRealtime컴포넌트안에 메모이제이션을 사용한 컴포넌트인 MemoizedMovie를 사용해서 불필요한 리렌더링을 방지할 수 있습니다.

function MovieViewsRealtime({ title, releaseDate, views }) {
  return (
    <div>
      <MemoizedMovie title={title} releaseDate={releaseDate} />
      Movie views: {views}
    </div>
  )
}

title과 releaseDate props의 값이 바뀌지 않는 이상, 리액트는 MemoizedMovie 컴포넌트의 렌더링을 건너뜁니다. 이렇게 함으로써 MovieViewsRealtime 컴포넌트의 성능을 향상시킬 수 있습니다.

"컴포넌트가 같은 props로 자주 렌더링 될수록, 출력이 무거워 지고 계산 비용이 더 많이 들수록, 컴포넌트를 React.memo()로 감싸야될 가능성이 커집니다."

어쨌든, profiling을 사용해서 React.memo()를 적용시켜 얻는 이점들을 측정할 수 있습니다.

3. When to avoid React.memo()

"컴포넌트가 무겁지 않고 자주 다른 props로 렌더된다면 React.memo()가 그다지 필요하지는 않습니다."

이 규칙을 사용해주세요: 성능의 향상을 측정할 수 없다면 메모이제이션을 사용하지 마세요.

"잘못 적용된 성능향상 관련 변화들은 오히려 성능에 악영향을 끼칠 수 있습니다. React.memo()를 현명하게 사용하세요."

가능하다면, 클래스형 컴포넌트를 React.memo()로 감싸는것은 좋지 않습니다. 만약 클래스 기반의 컴포넌트에서 메모이제이션이 필요하다면 PureComponent 클래스로 확장을 하거나 shouldComponentUpdate()메소드를 설정해서 사용하세요.

3.1 Useless props comparison

주로 다른 props를 받아 렌더하는 컴포넌트가 있다고 생각해보세요. 이러한 경우에는 메모이제이션은 이점을 제공해주지 못합니다.

이러한 컴포넌트에 React.memo()를 사용한다면, 리액트는 매번 렌더될 때마다 두가지의 일을 합니다.

  1. 전과 다음 props의 값을 비교하기 위해 비교 함수를 호출합니다.
  2. props 비교가 거의 항상 false를 반환하므로, 리액트는 전과 현재의 렌더링된 결과들을 비교하는 일을 수행합니다.

성능상 이점을 얻는것은 없고 대부분 무의미한 비교 함수만이 작동될 뿐입니다.

4. React.memo() and callback functions

함수 객체는 오직 그 자체와 값이 같습니다. 아래의 비교 함수 예시를 보시죠.

function sumFactory() {
  return (a, b) => a + b;
}

const sum1 = sumFactory();
const sum2 = sumFactory();

console.log(sum1 === sum2); // => false
console.log(sum1 === sum1); // => true
console.log(sum2 === sum2); // => true

sumFactory()는 팩토리 함수입니다. 이 함수는 두개의 숫자를 더한 값을 반환합니다.

함수 sum1sum2는 팩토리에 의해 생성되었습니다. 두개의 함수는 모두 숫자를 더합니다. 그러나, sum1과 sum2는 서로 다른 함수 객체입니다(sum1 === sum2는 false입니다).

매번 부모 컴포넌트에서 자식 컴포넌트의 콜백 함수를 정의할 때마다, 새로운 함수 인스턴스가 생성이 됩니다. 이것이 어떻게 메모이제이션을 망가뜨리는지 그리고 어떻게하면 고칠 수 있는지 봅시다.

아래 Logout 컴포넌트는 onLogout 콜백 prop을 받습니다:

function Logout({ username, onLogout }) {
  return (
    <div onClick={onLogout}>
      Logout {username}
    </div>
  );
}

const MemoizedLogout = React.memo(Logout);

콜백함수를 받는 컴포넌트는 메모이제이션을 진행할 때 조심스럽게 해야합니다. 부모 컴포넌트는 매번 렌더될때마다 다른 인스턴스의 콜백 함수를 제공합니다:

function MyApp({ store, cookies }) {
  return (
    <div className="main">
      <header>
        <MemoizedLogout
          username={store.username}
          onLogout={() => cookies.clear('session')}
        />
      </header>
      {store.content}
    </div>
  );
}

같은 usename의 값이 제공되었지만, MemoizedLogout 컴포넌트는 매번 렌더됩니다. 왜냐하면 새로운 onLogout 콜백 인스턴스를 받기 때문입니다.

메모이제이션이 고장난겁니다.

이것을 고치기 위해서, onLogout prop은 같은 콜백의 인스턴스를 받아야만 합니다. useCallback()을 적용시켜 렌더링 될 때의 콜백을 유지시켜봅시다.

const MemoizedLogout = React.memo(Logout);

function MyApp({ store, cookies }) {
  const onLogout = useCallback(
    () => cookies.clear('session'), 
    [cookies]
  );
  return (
    <div className="main">
      <header>
        <MemoizedLogout
          username={store.username}
          onLogout={onLogout}
        />
      </header>
      {store.content}
    </div>
  );
}

useCallback(() => cookies.clear('session'), [cookies])은 항상 같은 함수 인스턴스를 반환합니다 cookies의 값이 계속 같다면 말이죠. MemoizedLogout 컴포넌트의 메모이제이션은 이렇게 고칠 수 있습니다.

5. React.memo() is a performance hint

엄밀히 말하자면, 리액트는 메모이제이션을 성능 힌트로써 사용합니다.

대부분의 경우에 리액트는 메모리에 저장된 컴포넌트의 렌더링을 피하지만, 렌더링을 방지하기 위해 이것에 의존해서는 안됩니다.

6. React.memo() and hooks

훅을 사용하는 컴포넌트들은 메모이제이션을 위해 자유롭게 React.memo()에 감싸일 수 있습니다.

리액트는 만약 상태가 바뀌었다면 항상 컴포넌트를 리렌더링 합니다. 비록 컴포넌트가 React.memo()에 의해 감싸졌다고 해도 말이죠.

7. Conclusion

React.memo()는 함수형 컴포넌트를 메모리에 저장하기에 아주 좋은 도구입니다. 알맞게 사용하면, 전의 props와 변화된 props를 비교할때 불필요한 리렌더링을 방지합니다.

콜백을 props로 받는 컴포넌트를 메모리에 저장할때 주의해주세요. 렌더링될때 같은 콜백 함수의 인스턴스가 제공되도록 해주세요,

메모이제이션을 사용하고 성능을 측정하기 위해 profiling의 사용을 잊지마세요.

다음에는 무엇을 읽는게 좋을까요? 콜백 함수를 메모이제이션 하기 위해서 저의 다른 글인 Your Guide to React.useCallback()을 추천합니다.

React.memo()를 어떻게 사용하는지 관심있으신가요? 그렇다면 아래 댓글을 달아주세요!

원문: Use React.memo() wisely by Dmitri Pavlutin