React.memo 적용기와 Profiler를 사용한 성능 변화를 확인해 보자

hoi·2020년 6월 19일
5
post-thumbnail

Photo by Bill Oxford on Unsplash

Functional Component의 React.memo에 사용기에 대한 공유를 하고자 이 글을 작성합니다.

React와 렌더링(rendering)


경험을 공유하기에 앞서 LifeCycle의 shouldComponentUpdate에 대한 약간의 이해를 하고 넘어가 보도록 하겠습니다.
위의 사진은 React의 LifeCycle에 대한 다이어그램입니다.
React LifeCycle 다이어그램은 React에서의 렌더링 과정을 설명해 주기도 하며 LifeCycle의 메서드를 활용해서 Side Effect 처리와 Render의 여부를 결정짓는 등 여러 도움을 주는 메서드들이 있습니다.
하지만 아쉽게도 Functional Component 에서는 다이어그램의 메서드들을 사용할 수 없는데요 😥
이는 Functional Component 컴포넌트에서는 해당하는 메서드들을 아예 사용할 수 없다는 말을 뜻하는 것은 아닙니다 !
React는 여러 hooks와 React.memo와 같은 고차 컴포넌트(Higher Order Component)를 사용해서 메서드의 기능을 구현할 수 있습니다. 그중에서도 React.memo의 사용 경험기를 공유해 보고자 합니다.

참고 링크
React.memo : https://ko.reactjs.org/docs/react-api.html#reactpurecomponent

React.memo가 무엇인가? 🙄

일단 React.memo에 대해서 알아보기 전에 React LifeCycle에 shouldComponentUpdate에 대해서 먼저 알아보고 넘어가 보도록 하겠습니다.

shouldComponentUpdate

shouldComponentUpdate는 Componet가 전달받은 props 또는 state가 변경을 판단하여 re-rendering을 결정하는 메소드 입니다. 간단한 예시를 들어보도록 하겠습니다.

  • Code
shouldComponentUpdate(nextProps, nextState) {
return this.props.count !== next.Props.count
}

shouldComponentUpdate의 초기값은 true이며 true를 return하게 되면 rendering이 발생하고 false일 경우에는 발생하지 않습니다. 위 예제는 전달된 props의 count값이 전과 같다면 해당 컴포넌트를 rendering 하지 않는다는 것을 의미합니다. React의 공식 홈페이지에서는 해당 메서드는 오직 성능 최적화만을 위한 것이며 비교되는 Props의 복잡도가 높지 않다면 shouldComponentUpdate의 내용을 직접 작성하는 대신에 PureComponent를 사용할 것을 권하고 있습니다.
PureComponent는 shouldComponentUpdate가 내장된 Class Component라고 보시면 될 것 같습니다. 하지만 PureComponent은 컴포넌트에 대하여 얕은 비교만을 수행기 때문에 복잡한 Props의 구조까지는 파악해 주지 못합니다.

참고링크
React.PureComponent : https://ko.reactjs.org/docs/react-api.html#reactpurecomponent

React.memo

위에서 말한것처럼 Functional Component는 LifeCycle을 사용할 수 없지만 React.memo라는 고차 컴포넌트(Higher Order Component)가 위의 shouldComponentUpdate와 PureComponent의 역할을 대신합니다.
가장 기본적인 동작은 React.memo의 두번째 인자에 비교하는 함수식을 전달하지 않을 땐 React.PureComponent와 동작이 같습니다. Props로 전달된 객체에 대해서 얕은 비교만을 수행하며 복잡한 구조를 가지는 Props에 대해서는 내가 원하는대로 작동하지 않을 가능성이 있습니다.

const ProjectComponent = React.memo((props) => {
/* Project Component */
})

export default ProjectComponent

위 코드의 예시는 React.PureComponent와 비슷했다면 React.memo를 사용해서 shouldComponentUpdate의 기능을 구현하고자 할 때는 두번째 인자에 비교 함수를 제공할 수 있습니다.

const ProjectComponent = (props) => {
const { count } = props
}

const judgeEqual = (prevProps , nextProps) => {
return prevProps.count === nextProps.count
}

export default React.memo(ProjectComponent , judgeEqual)

여기서 shouldComponentUpdate랑 다른점은 전달된 props가 같으면 true를 반환하며 다를때는 false를 반환 한다는 차이점이 있습니다. shouldComponentUpdate의 작동 방식과는 반대 입니다.

React.memo를 사용하는 경우

해당 내용은 https://dmitripavlutin.com/use-react-memo-wisely/ 를 참고해서 작성했습니다.

  1. 동일한 Props로 자주 Component Rendering이 일어나는 경우

Component에 React.memo를 사용하는 가장 좋은 경우는 Component가 자주 그리고 동일한 Props를 받아서 Rendering이 일어날 것이라고 판단될 때 입니다. 간단한 코드를 통해서 예시를 들어보도록 하겠습니다.

  • 코드
const Product = () => {
  const [count, setCount] = useState(10);

  const onClickProduct = () => {
    setCount(count - 1);
  };

  return (
    <div>
      <button onClick={onClickProduct}>Click Product</button>
      <ProductItem title="meat" description="fresh meat" count={count} />
    </div>
  );
};

const ProductItem = ({ title, description, count }) => {
  return (
    <div>
      <ProductInfo title={title} description={description} />
      <span>{count}</span>
    </div>
  );
};

const ProductInfo = ({ title, description }) => {
  console.log("re-rendering ProductInfo Component");
  return (
    <div>
      <div>{title}</div>
      <div>{description}</div>
    </div>
  );
};
  • Component 간의 흐름
  1. Product Component는 상품 정보를 보여주는 최상단 Component이며 props로 title , description , count의 정보를 전달합니다. 만약에 사용자가 button을 클릭하면 해당 상품의 count가 감소하게 됩니다.

  2. ProductItem Component는 props로 전달받은 count를 rendering하고 ProductInfo Component에 title, description을 전달합니다.

  3. ProductInfo Component는 title, description 를 전달 받아서 해당 props를 rendering 합니다.\

React에서 Rendering의 여부는 부모 Component로 부터 전달받은 값의 변화에 의해서 결정된다는 점을 생각한다면 button을 클릭해서 count가 변경되면 ProductItem Component는 변경된 count값에 의해서 rendering이 발생될 것입니다.
하지만 ProductItem Component에서 사용되는 ProductInfo는 전달받는 Props의 값에 변화가 없음에도 같이 rendering이 발생하게 됩니다. 이 경우에는 동일한 Props로 자주 rendering이 일어나는 경우라고 할 수 있습니다.


그렇다면 rendering을 최적화하기 위해서 ProductInfo Component에 React.memo를 사용하면 불필요한 Rendering을 제거해 보도록 하겠습니다.

  • 코드
const ProductItem = ({ title, description, count }) => {
  return (
    <div>
      <MemoizedProductInfo title={title} description={description} />
      <span>{count}</span>
    </div>
  );
};

const MemoizedProductInfo = React.memo(({ title, description }) => {
  console.log("re-rendering ProductInfo Component");
  return (
    <div>
      <div>{title}</div>
      <div>{description}</div>
    </div>
  );
});

ProductInfo Component의 이름을 MemoizedProductInfo로 변경하고 React.memo로 감싸 주었습니다.
이제 count가 변경되도 MemoizedProductInfo Component는 re-rendering이 발생하지 않을것입니다. 개발자 도구의 Profiler를 통해서 확인해 보도록 하겠습니다.

위 사진을 보면 의도한 Component만 rendering이 안되는 것을 볼 수 있습니다.

  1. React.memo와 Callback 함수
    자식 컴포넌트에 Props로 callback 함수를 그냥 내려줄 경우에는 매 렌더링마다 새로운 callback이 생성되게 됩니다.
    아무리 같은 기능을 하는 함수라 해도 다시 생성된 함수는 그 전에 함수와 다르다고 판별됩니다.
    그 결과 React.memo에서 isEqual로 비교를 하게되어도 다르다는 판별에 의해서 re-rendering이 발생합니다.
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

const a = sumFactory();
const b = a
const c = a
console.log(b === c) // true;

위 sumFactory는 함수는 함수를 return하는 함수입니다. 또한 JS에서 함수는 함수 객체로 판별됩니다. 각각의 sum1 과 sum2는 같은 기능을 하는 함수라도 새로 생성된 함수 객체이며 참조하는 값이 다르기 때문에 sum1 === sum2 의 경우에는 false가 return 되는 것 입니다.

예시

const MyApp = () => {
  const [count, setCount] = useState(0);

  const onIncrese = () => setCount(count + 1);

  const resetButton = () => setCount(0);

  return (
    <div>
      <button onClick={onIncrese}>Increse</button>
      <div>{count}</div>
      <ResetButton onCount={resetButton} />
    </div>
  );
};

const ResetButton = React.memo(({ onCount }) => {
  console.log(`re-rendering Component`);
  return (
    <div>
      <button onClick={onCount}>Reset</button>
    </div>
  );
});

( 예시가 좋지 않을 수 있지만 최대한 useCallback으로 전달해 주지 않았을 경우를 구현해 봤습니다 ...😅 )
MyApp Component에서 resetButton 함수를 생성하고 ResetButton Component로 전달합니다. 위 코드의 경우에 사용자가 Increse button을 누를 경우에도 ResetButton Component가 다시 rendering되는 현상이 발생합니다. React.memo를 사용했음에도 resetCount로 전달된 callback 함수의 prevProps와 nextProps가 같지 않다고 판단하고 다시 렌더링 하는 현상이 발생했습니다.

다음은 resetButton callback 함수를 useCallback을 통해서 감싸준 후 ResetButton에 전달해 보도록 하겠습니다.

  • 코드
const MyApp = () => {
  const [count, setCount] = useState(0);

  const onIncrese = () => setCount(count + 1);

  const resetButton = useCallback(() => setCount(0), []);

  return (
    <div>
      <button onClick={onIncrese}>Increse</button>
      <div>{count}</div>
      <ResetButton onCount={resetButton} />
    </div>
  );
};

useCallback으로 감싸준 resetButton 함수는 더이상 React.memo에서 전과 다르다고 판별하지 않고 , Profiler에서도 rendering이 발생하지 않는것을 확인할 수 있습니다.

React.memo를 적용해 보자 ! 😼

사실 저는 평소에 React의 최적화 도구에 전혀 관심이 없었지만 ... 미션의 주제로 성능 최적화라는 추가적인 사항이 주어졌고 이번에 미니 프로젝트로 진행 중인 Github Clone 하기에서 의도적으로 적용할 수 있는 부분을 찾아내고 실제로 re-rendering이 어떻게 일어나고 그 부분을 찾아 해결해 보자고 생각했습니다.

re-rendering의 발견

프로젝트에 react-hook-form이라는 form 태그나 input 태그에 ref를 사용하여 폼의 구성을 도와주는 라이브러리의 여러 기능을 사용하던 와중에 자식 컴포넌트에게 react-hook-form의 useForm의 기능을 props로 하나하나 전달해 주지 않아도 부모 컴포넌트를 FormContext로 감싸주고 useFormContext라는 기능을 사용하여 자식 컴포넌트들이 간편하게 사용할 수 있는 기능을 적용하던 도중에 문제가 생겼습니다 ....
(FormContext의 작동 방식은 Context API와 같습니다! )

참고링크
Context API : https://ko.reactjs.org/docs/context.html

  • 간단한 react-hook-form의 사용 예시
import { useForm, FormContext } from "react-hook-form";

const App = () => {
// 전달받을 메서드를 methods 변수에 할당합니다.
  const methods = useForm();
  return (
  // FormContext 선언하여 methods를 전달해 줍니다.
    <FormContext {...methods}>
      <LoginPage />
    </FormContext>
  );
};

import { useFormContext } from "react-hook-form";
// 자식 컴포넌트인 LoginPage Component는 useFormContext를 사용해서 methods를 사용 가능합니다.
// 자식의 깊이가 깊어도 props로 전달받지 않고 간편하게 사용 가능합니다.
const LoginPage = () => {
  const methods = useFormContext();
  return <div></div>;
};
  • 실제 적용 코드
// CreateIssuePage.jsx
import React from "react";
import { useForm, FormContext } from "react-hook-form";

const CreateIssuesPage = () => {
const method = useForm();
const { errors, register, watch, handleSubmit } = method

return ( <FormContext {...method}>
      <IssuesEditor>
          ....
            <TitleInput name="title" type="text" placeholder="Title" ref={register({ required: true, maxLength: 256 })} />
            {errors.title && <ErrorLog>{ISSUES_TITLE_ERROR_MESSAGE}</ErrorLog>}
            <MarkdownEditor />
         ....
    </FormContext>
    )

}

// MarkdownEditor

import React from "react";
import { useFormContext } from "react-hook-form";


const MarkdownEditor = () => {
const { register } = useFormContext();

console.log("re-rendering MarkdownEditorContainer")

return (
<>
...
<textarea name="description" ref={register}>
...
</>
)
}

CreateIssuePage.jsx 에서 FormContext를 선언해주고 자식 컴포넌트들도 모두 useForm을 전달받지 않고 사용할 수 있도록 선언해 줬지만 TitleInput Component의 값을 바꾸면 MarkdownEditorContainer Component도 같이 re-rendering이 발생하는 현상이 있었습니다....😭😭😭

일단 첫번째 Context와 같은 기능을 하는 FormContext를 사용했고 그로 인해서 자식 컴포넌트들은 props로 useForm의 객체를 받게 됐습니다. 그로 인해서 하위 컴포넌트들에 영향을 받는다고 생각했고 이 부분에 React.memo를 사용해서 하위 MarkdownEditor Component rendering을 방지해 보고자 했습니다.

// MarkdownEditor

import React from "react";
import { useFormContext } from "react-hook-form";


const MarkdownEditor = React.memo(
  ({ register }) => {
    return (
      <>
        <textarea name="description" ref={register} />
      </>
    );
  },
  (prevProps, nextProps) => {
    return (
      prevProps.formState.dirtyFields.has("description") === nextProps.formState.dirtyFields.has("description")
    );
  }
);

const MarkdownEditorContainer = () => {
  const methods = useFormContext();
  return <MarkdownEditor {...methods} />;
};

export default MarkdownEditorContainer
  1. MarkdownEditor Component에 React.memo를 사용했고 두번째 인자에 prev와 next의 Props를 확인해서 렌더링 여부를 결정하도록 했습니다.

  2. React.memo는 전달받는 props의 값을 판별하기 때문에 MarkdownEditorContainer Component를 둔 후 useFormContext의 methods를 전달해주는 Component를 추가했습니다.

  3. 자식 Component에 Props로 전달되는 객체에 dirtyFields의 set 구조에 description이 없다면 rendering이 되지 않도록 비교 함수를 추가했습니다.

Profiler 결과

코드를 적용하고 실제로 TitleInput을 입력하고 개발자 도구의 Profiler를 이용해서 확인한 결과 실제로 렌더링이 안되는 것을 확인할 수 있었다

뭘 배웠나 ? 🤓

  1. 일단 첫번째는 실제로 개발자 도구에서 Rendering이 어떤 구조로 몇번 발생하는지 개발자 도구의 Profiler를 사용한 점이 특별한 경험이라고 생각이 됩니다. rendering의 여부는 더이상 React 라이브러리에 의존하는게 아닌 저에게도 rendering의 책임이 생긴 느낌 ...?

  2. 새로운 기술을 배웠으니까 무조건 적용하자 ! 이런것보다 앞으로 최적화를 할 수 있는 부분에 대해서 생각해 볼 수 있겠다는 생각이 듭니다 ... ( 예를 들면 실시간으로 데이터가 변경되는 서비스를 할 때 최적화를 할 수 있지 않을까 ?? 고민의 폭이 넓어졌다는 생각.... )

  3. 공부가 더 필요하겠구나 ... 실제로 위에서 Profiler를 활용해서 렌더링이 되지 않는 건 확인을 했지만 저게 과연 이상적인 최적화 적용인가? 두번째 인자로 전달한 비교 함수는 과연 좋은 방식인가? 의구심은 떨칠 수가 없습니다.... 모든지 처음 배운 지식에는 그런 의심이 들기 마련인데 더 깊게 공부해서 내가 적용하는 최적화에 확신을 가질 수 있어야겠구나.... 숙제입니다 ..... ㅋㅋ

profile
왕초보

0개의 댓글