[React] 자식 컴포넌트의 재평가 및 React.momo()로 컴포넌트 최적화하기

SuamKang·2023년 7월 20일
0

React

목록 보기
22/34
post-thumbnail

리액트는 부모컴포넌트의 리랜더링이 자식컴포넌트에게 영향을 끼치게 한다.
이말은 즉, 경우에 따라 혹은 앱의 컴포넌트간 로직이 복잡해질수록 성능에 있어서 악영향을 끼칠 수 있는 부분이 있게 된다는 의미로 해석 되기도 하는데,


예시 코드로 과정을 자세하게 살펴보면


App.js

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 toggleHandler = () => {
    setShowParagraph((prev) => !prev);
  };

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

export default App;

먼저 App컴포넌트에서 반환되는 자식컴포넌트는 DemoOutput컴포넌트와 Button컴포넌트가 있다.


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의 상태가 변경되어 h1요소 밑에 위치함 DemoOutput컴포넌트의 p요소 컨텐츠가 보이며 화면에 그려지게 되는 것인데

props로 상태를 전달해서 상태의 조건에 따라 랜더링이 되는 형태이다. 너무나 간단하지만 부모-자식 간의 컴포넌트 실행 및 랜더링을 확인해 볼 수 있는 아주 좋은 부분이 있다.


브라우저 실행결과


App.js 와 DemoOutput.js에 각각 랜더링 여부를 파악 하기 위해 콘솔을 지정하여 저장한 결과를 보았다.

우선 최초에 랜더링이 될때 두 컴포넌트 모두 실행이 되었고,

버튼 클릭이 이루어지면 상태가 변화하여 자식으로 전달된 props의 변경이 추가 혹은 삭제가 반복이 되며 깜빡거리는 현상이 발생했다.

매번 클릭이벤트로 바뀌는 상태는 App컴포넌트이지만, 실제로 바뀌는 부분은 DemoOutput컴포넌트의 일부분이다.

즉, 이를통해 리액트가 계속 비교 작업과 업데이트를 하고, 변경점을 찾아낸다는 증거라고 확인해 볼 수 있게된다.


하지만 자식으로 전달하는 props값을 App에서 관리하는 상태를 전달하지 않고 하드코딩 한상태로 바꿔봤을때

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 toggleHandler = () => {
    setShowParagraph((prev) => !prev);
  };

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

export default App;

이 상태로 다시 브라우저에서 확인을 해보면
버튼을 클릭했을때 전달하는 상태변경값을 주지않고 false만 했기때문에, p요소의 텍스트는 보이지 않지만,DemoOutput 콘솔도 역시 표시가 되고있는걸 확인할 수 있다.

왜 그런걸까??


부모컴포넌트에 영향을 받는 자식컴포넌트


App컴포넌트 함수는 상태가 버튼클릭으로인해 변경되었기 때문에 해당 컴포넌트 함수가 재실행(리랜더링)되고 재평가(Diffing을 통한 DOM업데이트) 된다.

여기서 App컴포넌트 함수 부분엔 반환문이 있고, jsx코드를 반환한다.
즉, App에서 반환되는 모든 jsx요소들은 결국 컴포넌트 함수에 대한 함수 호출하는 것과 같다.

정리하자면,
부모컴포넌트 함수가 재실행/재평가 되면 자식 컴포넌트 함수도 재실행/재평가 된다.
부모 컴포넌트들이 변경되었고 자식 컴포넌트들은 부모컴포넌트의 일부분이기때문이다.

위 코드에서 props의 변경이 실제 DOM의 변경으로 이어질 순 있지만
컴포넌트 함수에서 재평가를 할땐 부모 컴포넌트가 재평가 되는것으로 충분하다.

물론 자식컴포넌트(여기선 DemoOutput.js)가 재실행이 된다해서 실제 DOM이 변경이 된다는건 아니다!

실제 DOM은 변경된 부분이 확인이 된다면 가상DOM이 이전 가상DOM과 비교하여 변경이 되었다면 바꾸어 놓는것이지, 컴포넌트 함수가 재실행 했다해서 DOM이 변경이 되다는건 아니다!

위 예시코드에선 props로 전달하는값이 가상 DOM이 비교후 확인하여 변경점이 없었기 때문에 p요소의 컨텐츠가 화면에 여전히 안나오는게 정상이다.
자식컴포넌트들(DemoOutput, Button)의 재실행은 정상적으로 부모에 영향을받아 다 일어나고 있다.

만약 자식컴포넌트에 또 다른 자식 컴포넌트가 있다면 이 역시 재평가가 되는건 동일하다!

내의견

이러한 이유로 상태 변경후 재실행은 다되어지지만 재평가는 변경된 부분만 ReactDOM이 알아서 처리하여 실제 DOM에 반영하기 때문에 굉장히 많은 함수가 실행되어도 성능에 영향을 미치지 않도록 해주는게 난 리액트가 가지고있는 좋은 최적화 기능이라고 생각이 든다.


❗️ 혼선이 있었던 부분


여기서 난 헷갈리는 부분이 생겼었는데
바로 "컴포넌트 함수의 재평가" 와 "변경된 부분만을 재평가"하는 이 두가지 개념이 혼선이 있었다.


1. 컴포넌트 함수의 재평가

: '컴포넌트 함수의 재평가'는 컴포넌트 함수 자체가 다시 실행되는 것을 의미한다. 즉 이게 재실행(함수 다시 호출)인것이다!

리액트 컴포넌트는 상태(State)나 속성(props)이 변경되거나 부모 컴포넌트로부터 새로운 속성(props)을 전달받을 때 해당 컴포넌트 함수가 재평가된다. 이는 리액트가 컴포넌트의 상태와 속성에 따라 새로운 가상 DOM을 생성하기 위해 함수를 다시 호출하는 것을 의미한다.


2. 변경된 부분만을 재평가

: '변경된 부분만을 재평가'는 리액트가 이전 가상 DOM과 현재 가상 DOM을 비교하여 변경된 부분만을 파악하는 것을 의미한다. 이 과정이 앞서 설명했던 컴포넌트함수가 호출이 된 후에 바로 이어서 실행이 되는 작업이다.

이 비교과정(Diffing)을 통해 변경된 부분만을 실제 DOM에 반영한다. 이렇게 하면 DOM 조작을 최소화하여 성능을 향상시키는 것이다.


차이 정리

리액트는 상태나 속성이 변경되었을 때 해당 컴포넌트 함수를 재실행(리랜더링)하고, 그 후 변경된 부분만을 재평가하여 실제 DOM에 반영하여 화면에 그려준다.

사실 위 두개념이 상태나 속성이 변경되어 컴포넌트가 리랜더링 되면 함께 동작하게 되는것이다!




React.memo()


위 예시같은 간단한 앱은 ReactDOM에 의해 충분히 최적화가 자연스레 되며 전혀 문제가 되지 않지만 좀 더 큰 앱에선 좀 더 최적화가 필요하게 된다.

리액트는 최적화를 위해 특정환 상황일 경우에만 해당 컴포넌트를 재실행 할 수 있도록 지시할 수 있는 메소드를 제공한다.

예를들면, 전달받은 props가 변경되거나 하는 경우이다.

적용방법


React.memo를 적용하는 법은 꽤 간단하다.
그냥 해당 props가 바뀌었는지 확인하는 컴포넌트를 지정한 뒤 export할때 감싸주면 된다.

DemoOutput.js

import React from "react";

const DemoOutput = (props) => {
  return <p>{props.show ? "This is New" : ""}</p>;
};

export default React.memo(DemoOutput);

하지만 이 방법은 함수형 컴포넌트에만 적용가능하고 클래스형 컴포넌트 기반에선 작동하지 않는다.

React.memo()의 역할


React.memo가 어떤역할을 가지고 컴포넌트의 최적화를 돕는지 순서로 살펴보자.


  1. React.memo는 해당 인자로 들어가는 컴포넌트에 어떤 props가 입력되는지를 확인한다.
  2. 입력되는 모든 props의 새로운 값을 확인한 뒤, 이를 기존의 props의 값과 비교하도록 리액트에게 전달한다.
  3. 그리고 props의 값이 바뀐 경우에만 해당 컴포넌트를 재실행 및 재평가를 진행한다.
  4. 부모컴포넌트가 변경 되었다 해도, 해당 컴포넌트(자식컴포넌트)의 props값이 바뀌지 않았다면 컴포넌트 재실행을 하지 않는다.
  5. 만약 해당 컴포넌트에 또 다른 자식컴포넌트가 있다면 그거 역시 재실행해서 출력되지 않는다.

🤔

여기서 의문점이 생긴다.
그렇다면 모든 컴포넌트에 해당 메소드를 적용하여 최적화를 꾀할 수 있을 거 같은데 그냥 다 적용하면 안되는걸까..?

이러한 최적화는 비용이 따르기 때문이다.


이 React.memo 메소드는 부모 컴포넌트에서 변경이 발생할때마다 자식 컴포넌트로 이동하여 기존 props값과 새로운 props값을 비교하게 한다.

해서 기존의 props값을 저장할 공간이 필요하고 비교하는 작업도 필요하게 된다.
이렇게 2가지 작업을 리액트가 해야하며, 각각 개별적인 성능 비용이 발생한다.


따라서,
이러한 성능효율은 어떤 컴포넌트를 최적화 하느냐에 따라 달라지게 된다.
컴포넌트를 재평가하는데 필요한 성능비용과
props를 비교하는데 필요한 성능비용을 서로 맞바꾸는것이다.


  1. props의 갯수
  2. 컴포넌트의 복잡도
  3. 자식 컴포넌트의 수

위와같은 경우에 따라서 달라지므로 어느쪽의 비용이 더 높다고 정하기가 불가능한것이다.

만약 거대한 컴포넌트 트리를 통해 복잡한 앱 구성해 자식컴포넌트가 많다면
컴포넌트를 재평가하는데 필요한 성능비용이 훨씬 더 크기때문에
React.memo같은 메서드가 유용하게 쓰일 수 있는것이다.


하지만 그 반대로 부모컴포넌트를 매번 재평가 할 때마다 컴포넌트의 변화가 있거나 props의 값이 변화가 많아질 수 있는 경우라면 React.memo의 효율은 크게 의미가 없다.

props변화가 많다? -> props값 비교 비용이 리랜더링 비용보다 더든다 -> 그럼 차라리 리랜더링을 해서 props비용을 아낀다.

이렇게 되었을때, 컴포넌트의 리랜더링은 계속 일어날것이고, props값 비교에 대한 비용은 아낄 수 있겠지만 이렇게 오버헤드로 발생하는 비용은 굳이 아낄만한 가치는 없다고 판단 할 수 있기 때문이다.


위 예시에서 든 DemoOutput컴포넌트는 사실상 매우 작은 앱이기때문에 안하는게 좋지만, 불필요한 재평가를 안해도 될 큰 규모의 앱이라면 그럴만한 가치가 있다는 것이다.


App.js

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 toggleHandler = () => {
    setShowParagraph((prev) => !prev);
  };

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

export default App;

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 React.memo(Button);

그렇다면 App의 자식컴포넌트인 Button컴포넌트는 적용하는게 좋을까 좋지 않을까?

사실 Button컴포넌트를 구현한 이유는 매번 재사용될 수 있고, 트리거역할이 강하기 때문에 버튼이 다시 변경될 일이 없으니 이 Button컴포넌트를 매번 재평가 하는것은 가치가 없다.(왜냐면 Button컴포넌트에 있는 텍스트나 함수는 같은걸 매번 사용하고있으니)

따라서 React.memo를 적용해보는것도 나쁘지 않겠다.

그러나❗️

React.memo를 적용하고 브라우저에서 버튼을 클릭하여 확인을 하게되면 이상하게도 콘솔에 찍히고 있다.(리랜더링 되고있음)

1은 최초 랜더링이고, 2가 버튼을 클릭했을때 리랜더링 되어 찍힌 부분인데 여전히 콘솔에 button이 찍히고 있다.

이것은 props값이 계속 바뀐다는 뜻인건데
확인해보면 onClick이라는 함수를 전달하는 props도 하나이고 컨텐츠 텍스트도 불변이다.


이 부분은 찾아본 결과, 리액트에서 흔히 발생하는 오류 중 하나라고 하는데

App컴포넌트 함수는 어쨋건 자바스크립트 함수이기 때문에 일반 자바스크립트 함수처럼 실행 된다.
그리고 이 함수는 개발자가 아닌 리액트에 의해 호출이 된다.
이 말은 모든 코드가 다시 실행된다는 의미이다. Button컴포넌트에 전달되는 이벤트 함수(onClick)는 매번 랜더링 또는 실행 사이클에서 완전히 새로운 함수이다.

모든 코드가 다시 실행되므로 당연히 새로운 함수가 만들어지고 이 함수는 그럼 이전과 같은 함수로 인식하지 않고 같은 기능을 하는 새로운 함수로 리액트는 인식한다.


그렇다면 왜 React.memo가 DemoOutput컴포넌트에선 작동하고 Button컴포넌트에선 작동하지 않는것일까?

DemoOutput에 전달한 show={false}
Button에 전달한 onClick={toggleParagraphHandler}
뭐가 다른걸까?


DemoOutput에 전달한 false는 불리언값이며 원시 값이다. 아까 말했듯이 React.memo가 props이전 값과 현재값을 비교하는데 이 작업을 일반 비교연산자를 통해 한다.

자바스크립트의 원시값은 서로 같은값이면 true로 판단한다. 당연하다.

원시값 비교

false === false
// true

'good' === 'good'
// true

4 === 4
// true

이렇게 원시값이면 비교가 가능하지만

배열이나 객체, 함수(일급객체)를 비교한다면 이는 다른경우이다.

참조값 비교

[1,2,3] === [1,2,4]
// false

{ a: "a", b: 'hi'} === { a: "a", b: 'hi'}
Uncaught SyntaxError: Unexpected token ':'

Javascript에서 객체리터럴을 '==='연산자로 비교하는것은 객체의 내용을 비교하는게 아니라 객체의 참조값을 비교하는 것이다.
즉, 객체가 동일한 메모리위치(주솟값)를 가리키는 경우에만 '==='가 ture로 평가 된다.

그렇기 때문에 함수도 자바스크립트에선 하나의 객체에 불과하다.

리액트에서도 App 컴포넌트 함수가 실행 될 때마다 새로운 함수 객체(toggleParagraphHandler)가 생성이 되며,
이 함수 객체가 onClick props에 전달된다.

이는 이전 toggleParagraphHandler과 현재 toggleParagraphHandler를 비교하는 것이 되는데 이 두 함수 객체가 아무리 같은 내용을 갖고 있다 해도 자바스크립트 규칙상 이 둘은 결코 동일하지 않는것이다.

따라서 React.memo가 이러한 자바스크립트 작동방식 때문에 props 값이 변경되었다고 인식하게 되어 리랜더링이 되게된것이다.('Button Running'이 콘솔에 찍힌 이유)


React.memo의 한계??


그렇다면 이렇게 원시값을 전달하는 props형태만 정확한 판단이 가능하게 될텐데,
사실 컴포넌트에 props로 전달하는 값은 함수나 객체형태가 훨씬 더 많을건데 이 경우엔 어떻게 해결해야할까??

아니다.
해결방법이 있다.

바로 useCallback hook을 사용하는것이다.

profile
마라토너같은 개발자가 되어보자

0개의 댓글