본문 바로가기
JS/React 강의

[React] 18. React.memo()

by GiraffePark 2023. 12. 26.

안녕하세요. 박기린입니다.

이번엔 React.memo()에 대해 알아보겠습니다.

 

 

 

 


사전 정의

React.memo()의 구체적인 설명을 위해, 어떠한 앱이 있다고 가정을 하겠습니다.

이 앱은 아래 4개의 파일을 가지고 있습니다.

 

프로그램의 구조
App.js 
- Demo.js - MyParagraph.js
- Button.js

 

 

// App.js
import React, { useState } from 'react';

import Button from './components/UI/Button/Button';
import DemoOutput from './components/Demo/DemoOutput';
import './App.css';

function App() {
  const [showParagraph, setShowParagraph] = useState(false);

  console.log('APP RUNNING');

  const toggleParagraphHandler = () => {
    setShowParagraph((prevShowParagraph) => !prevShowParagraph);
  };

  return (
    <div className="app">
      <h1>Hi there!</h1>
      <DemoOutput show={showParagraph} />
      <Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>
    </div>
  );
}

export default App;

 

 

//Demo.js
import React from 'react';
import MyParagraph from './MyParagraph';

const DemoOutput = (props) => {
  console.log('DemoOutput RUNNING');
  return <MyParagraph>{props.show ? 'This is new!' : ''}</MyParagraph>;
};

export default DemoOutput;

 

 

// MyParagraph.js
import React from 'react';

const MyParagraph = (props) => {
  console.log('MyParagraph RUNNING');
  return <p>{props.children}</p>;
};

export default MyParagraph;

 

 

// Button.js
import React from 'react';

import classes from './Button.module.css';

const Button = (props) => {
  console.log('Button RUNNING');
  return (
    <button
      type={props.type || 'button'}
      className={`${classes.button} ${props.className}`}
      onClick={props.onClick}
      disabled={props.disabled}
    >
      {props.children}
    </button>
  );
};

export default Button;

 

 


프로그램의 작동 구조

코드를 출력하면 위와 같은 모습이 됩니다.

여기서 버튼 <Toggle Paragraph!>를 클릭하면,

 

 

 

<This is new!> 라는 문구가 생깁니다.

그리고 버튼을 한 번 더 누르면 문구가 사라집니다.

 

 

 

  console.log('MyParagraph RUNNING');
  console.log('DemoOutput RUNNING');

그리고 Demo.js와 MyParagraph.js에는 위의 console.log()를 각각 가지고 있습니다.

 

 

 

그래서 <Toggle Paragraph!> 버튼을 누를 때마다, App.js의 showParagraph의 state가 변경되고, 이를 전달받는 하위 컴포넌트 파일인 Demo.js와 MyParagraph.js 파일도 재평가됩니다.

그래서 버튼을 누를 때마다 로그가 찍힙니다.

 

 


문제 발생

그런데 여기서 한 가지 문제가 있습니다.

 

//App.js 중
      <DemoOutput show={showParagraph} />

App.js 중 DemoOutput을 호출하는 코드에 showParagraph prop을 넘기는 부분이 있는데,

 

 

//App.js 중
      <DemoOutput show={false} />

이 부분을 그냥 false로 바꿔주고 싶어서 바꿨다고 칩니다. 그러면 showParagraph prop을 전달하지 않기 때문에, App.js의 state가 변경돼도 DemoOutput 컴포넌트가 재실행/재평가 될 필요는 없게 됩니다.

 

 

 

 

그런데 실제 작동은 그렇지 않습니다. 화면에 <This is new!>라는 문구가 더 이상 뜨지 않게 됐더라도, 콘솔창에는 여전히 <My Paragraph RUNNING> 문구를 띄우고 있습니다. 즉, 여전히 자녀 컴포넌트가 재실행/재평가되고 있습니다. props를 전달하지 않았음에도 말입니다.

 

 

그 이유는?

App.js 컴포넌트가 state 변경에 따라 재실행된다면, 모든 자식 컴포넌트도 App.js를 따라 재실행됩니다. 다만, ReactDOM은 이전에 설명했던 것처럼, 컴포넌트의 재실행/재평가 여부와 달리 가상 DOM의 변경 여부만 비교하기 때문에 아무런 변화가 없어보입니다.

 

항상 App 컴포넌트가 변경될 때마다 자식 컴포넌트가 전부 재실행된다면, 상당한 자원 낭비로 보입니다. 특히, 규모가 큰 앱일수록 자원 누수가 치명적일 수 있습니다. 이를 막기위한 방법으로 React.memo()가 있습니다.

 

 

 

 


문제 해결사 : React.memo()

//Demo.js
import React from 'react';
import MyParagraph from './MyParagraph';

const DemoOutput = (props) => {
  console.log('DemoOutput RUNNING');
  return <MyParagraph>{props.show ? 'This is new!' : ''}</MyParagraph>;
};

export default React.memo(DemoOutput);

뜻하지 않게 재실행/재평가되는 DemoOutput.js에 React.memo()를 적용시킨 코드입니다.

 

 

export default React.memo(DemoOutput);

기존 코드와의 차이점은, export 부분에 React.memo()로 컴포넌트 함수를 감싸준 것입니다.

 

이러면 React.memo() 안에 들어 있는 함수형 컴포넌트는 특수한 방식으로 작동합니다. 이 컴포넌트 안의 props가 변경되었을 때만 재실행/재평가가 일어나게 됩니다. 만약 props에 아무런 변경사항이 없다면, 비록 부모 컴포넌트가 재실행/재평가 됐더라도 가만히 있습니다.

 

 

 

주의사항

React.memo()는 함수형 컴포넌트일 때만 작동합니다. Class 기반 컴포넌트에서는 작동하지 않습니다!

 

 

 


React.memo()의 작동방식

1. React.memo()는 전달받은 컴포넌트에 어떤 props가 있는지를 확인합니다.

2. 이후, 입력되는 모든 props의 신규/갱신값을 확인하고, 이를 기존 props 값과 비교합니다.

3. 만약 props 값이 변경된 경우, 컴포넌트를 재실행/재평가 합니다.

 

-> 부모 컴포넌트(ex: App.js)가 변경됐더라도, 자식 컴포넌트(ex: DemoOutput.js)에 전달할 props는 변경되지 않았다면, 자식 컴포넌트는 재실행/재평가를 건너뜁니다.

 

 

 

 

이제 <Toggle Paragraph!> 버튼을 눌러도, 콘솔이 찍히지 않습니다.

 

 

 

//App.js 중
      <DemoOutput show={false} />

DemoOutput.js는 false만 전달받아 버튼을 누르든 말든 show prop이 변하지 않는 상태이며, DemoOutput.js는 React.memo()가 적용됐기 때문입니다.

 

 

 


왜 React.memo()가 기본으로 설정되어 있지 않나요?

React.memo() 기능은 엄청 유용해 보입니다. 그렇다면 default 설정으로 해줬으면 좋겠는데, 왜 React 개발자들은 굳이 따로 빼놨을까요?

그 이유는 '최적화 비용이 발생하기 때문'입니다.

 

React.memo()는 state가 변경될 때마다, 전달받은 컴포넌트로 이동해서 기존 props 값들과 새로운 값들을 비교합니다. 그러면 react는 두 가지 작업을 필요로 합니다.

1. 기존의 props 값을 저장할 공간이 필요하다.
2. 이 둘을 비교할 작업 공간이 필요하다.

 

이 두 작업은 개별적인 성능 비용을 요구합니다. 그리고 효율성 또한 경우에 따라 달라집니다.

그래서 오히려 React.memo()를 쓰지 않고 매번 컴포넌트가 재실행/재평가되도록 냅두는 게 더 효율적일 때가 있습니다.

 

props의 갯수, 컴포넌트의 복잡도, 자식 컴포넌트의 갯수에 따라 달라지므로 어느 방법이 더 좋다고 하기 힘듭니다.

컴포넌트 트리가 매우 넓거나, 컴포넌트 트리의 상위에 위치한 컴포넌트라면 React.memo()를 사용해서 쓸데없는 재평가/재실행을 막는 게 좋습니다. 

반면에, 자식 컴포넌트에 전달받는 props의 state가 자주 변경되는 경우엔 React.memo()는 효율적이지 못합니다. 왜냐하면 실제로 재렌더링이 필요한 경우가 많은데, 이때마다 매번 props를 비교하는 비용이 추가로 발생하기 때문입니다.

 

 

 

 


또 하나의 결점 : Object type에 무쓸모인 React.memo()

// Button.js
import React from 'react';

import classes from './Button.module.css';

const Button = (props) => {
  console.log('Button RUNNING');
  return (
    <button
      type={props.type || 'button'}
      className={`${classes.button} ${props.className}`}
      onClick={props.onClick}
      disabled={props.disabled}
    >
      {props.children}
    </button>
  );
};

export default Button;

이제, <Toggle Paragraph!> 버튼을 출력해주는 Button.js에 주목해야 합니다.

 

 

export default React.memo(Button);

일반적으로 버튼은 컴포넌트의 재렌더링이 일어날 필요가 없습니다. 항상 그 모습 그대로를 유지하면 되기에 React.memo()에 적합해 보입니다. 그러므로 React.memo()를 감싸줍니다.

 

 

 

 

그 후 <Toggle Paragraph!>를 눌렀더니, 콘솔에는 여전히 'Button RUNNING' 콘솔이 찍힙니다.

React.memo()로 감쌌는데도 Button.js 컴포넌트가 재실행/재평가 됐습니다.

 

 

 

왜 그럴까?

이 문제는 리액트에서 흔하게 발생하는 오류입니다. 그리고 그 이유는 JavaScript에서 찾을 수 있습니다.

 

 

//App.js 중
      <DemoOutput show={false} />

아까 React.memo()를 적용했떤 컴포넌트인 DemoOutput.js를 살펴봅니다. 여기서 전달받는 props는 boolean 타입으로, 자바스크립트의 원시 타입입니다.

 

 

 

React.memo()의 작동 매커니즘은 이러합니다.

props.show === props.previous.show

이전의 props와 변경된 props를 strict(===)로 비교합니다. ===의 결과값이 true이면 재실행/재평가를 막고, false이면 재실행/재평가가 작동합니다.

 

 

원시타입이면 비교 연산자가 정상적으로 작동합니다.

 

 

 

그런데 object 타입(배열, 객체, 함수)은 그렇지 않습니다. 아무리 내부가 똑같다고 해도, 주소값을 비교하기 때문에 true를 반환하지 않습니다.

이건 바닐라 자바스크립트의 핵심적인 개념이기도 합니다. 리액트는 자바스크립트를 근간으로 하기 때문에, 위와 같은 개념이 동일하게 적용됩니다. 결국 React.memo()도 이 개념에 종속됩니다.

 

 

 

  <Button onClick={toggleParagraphHandler}>Toggle Paragraph!</Button>

Button.js 컴포넌트는 App.js로부터 <toggleParagraphHandler>라는 핸들러 함수를 onClick prop으로 전달받습니다.

 

 

 

props.onClick === props.previous.onClick

Button.js 안에 있는 React.memo()는 onClick 함수를 위처럼 비교합니다.

함수일 경우 '===' 비교 연산자를 사용하면 무조건 false를 반환합니다.

따라서 React.memo()는 false를 받고, Button.js를 재실행/재평가합니다.

 

 

 


또 다른 해결사 : useCallback()

그렇다면, 함수 타입의 props는 어떻게 대응해야 할까요?

바로 useCallback()을 사용합니다.

 

 

useCallback() 설명글 : https://arnopark.tistory.com/845

 

[React] 19. useCallback()으로 함수 재생성을 방지하기

안녕하세요. 박기린입니다. useCallback()에 대해 깊이 있게 이해하기 위해, React.memo()를 먼저 알아보는 것을 추천드립니다. (React.memo() 강의 : https://arnopark.tistory.com/840) useCallback() React.memo() 설명글에

arnopark.tistory.com

 

반응형