코딩/React

React Hooks - 최적화(React.memo, useCallback, useMemo)

junhub 2023. 4. 18. 15:04

(1) 리-렌더링의 발생 조건

  1. 컴포넌트에서 state가 바뀌었을 때
  2. 컴포넌트가 내려받은 props가 변경되었을 때
  3. 부모 컴포넌트가 리-렌더링 된 경우 자식 컴포넌트는 모두

 

(2) 최적화

 

리액트에서 불필요한 렌더링이 발생하지 않도록 최적화하는 대표적인 방법은 아래와 같다.

  • memo(React.memo) : 컴포넌트를 캐싱
  • useCallback : 함수를 캐싱
  • useMemo : 값을 캐싱

 

 

# memo(React.memo)

(1) memo란?

리-렌더링의 발생 조건 중 3번째 경우. 즉, 부모 컴포넌트가 리렌더링 되면 자식컴포넌트는 모두 리렌더링 된다는 것은 그림으로 보면

  • 1번 컴포넌트가 리렌더링 된 경우, 2~7번이 모두 리렌더링 된다.
  • 4번 컴포넌트가 리렌더링 된 경우, 6, 7번이 모두 리렌더링 된다.

자녀 컴포넌트의 입장에서는 “나는 바뀐게 없는데 왜 다시 렌더링 돼야하지?”라고 할 수 있는데,

이 부분을 돕는 도구가 바로 React.memo이다.

 

 

App.jsx

import React, { useState } from "react";
import Box1 from "./components/Box1";
import Box2 from "./components/Box2";
import Box3 from "./components/Box3";

const boxesStyle = {
  display: "flex",
  marginTop: "10px",
};

function App() {
  console.log("App 컴포넌트가 렌더링되었습니다!");

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

  // 1을 증가시키는 함수
  const onPlusButtonClickHandler = () => {
    setCount(count + 1);
  };

  // 1을 감소시키는 함수
  const onMinusButtonClickHandler = () => {
    setCount(count - 1);
  };

  return (
    <>
      <h3>카운트 예제입니다!</h3>
      <p>현재 카운트 : {count}</p>
      <button onClick={onPlusButtonClickHandler}>+</button>
      <button onClick={onMinusButtonClickHandler}>-</button>
      <div style={boxesStyle}>
        <Box1 />
        <Box2 />
        <Box3 />
      </div>
    </>
  );
}

export default App;

 

Box1.jsx

import React from "react";

const boxStyle = {
  width: "100px",
  height: "100px",
  backgroundColor: "#91c49f",
  color: "white",

  // 가운데 정렬 3종세트
  display: "flex",
  justifyContent: "center",
  alignItems: "center",
};

function Box1() {
  console.log("Box1이 렌더링되었습니다.");
  return <div style={boxStyle}>Box1</div>;
}

export default Box1;

 

 

Box2.jsx

import React from "react";

const boxStyle = {
  width: "100px",
  height: "100px",
  backgroundColor: "#4e93ed",
  color: "white",

  // 가운데 정렬 3종세트
  display: "flex",
  justifyContent: "center",
  alignItems: "center",
};

function Box2() {
  console.log("Box2가 렌더링되었습니다.");
  return <div style={boxStyle}>Box2</div>;
}

export default Box2;

 

 

Box3.jsx

import React from "react";

const boxStyle = {
  width: "100px",
  height: "100px",
  backgroundColor: "#c491be",
  color: "white",

  // 가운데 정렬 3종세트
  display: "flex",
  justifyContent: "center",
  alignItems: "center",
};

function Box3() {
  console.log("Box3가 렌더링되었습니다.");
  return <div style={boxStyle}>Box3</div>;
}

export default Box3;

 

 

위 코드와 같이 구성했을때 + 혹은 - 버튼을 누르면 state가 변경되어 부모컴포넌트 및 자식 컴포넌트들이 모두 리렌더링 된다.

즉, 박스들도 다시 렌더링이 되고 있다는 의미이다. 

 

간단히 React.memo를 이용해서 컴포넌트를 메모리에 저장해두고 필요할 때 갖다 쓸 수 있다.

이렇게 하면 부모 컴포넌트의 state의 변경으로 인해 props가 변경이 일어나지 않는 한 컴포넌트는 리렌더링 되지 않는다.

이것을 컴포넌트 memoization 이라고 한다. 

 

# 컴포넌트 memoization 하는 방법

export default React.memo(Box1);
export default React.memo(Box2);
export default React.memo(Box3);

 

 

자식 컴포넌트가 리렌더링 되지 않고 App 컴포넌트(부모 컴포넌트)만 리렌더링 되는 모습


# useCallback

 

useCallback이란?

React.memo는 컴포넌트를 메모이제이션 했다면, useCallback은 인자로 들어오는 함수 자체를 기억(메모이제이션) 한다. 

 

Box1이 만일, count를 초기화 해 주는 코드라고 가정해보자.

 

 

App.jsx

...

	// count를 초기화해주는 함수
  const initCount = () => {
    setCount(0);
  };

  return (
    <>
      <h3>카운트 예제입니다!</h3>
      <p>현재 카운트 : {count}</p>
      <button onClick={onPlusButtonClickHandler}>+</button>
      <button onClick={onMinusButtonClickHandler}>-</button>
      <div style={boxesStyle}>
        <Box1 initCount={initCount} />
        <Box2 />
        <Box3 />
      </div>
    </>
  );
}

...

 

 

Box1.jsx

...

function Box1({ initCount }) {
  console.log("Box1이 렌더링되었습니다.");

  const onInitButtonClickHandler = () => {
    initCount();
  };

  return (
    <div style={boxStyle}>
      <button onClick={onInitButtonClickHandler}>초기화</button>
    </div>
  );
}

...

 

 

+ 버튼이나, - 버튼을 누를 때 그리고 초기화 버튼을 누를 때 모두

App 컴포넌트와 Box1 컴포넌트가 리렌더링 되는 것을 볼 수 있다. 

 

 

 

const onInitButtonClickHandler = () => {
  initCount();
};

React.memo를 통해서 Box1.jsx는 메모이제이션을 했는데도 리렌더링이 되는 이유는 함수형 컴포넌트를 사용했기 때문이고, 

App.jsx가 리렌더링 되면서 코드가 다시 만들어지기 때문이다.

 

자바스크립트에서는 함수도 객체의 한 종류이다.

따라서 모양은 같더라도 다시 만들어지면 그 주솟값이 달라지고 이에 따라 하위 컴포넌트인 Box1.jsx는 props가 변경됐다고 인식하는거임

 

 

 

# useCallback 사용을 통한 함수 메모이제이션

const onInitButtonClickHandler = () => {
  initCount();
};

 

이 함수를 메모리 공간에 저장해놓고, 특정 조건이 아닌 경우엔 변경되지 않도록 해야한다. 

 

 

# useCallback hook의 적용

// 변경 전
const initCount = () => {
  setCount(0);
};

// 변경 후
const initCount = useCallback(() => {
  setCount(0);
}, []);

함수를 useCallback()으로 감싸주었다. 이를 통해 화면이 처음 렌더링 될때를 기억하여 메모리에 저장해놓고 불러내서 쓰는 것이다.

따라서 주소값이 변경되지 않으므로 아래와 같이 자식 컴포넌트의 리렌더링 또한 실행되지 않는다.

 

 

# count를 초기화 할 때 console 찍어보기

...

// count를 초기화해주는 함수
const initCount = useCallback(() => {
  console.log(`[COUNT 변경] ${count}에서 0으로 변경되었습니다.`);
  setCount(0);
}, []);

...

초기화를 누르면 7에서 0으로 변경되었습니다. 라고 출력됨을 예상해볼 수 있으나, 

 

이와 같이 0으로 인식하는 것을 볼 수 있다. 

 

이런 현상이 발생하는 이유는, useCallback이 count가 0일 때의 시점을 기준으로 메모리에 함수를 저장했기 때문이에요. 이 때문에 우리는 dependency array가 필요합니다.

 

...

// count를 초기화해주는 함수
const initCount = useCallback(() => {
  console.log(`[COUNT 변경] ${count}에서 0으로 변경되었습니다.`);
  setCount(0);
}, [count]);

...

이와 같이 의존성 배열에 [count]를 넣어주면 count 값이 변하는 동안에는 count가 변경될 때마다 함수를 새롭게 할당하게 된다. 

 


# useMemo

useMemo란?

여기서 말하는 memo는 memoization을 뜻한다. 동일한 값을 반환하는 함수를 계속 호출해야 하면 필요없는 렌더링을 한다고 볼 수 있다. 맨 처음 해당 값을 반환할 때 그 값을 특별한 곳(메모리)에 저장한다. 이렇게 하면 필요할 때 마다 다시 함수를 호출해서 계산하는게 아니라 이미 저장한 값을 단순히 꺼내와서 쓸 수 있다. 보통 이러한 기법을 캐싱을 한다. 라고 표현하기도 함

 

// as-is
const value = 반환할_함수();

// to-be
const value = useMemo(()=> {
	return 반환할_함수()
}, [dependencyArray]);

dependency Array의 값이 변경 될 때만 **반환할_함수()**가 호출됨

그 외의 경우에는 memoization 해놨던 값을 가져오기만 한다

 

 

# useMemo 적용해보기 - 1

App.jsx

import "./App.css";
import HeavyComponent from "./components/HeavyComponent";

function App() {
  const navStyleObj = {
    backgroundColor: "yellow",
    marginBottom: "30px",
  };

  const footerStyleObj = {
    backgroundColor: "green",
    marginTop: "30px",
  };

  return (
    <>
      <nav style={navStyleObj}>네비게이션 바</nav>
      <HeavyComponent />
      <footer style={footerStyleObj}>푸터 영역이에요</footer>
    </>
  );
}

export default App;

 

 

components > HeavyComponent.jsx

import React, { useState, useMemo } from "react";

function HeavyButton() {
  const [count, setCount] = useState(0);

  const heavyWork = () => {
    for (let i = 0; i < 1000000000; i++) {}
    return 100;
  };

	// CASE 1 : useMemo를 사용하지 않았을 때
  const value = heavyWork();

	// CASE 2 : useMemo를 사용했을 때
  // const value = useMemo(() => heavyWork(), []);

  return (
    <>
      <p>나는 {value}을 가져오는 엄청 무거운 작업을 하는 컴포넌트야!</p>
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        누르면 아래 count가 올라가요!
      </button>
      <br />
      {count}
    </>
  );
}

export default HeavyButton;

HeavyComponent 안에서는 const value = heavyWork() 를 통해서 value값을 세팅해주고 있다.

만약 heavyWork가 엄청나게 무거운 작업이라면 다른 state가 바뀔 때 마다 계속해서 호출이 된다.

하지만 useMemo()로 감싸주게 되면 그럴 걱정이 없이 기존 초기값을 메모리에 저장해놓고 가져오게 된다. 

 

 

# useMemo 적용해보기 - 2

import React, { useEffect, useState } from "react";

function ObjectComponent() {
  const [isAlive, setIsAlive] = useState(true);
  const [uselessCount, setUselessCount] = useState(0);

  const me = {
    name: "Ted Chang",
    age: 21,
    isAlive: isAlive ? "생존" : "사망",
  };

  useEffect(() => {
    console.log("생존여부가 바뀔 때만 호출해주세요!");
  }, [me]);

  return (
    <>
      <div>
        내 이름은 {me.name}이구, 나이는 {me.age}야!
      </div>
      <br />
      <div>
        <button
          onClick={() => {
            setIsAlive(!isAlive);
          }}
        >
          누르면 살았다가 죽었다가 해요
        </button>
        <br />
        생존여부 : {me.isAlive}
      </div>
      <hr />
      필요없는 숫자 영역이에요!
      <br />
      {uselessCount}
      <br />
      <button
        onClick={() => {
          setUselessCount(uselessCount + 1);
        }}
      >
        누르면 숫자가 올라가요
      </button>
    </>
  );
}

export default ObjectComponent;

useEffect hook을 이용해서 me의 정보가 바뀌었을 때만 발동되게끔 dependency array를 넣어놨는데,

엉뚱하게도 count를 증가하는button을 눌러보면 계속 log가 찍히는 것을 볼 수가 있다. 

 

이유로는

불변성과 관련이 깊다.

위 예제에서 버튼이 선택돼서 uselessCount state가 바뀌게 되면 → 리렌더링 실행→ 컴포넌트 함수가 새로 호출 → me 객체도 다시 할당해요(이 때, 다른 메모리 주소값을 할당) → useEffect의 dependency array에 의해 me 객체가바뀌었는지 확인 → 이전 것과 모양은 같은데 주소가 다름 → 리액트 입장에서는 me가 바뀌었구나 인식하고 useEffect 내부 로직이 호출

 

// 이건 일치해요
const a = 1;
const b = 1;

console.log(a === b); // true

// 하지만 이건 달라요
const me = {
  name: "ted chang",
  age: 21,
};

const you = {
  name: "ted chang",
  age: 21,
};

console.log(me === you); // false

위 코드는 불변성 이해를 위한 코드이다. 

 

 

# 이런 상황을 해결하기 위해서 또한 useMemo를 활용할 수 있다

const me = useMemo(() => {
  return {
    name: "Ted Chang",
    age: 21,
    isAlive: isAlive ? "생존" : "사망",
  };
}, [isAlive]);

useMemo()만 이렇게 써주면, uselessCount가 아무리 증가돼도 영향이 없게 된다. 

의존성 배열에 isAlive를 넣어주어, isAlive 값이 변화할때만 새로운 객체를 리턴하게 한다. 

 

 

# 주의사항

useMemo를 남발하게 되면 별도의 메모리 확보를 너무나 많이 하게 되기 때문에 오히려 성능이 악화될 수 있다.