티스토리 뷰

이 글에서는 컴포넌트의 re-evaluation과 re-rendering의 차이에 대해서 알아보고, 최적화 방법의 대표격인 React.memo 와 useCallback을 이용해서 최적화 하는 방법에 대해 말해보려고 한다. 
 
리액트의 작동원리와 re-evaluation, re-rendering, 최적화에 대해서 순차적으로 알아보자. 
 

1️⃣ 리액트의 작동 원리

알다시피 React는 UI 를 구축하기 위한 JS 라이브러리이다. 그런데 사실 React는 web을 알지 못한다. React는 오직 component, state, data, context, props 등에 대해서만 신경쓰고, UI 를 바꾸거나 업데이트 하는 건 ReactDOM이 처리한다. 즉, ReactDOM은 React 와 web 간의 interface이며, DOM 에 접근하는 건 React가 아닌 ReactDOM이다. 
이에 대해서는 이전에 작성한 글([React] 리액트의 작동 원리 (React-DOM 과 Virtual-DOM))을 참고해주세요!

2️⃣ 재평가(re-evaluation)와 리렌더링(re-rendering)

우리가 한가지 짚고 넘어가야 할 것은 re-evaluation !== re-rendering 라는 사실이다.
즉, 함수 컴포넌트가 재실행(re-execute)되고 재평가(re-evaluate)된다고 해서 무조건 리렌더링(re-render)이 일어나는 것이 아니다. 재실행 되는 것은 컴포넌트이고, 리렌더링 되는 것은 DOM 이기 때문이다. 
 
💡React는 state, props, context 등이 변화하면 함수 컴포넌트를 재실행(re-execute) 하여, 위에서부터 차례로 재평가(re-evaluate)한다. 이렇게 재평가한 결과는 ReactDOM에게 전달되고, ReactDOM은 virtual DOM을 이용해서 전후 비교를 한 이후에, 바뀐 부분만 real DOM 에 반영하는 리렌더링을 일으킨다. 즉, 실행과 평가의 주체는 React이고 그 대상은 컴포넌트이며, 리렌더링의 주체는 ReactDOM이고 그 대상은 DOM이다. 서로 연관되어 있기는 하지만, 같은 의미도 아닐 뿐더러 꼭 함께 일어난다고 말할 수 없다. 
 
📌 하지만 많은 사람들이 컴포넌트를 재호출 하고, 재평가하는 것을 'rendering' 라고 표현한다! 그리고 real DOM을 업데이트 하여 re-rendering 하는 것을 repaint 등으로 부를 수 있다. 그래서 이는 문맥과 상황에 맞게 받아들여야 한다! 이 글에서는 DOM 을 바꾸는 행위를 리렌더링이라고 말하겠습니다!
 
 

💭 이를 간단한 예제를 통해서 확인해보자!

//App.js

import React, { useState } from 'react';

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

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

  console.log('APP RUNNING');

  const toggleParagraph = () => {
    setShowParagraph((prev) => !prev);
  };

  return (
    <div className='app'>
      <DemoOutput show={false}/>
      <button onClick={toggleParagraph}>show</button>
    </div>
  );
}

export default App;
//DemoOutput.js

import React from 'react';

const DemoOutput = (props) => {
    console.log('DemoOutput RUNNING');

  return <p>{props.show ? 'This is new!' : ''}</p>;
};

export default DemoOutput;

부모 컴포넌트인 <App /> 에는 showParagraph 라는 state를 만들고, 초기값을 false로 설정해주었으며, showParagraph의 상태를 바꾸어 주는 toggleParagraph함수도 만들었다. 그리고 console.log를 이용해서 App 컴포넌트 함수가 호출될 때마다 'APP RUNNING'을 출력하도록 해주었다. App 컴포넌트는 <DemoOutput /> 자식 컴포넌트와 button을 return 한다. button 에는 toggleParagraph 함수를 이벤트핸들러로 달아주어 button을 클릭할 때마다 showParagraph의 상태를 바꾸어준다. <DemoOutput />은 함수 컴포넌트가 호출될 때마다 'DemoOutput RUNNING'을 출력할 것이다. 그리고 부모로부터 show props를 받아서 show 의 state가 true일 때마다 'This is new' 을 화면에 보여준다. 하지만 지금은 부모에서 show={false}로 항상 false값을 넘겨주고 있으므로 화면에는 아무것도 나타나지 않을 것이다.
 

🔍 여기서 APP 컴포넌트의 button을 클릭하면 어떻게 될까? 

 
button을 클릭할 때마다 showParagraph 의 state가 바뀌게 되고, state가 바뀌면 React는 해당 컴포넌트를 재실행한다. 즉, App 컴포넌트는 다시 호출되어 위에서부터 순차적으로 재평가되며, 이때 'APP RUNNING' 또한 콘솔에 출력한다. 그리고 return 에서 자식 컴포넌트인 <DemoOutout /> 을 다시금 호출하여 <DemoOutput /> 또한 재실행 되고 위에서부터 순차적으로 재평가 되며, 'DemoOutput RUNNING'이 콘솔에 출력될 것이다.  우리는 props로 받은 show 의 state가 변화하지 않았음에도 <DemoOutout /> 은 재실행 및 재평가 되었다는 걸 주목해야 한다.(물론 props가 변화가 없으니 리렌더링은 일어나지 않을 것이다!) 또한 함수 컴포넌트의 재실행 및 재평가가 꼭 리렌더링(UI 변화)과 함께 일어나는 것이 아님을 확인할 수 있다. 
 

3️⃣ 최적화 필요성 

여기서 우리는 최적화의 필요성을 도출할 수 있다. 실제로 변화가 없음에도 React는 쓸데없는 호출과 계산을 하고있다. 물론 리액트가 이런 식의 실행 및 비교작업에 최적화 되어있기는 하지만 프로젝트의 규모가 커질 수록 성능에 영향을 줄 수 있기 때문에, 최적화가 필요하다. 우리는 최적화를 함으로써 특정한 상황일 경우에만 재실행 및 재평가가 이루어지도록 리액트에 지시할 수 있다.
 

4️⃣ React.memo

React.memo는 props가 변화하였을 때만 해당 컴포넌트를 재실행 및 재평가 하도록 컴포넌트를 메모이제이션(memoization) 한다. 즉, 함수 컴포넌트를 캐싱하는 데 사용한다. React.memo는 전달 받은 컴포넌트를 memoized 버전으로 return 하여, props가 변경될 때까지 memoized 된 내용을 그대로 사용한다. 

React.memo()

위의 예시에서 보았던 <DemoOutput /> 컴포넌트를 아래와 같이 React.memo를 사용해서 메모이제이션 할 수 있다. 이 경우 부모 컴포넌트가 재호출 및 재평가 되더라도 부모 컴포넌트로부터 전달 받는 props의 값이 변화하지 않는다면 캐싱처리 된 컴포넌트가 그대로 반환되어, 재실행 및 재평가 되지 않는다. (흔히들 말하는 대로는 리렌더링이 일어나지 않는다.)

//DemoOutput.js
import React, { useEffect } from 'react';

const DemoOutput = (props) => {
    console.log('DemoOutput RUNNING');

  return <p>{props.show ? 'This is new!' : ''}</p>;
};

export default React.memo(DemoOutput);

 
 
🔍 하지만 props가 바뀌지 않(는 것처럼 보이)고, React.memo를 이용해서 memoization 하였는데도 불구하고 함수 컴포넌트가 계속해서 재실행 되고 재평가되는 경우가 있다. 이는 사실 props가 변화했기 때문이다. 

 
💭 다음 예제를 보면서 확인해 보자! 위의 예제에서 button 대신 <Button /> 컴포넌트를 만들어 살짝쿵 변경시킨 코드이다. 

import React, { useState } from 'react';

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

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

  console.log('APP RUNNING');

  const toggleParagraph = () => {
    setShowParagraph((prev) => !prev);
  };

  return (
    <div className='app'>
      <DemoOutput show={false}/>
      <Button onClick={toggleParagraph}>show</Button>
    </div>
  );
}

export default App;
/Button.js
import React from 'react';

const Button = (props) => {
  console.log('Button RUNNING');
  return (
    <button
      type='button'
      onClick={props.onClick}
      >
      {props.children}
    </button>
  );
};

export default React.memo(Button);

<Button /> 컴포넌트는 부모 컴포넌트인 <App />으로부터 props로 onClick 이벤트 핸들러인 toggleParagraph 함수를 전달받고 있다. 그리고 <Button /> 컴포넌트도 React.memo로 메모이제이션을 해두었다. 즉, props 의 값이 변경되지 않으면 재실행 및 재평가가 일어나지 않아야한다. 하지만 이 경우, 버튼을 누를 때마다 'Button RUNNING'이 콘솔에 출력될 것이다. 
 
이는 toggleParagraph 함수가 <App/> 컴포넌트가 재평가될 때마다 매번 재 생성되기 때문이다. 즉, 버튼을 눌렀을 때 이벤트 핸들러인 toggleParagraph가 실행되면, <App/> 컴포넌트의 showParagraph의 상태가 변화하고, 상태가 변화하였기 때문에 <App/>이 재실행 된다. 그런데 함수는 primitive value가 아닌 reference value이기 때문에, 매번 새로운 인스턴스가 생성되고 그때마다 메모리 상의 주소값이 달라지기 때문에 <Button /> 컴포넌트에 전달되는 함수는 매번 다른 함수가 되는 것이다. 이는 결국 React.memo 만으로는 최적화를 할 수 없다는 것이다. 이때 useCallback을 함께 사용해서 최적화 할 수 있다. 
 

5️⃣ useCallback

useCallback은 컴포넌트 실행 전반에 걸쳐 함수를 캐싱할 수 있게 하는 훅으로, React에게 컴포넌트를 실행할 때마다 해당 함수를 재생성 할 필요 없다는 것을 알릴 수 있다. 즉, 함수 객체가 동일한 위치에 저장된다. 
그리고 useCallback은 의존성 배열을 받아, 특정 의존성이 변경될 때마다 재생성 하도록 만들 수 있다. 

useCallback(()=>{},[])
//App.js

import React, { useState, useCallback } from 'react';

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

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

  console.log('APP RUNNING');

  const toggleParagraph = useCallback(() => {
    setShowParagraph((prev) => !prev);
  }, []);

  return (
    <div className='app'>
      <DemoOutput show={false} />
      <Button onClick={toggleParagraph}>show</Button>
    </div>
  );
}

export default App;

useCallback으로 함수를 memoize 했기 때문에, <App /> 컴포넌트가 재실행 및 재평가 되어도 toggleParagraph는 재생성 되지 않고 같은 메모리 주소값을 가지기 때문에, React.memo로 memoized 된 <Button /> 컴포넌트는 부모 컴포넌트가 재실행 되어도 재호출 되지 않는다. 
 

6️⃣ 최적화 비용

물론 최적화에도 비용이 따른다. React.memo로 최적화 한 컴포넌트의 경우, 기존 props 와 새로운 props 의 값을 비교해야한다. 그러기 위해서 기존의 props 값을 저장할 공간 또한 필요하다. 즉, 최적화란, 컴포넌트를 재평가하는 데 필요한 성능 비용과 props 를 비교하는 성능의 비용을 서로 맞바꾸는 것이다.
자식 컴포넌트가 매우 많고, 많이 겹쳐있는 상황이라면 최적화를 통해서 비용을 아낄 수 있겠지만, 매우 작은 앱이나 매우 작은 컴포넌트의 트리에서는 그 효과가 미미할 것이다. 그래서 모든 컴포넌트에서 사용할 필요는 없고, 몇 가지 주요 컴포넌트 부분을 선택하여 사용하면 된다. 
 


참고 : https://rkraj604-hzb.medium.com/react-re-render-vs-re-evaluation-issue-with-ease-9eaf5ac99dba

댓글