instruments

React.memo() 현명하게 사용하기


원문: https://dmitripavlutin.com/use-react-memo-wisely

유저들은 반응이 빠른 UI를 선호한다. 100ms 미만의 UI 응답 지연은 유저들이 즉시 느낄 수 있고, 100ms에서 300ms가 지연되면 이미 유저들은 상당한 지연으로 느낀다.

UI 성능을 증가시키기 위해, React는 고차 컴퍼넌트(Higher Order Component, HOC) React.memo()를 제공한다. 렌더링 결과를 메모이징(Memoizing)함으로써, 불필요한 리렌더링을 건너뛴다.

이 글은 언제 React.memo()가 성능을 향상하는데 도움이 되는지, 언제 불필요한지 구분하는 데 도움이 될 것이다.

추가로 당신이 알아두면 좋을 쓸만한 메모이제이션(Memoization) 팁 또한 설명할 것이다.

1. React.memo()

React는 먼저 컴퍼넌트를 렌더링(rendering) 한 뒤, 이전 렌더된 결과와 비교하여 DOM 업데이트를 결정한다. 만약 렌더 결과가 이전과 다르다면, React는 DOM을 업데이트한다.

다음 렌더링 결과와 이전 결과의 비교는 빠르다. 하지만 어떤 상황에서는 이 과정의 속도를 좀 더 높일 수 있다.

컴퍼넌트가 React.memo()로 래핑 될 때, React는 컴퍼넌트를 렌더링하고 결과를 메모이징(Memoizing)한다. 그리고 다음 렌더링이 일어날 때 props가 같다면, React는 메모이징(Memoizing)된 내용을 재사용한다.

예시를 살펴보자. 함수형 컴퍼넌트 MovieReact.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를 반환한다. 한 가지 차이점을 제외하고 원래의 Movie 컴퍼넌트와 같은 결과를 나타낼 것이다.

MemoizedMovie의 렌더링 결과는 메모이징 되어있다. 만약 title이나 releaseData 같은 props가 변경 되지 않는다면 다음 렌더링 때 메모이징 된 내용을 그대로 사용하게 된다.

// 첫 렌더이다. React는 MemoizedMovie 함수를 호출한다.
<MemoizedMovie
  movieTitle="Heat"
  releaseDate="December 15, 1995"
/>

// 다시 렌더링 할 때 React는 MemoizedMovie 함수를 호출하지 않는다.
// 리렌더링을 막는다.
<MemoizedMovie
  movieTitle="Heat"
  releaseDate="December 15, 1995"
/>

메모이징 한 결과를 재사용 함으로써, React에서 리렌더링을 할 때 가상 DOM에서 달라진 부분을 확인하지 않아 성능상의 이점을 누릴 수 있다.

클래스 컴퍼넌트 또한 PureComponent로 동일한 내용이 구현되어 있다.

1.1 props 동등 비교 커스터마이징

React.memo()는 props 혹은 props의 객체를 비교할 때 얕은(shallow) 비교를 한다.

비교 방식을 수정하고 싶다면 React.memo() 두 번째 매개변수로 비교함수를 만들어 넘겨주면 된다.

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

areEqual(prevProps, nextProps) 함수는 prevPropsnextProps가 같다면 true를 반환할 것이다.

예를들어 Movieprops가 동일한지 수동으로 비교해보자.

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

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

moviePropsAreEqual() 함수는 이전 props와 현재 props가 같다면 true를 반환할 것이다.

2. 언제 React.memo()를 써야할까

image2.png

2.1 같은 props로 렌더링이 자주 일어나는 컴퍼넌트

React.memo()는 함수형 컴퍼넌트에 적용되어 같은 props에 같은 렌더링 결과를 제공한다.

React.memo()를 사용하기 가장 좋은 케이스는 함수형 컴퍼넌트가 같은 props로 자주 렌더링 될거라 예상될 때이다.

일반적으로 부모 컴퍼넌트에 의해 하위 컴퍼넌트가 같은 props로 리렌더링 될 때가 있다.

위에서 정의한 Movie를 다시 사용해서 예시를 들어보자. 여기 Movie의 부모 컴퍼넌트인 실시간으로 업데이트되는 영화 조회수를 나타내는 MovieViewsRealtime 컴퍼넌트가 있다.

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

이 어플리케이션은 주기적(매초)으로 서버에서 데이터를 폴링(Polling)해서 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가 새로운 숫자가 업데이트 될 때 마다 MoviewViewsRealtime 컴퍼넌트 또한 리렌더링 된다. 이때 Movie 컴퍼넌트 또한 title이나 releaseData가 같음에도 불구하고 리렌더링 된다.

이때가 Movie 컴퍼넌트에 메모이제이션을 적용할 적절한 케이스다.

MovieViewsRealtime에 메모이징된 컴퍼넌트인 MemoizedMovie를 대신 사용해 성능을 향상해보자.

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

title 혹은 releaseDate props가 같다면, React는 MemoizedMovie를 리렌더링 하지 않을 것이다. 이렇게 MovieViewsRealtime 컴퍼넌트의 성능을 향상할 수 있다.

컴퍼넌트가 같은 props로 자주 렌더링되거나, 무겁고 비용이 큰 연산이 있는 경우, React.memo()로 컴퍼넌트를 래핑할 필요가 있다.

profiling을 통해 React.memo()의 이점을 측정해봐라

추가적으로 적절한 상황이 있다면 이 글의 댓글로 남겨주기 바란다!

3. 언제 React.memo()를 사용하지 말아야 할까

만약 위에서 언급한 상황에 일치하지 않는다면 React.memo()를 사용할 필요가 없을 가능성이 높다.

경험적으로, 성능적인 이점을 얻지 못한다면 메모이제이션을 사용하지 않는것이 좋다.

성능 관련 변경이 잘못 적용 된다면 성능이 오히려 악화될 수 있다. React.memo()를 현명하게 사용하라.

또한, 기술적으로는 가능하지만 클래스 기반의 컴퍼넌트를 React.memo()로 래핑하는것은 적절하지 않다. 클래스 기반의 컴퍼넌트에서 메모이제이션이 필요하다면 PureComponent를 확장하여 사용하거나, shouldComponentUpdate() 메서드를 구현하는 것이 적절하다.

3.1 쓸모없는 props 비교

렌더링될 때 props가 다른 경우가 대부분인 컴포넌트를 생각해보면, 메모이제이션 기법의 이점을 얻기 힘들다.

props가 자주 변하는 컴퍼넌트를 React.memo()로 래핑할지라도, React는 두 가지 작업을 리렌더링 할 때마다 수행할 것이다.

  1. 이전 props와 다음 props의 동등 비교를 위해 비교 함수를 수행한다.
  2. 비교 함수는 거의 항상 false를 반환할 것이기 때문에, React는 이전 렌더링 내용과 다음 렌더링 내용을 비교할 것이다.

비교 함수의 결과는 대부분 false를 반환하기에 props 비교는 불필요하게 된다.

4. React.memo() 와 콜백 함수

함수 객체는 "일반" 객체와 동일한 비교 원칙을 따른다. 함수 객체는 오직 자신에게만 동일하다.

몇가지 함수를 비교해보자.

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()는 팩토리 함수이다. 이 함수는 2가지 숫자를 더해주는 화살표 함수를 반환한다.

함수 sum1sum2는 팩토리에 의해 생성된 함수다. 두 함수 모두 두 숫자를 더해주는 함수이다. 그러나 sum1sum2는 다른 함수 객체이다.

부모 컴퍼넌트가 자식 컴퍼넌트의 콜백 함수를 정의한다면, 새 함수가 암시적으로 생성될 수 있다. 이것이 어떻게 메모이제이션을 막는지 보고, 수정해보자.

Logout 컴퍼넌트는 콜백 proponLogout을 갖는다.

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()}
        />
      </header>
      {store.content}
    </div>
  );
}

동일한 username 값이 전달되더라고, MemoizedLogout은 새로운 onLogout 콜백 때문에 리렌더링을 하게 된다.

메모이제이션이 중단되게 되는 것이다.

이 문제를 해결하려면 onLogout prop의 값을 매번 동일한 콜백 인스턴스로 설정해야만 한다.useCallback()을 이용해서 콜백 인스턴스를 보존시켜보자.

const MemoizedLogout = React.memo(Logout);

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

useCallback(() => { cookies.clear() }, []) 는 항상 같은 함수 인스턴스를 반환한다. MemoizedLogout의 메모이제이션이 정상적으로 동작하도록 수정되었다.

5. React.memo() 은 성능 개선의 도구다

엄밀히 말하면, React에서는 성능 개선을 위한 하나의 도구로 메모이제이션을 사용한다.

대부분의 상황에서 React는 메모이징 된 컴퍼넌트의 리렌더링을 피할 수 있지만, 렌더링을 막기 위해 메모이제이션에 의존하면 안된다.

6. 결론

React.memo()는 함수형 컴퍼넌트에서도 메모이제이션의 장점을 얻게 해 주는 훌륭한 도구다. 올바르게 적용 된다면 변경되지 않은 동일한 prop에 대해 리렌더링을 하는 것을 막을 수 있다.

다만, 콜백 함수를 prop으로 사용하는 컴퍼넌트에서 메모이징을 할 때 주의하라. 그리고 같은 렌더링을 할 때 이전과 동일한 콜백 함수 인스턴스를 넘기는지 확실히 하라.

그리고 메모이제이션의 성능상 이점을 측정하기 위해 profiling을 사용하는 것을 잊지 말아라.

React.memo() 사용 케이스에 대해 더 궁금한가? 그렇다면 이 글에 댓글을 남겨라!