front-end / / 2024. 3. 7. 07:20

[react] DOM 업데이트, DevTools, memo, useCallback(), useMemo

이 내용은 아래 강좌의 일부 강의를 정리한 내용이다.
[React 완벽 가이드 with Redux, Next.js, TypeScript] https://www.udemy.com/course/best-react

1. 리액트 컴포넌트 트리 생성

리액트에서 DOM 업데이트는 어떻게 실행되는가? 컴포넌트 함수가 어떻게 실행되는지를 알아보자.

다음 예제 앱을 통해 자세한 내용을 알아보자.

이 앱에는 여러 가지 컴포넌트가 있다. 숫자를 입력하고 Set 버튼을 클릭하면 initialValue 값이 세팅이 되며 + Increment, -Decrement 버튼을 통해 숫자를 변경할 수 있는 기능을 한다.

이 앱의 소스 내용을 확인해보자.

[App.js]

function App() {
  log('<App /> rendered');

  const [enteredNumber, setEnteredNumber] = useState(0);
  const [chosenCount, setChosenCount] = useState(0);

  function handleChange(event) {
    setEnteredNumber(+event.target.value);
  }

  function handleSetClick() {
    setChosenCount(enteredNumber);
    setEnteredNumber(0);
  }

  return (
    <>
      <Header />
      <main>
        <section id="configure-counter">
          <h2>Set Counter</h2>
          <input type="number" onChange={handleChange} value={enteredNumber} />
          <button onClick={handleSetClick}>Set</button>
        </section>
        <Counter initialCount={chosenCount} />
      </main>
    </>
  );
}

App 컴포넌트는 가장 먼저 실행되는 컴포넌트이다. 위의 컴포넌트 코드들은 전부 순차적으로 실행이 된다. 이 컴포넌트에는 두 개의 상태가 등록되고 그 다음 함수가 생성된다. 함수가 실행 되는 건 아니다. 그리고 나서 JSX 코드가 실행이 되고 컴포넌트를 통해 반환이 된다. JSX 코드는 반환될 때 자바스크립트로 변환 되어야 하고 화면에 렌더링될 수 있는 요소로 변환된다.

JSX 코드를 보면 내장 컴포넌트와 HTML 요소가 포함되어 있다. 대문자로 시작하는 것(Header, Counter)들이 내장 컴포넌트이고 소문자로 시작하는 것들이 HTML 요소이다. JSX 코드가 실행될 때 내장 컴포넌트도 순서대로 실행을 한다. App 컴포넌트에서 코드를 실행할 때 리액트는 Header 컴포넌트를 가장 먼저 실행한다.

[Header.js]

export default function Header() {
  log('<Header /> rendered', 1);

  return (
    <header id="main-header">
      <img src={logoImg} alt="Magnifying glass analyzing a document" />
      <h1>React - Behind The Scenes</h1>
    </header>
  );
}

다음으로 Counter 컴포넌트를 실행을 한다. 여기서 리액트는 chosenCount 값을 전달을 한다. 앱의 상태값이다. Counter 컴포넌트 실행 시 initialCount 값을 전달을 한다.

Counter 컴포넌트에서 initialCounter 값을 받아서 컴포넌트 내부 함수가 실행이 되고 JSX가 실행이 된다.

[Counter.js]

export default function Counter({ initialCount }) {
  log("<Counter /> rendered", 1);
  const initialCountIsPrime = isPrime(initialCount);

  const [counter, setCounter] = useState(initialCount);

  function handleDecrement() {
    setCounter((prevCounter) => prevCounter - 1);
  }

  function handleIncrement() {
    setCounter((prevCounter) => prevCounter + 1);
  }

  return (
    <section className="counter">
      <p className="counter-info">
        The initial counter value was <strong>{initialCount}</strong>. It{" "}
        <strong>is {initialCountIsPrime ? "a" : "not a"}</strong> prime number.
      </p>
      <p>
        <IconButton icon={MinusIcon} onClick={handleDecrement}>
          Decrement
        </IconButton>
        <CounterOutput value={counter} />
        <IconButton icon={PlusIcon} onClick={handleIncrement}>
          Increment
        </IconButton>
      </p>
    </section>
  );
}

Counter 컴포넌트 내에도 CounterOutputIconButton 내장 컴포넌트가 있다. Counter 컴포넌트가 실행될 때 리액트에서 관리하는 컴포넌트 트리에 추가가 된다.

[CounterOutput.js]

export default function CounterOutput({ value }) {
  log('<CounterOutput /> rendered', 2);

  const cssClass = value >= 0 ? 'counter-output' : 'counter-output negative';
  return <span className={cssClass}>{value}</span>;
}

[IconButton.js]

export default function IconButton({ children, icon, ...props }) {
  log('<IconButton /> rendered', 2);

  const Icon = icon;
  return (
    <button {...props} className="button">
      <Icon className="button-icon" />
      <span className="button-text">{children}</span>
    </button>
  );
}

이 앱을 실행해보면 최초 렌더링 시 아래와 같은 로그가 표시된다.

컴포넌트 트리에 표시된 모든 것들이 출력이 된다.

2. 리액트 DevTools Profiler로 함수 실행 분석

google에서 react dev tools로 검색을 하면 아래 react developer tools를 설치할 수 있다.

react dev tools는 크롬에서 설치하여 디버깅 용도로 사용할 수 있다. 위의 앱을 실행하고 개발자 도구를 열어 Starting profiling 버튼을 통해 실행할 수 있다.

실행하고 나서 화면에서 숫자를 입력한 이후 Stop을 누르면 그 결과를 확인할 수 있다.

위의 그림에서 App, Header, Counter, CounterOutput 순으로 컴포넌트가 실행된 것을 확인할 수 있다. Ranked chart로 보면 아래 형태로 표시가 된다.

여기서 Counter 컴포넌트의 +, -를 클릭하면 Counter 내부 상태를 변경하므로 Counter, CounterOutput 컴포넌트의 리랜더링을 유발시키지만 부모 컴포넌트인 App 컴포넌트에는 영향이 없다. 대신 입력값에 숫자를 입력하면 모든 컴포넌트에 영향을 준다. 왜냐하면 App 컴포넌트 내부에서 State를 변경하기 때문에 App을 포함한 하위 컴포넌트의 리랜더링을 유발한다.

여기서 View Settings에 들어가서 Record why each component rendered while profiling.를 체크해보자.

그리고 나서 앱을 새로고침하고 다시 profiling을 해보면 아래와 같이 해당 컴포넌트가 왜 랜더링이 되었는지 알 수 있다.

이렇듯 react dev tools을 통해 여러 컴포넌트를 디버깅하는데 사용할 수 있다.

3. memo()로 컴포넌트 함수 실행 방지

위의 컴포넌트에서 숫자를 변경해보자.

10으로 변경하고 개발자 도구 로그를 확인해보면 아래와 같이 표시된다.

App 하위의 모든 컴포넌트가 리랜더링 되었다. Input 영역의 컴포넌트만 리랜더링 되어야 하는데 왜 이런일이 벌어질까?

위의 Input 영역이 App 컴포넌트에 있기 때문이다. 숫자를 입력할 때마다 상태가 업데이트 된다. 상태가 변경되면 컴포넌트 함수가 재실행이 된다. 그리고 자식 컴포넌트 모두 재실행이 된다. 여기서 이런 상황이 반드시 나쁜것만은 아니지만 너무 많은 코드가 실행이 되는 문제가 생긴다.

const [enteredNumber, setEnteredNumber] = useState(0);
...
function handleChange(event) {
  setEnteredNumber(+event.target.value);
}

...

return (
  ...
  <input type="number" onChange={handleChange} value={enteredNumber} />
)

어떻게 이 문제를 해결할 수 있을까?

Counter 컴포넌트를 함수로 감싸서 불필요한 함수 실행을 방지할 수 있다. 이런 목적으로 사용되는 함수는 memo 함수이다.

const Counter = memo(function Counter({ initialCount }) {
  ...
});

export default Counter;

memo가 하는 것은 컴포넌트 함수의 속성(props)을 확인하여 이전 값과 새로 받을 속성 값을 비교한다. 만일 값이 같다면, 함수를 다시 실행하지 않고 기존에 실행된 결과값을 가져온다. 그래서 Counter는 한번만 실행이 된다. initialCounter가 변경되면 재실행이 된다. 물론 memo로 감싸진 함수의 내부 상태가 변경되면 당연히 재실행된다. memo는 부모 컴포넌트에서 호출할 경우에만 영향을 주고 내부 상태 변경에 대해서는 영향을 주지 않는다.

이렇게 Counter 컴포넌트를 memo로 감싸고 다시 숫자를 입력하여 실행해보자.

App과 Header만 실행이 된다. Counter가 memo로 감싸여져 있고 Counter 내부의 JSX 코드 또한 실행이 되지 않으므로 CounterOutput 또한 실행이 되지 않는다.

이런 방식으로 모든 컴포넌트를 memo로 감싸면 성능 개선이 되지 않을까 생각할 수 있다. 최상위 컴포넌트를 감싸는 것은 도움이 될 수 있다. 여기서는 Counter 컴포넌트이고 하위 여러 개 컴포넌트의 재실행을 막아주기 때문에 도움이 될 수 있다. 하지만 memo로 되어있는 컴포넌트는 함수를 실행하기 전에 속성들을 확인해야 한다. 그러한 작업들이 성능에 부담을 주게 된다. 그래서 신경을 써서 사용을 해야 한다. 이 예제의 앱은 전혀 문제가 되지 않는다.

4. useCallback() 훅 이해하기

이전 앱에서 + 버튼을 누르면 많은 컴포넌트가 재실행된다. 로그를 보면 아래와 같다.

Counter의 코드를 한번 보자.

const Counter = memo(function Counter({ initialCount }) {
  log("<Counter /> rendered", 1);
  const initialCountIsPrime = isPrime(initialCount);

  const [counter, setCounter] = useState(initialCount);

  function handleDecrement() {
    setCounter((prevCounter) => prevCounter - 1);
  }

  function handleIncrement() {
    setCounter((prevCounter) => prevCounter + 1);
  }

  return (
    <section className="counter">
      <p className="counter-info">
        The initial counter value was <strong>{initialCount}</strong>. It{" "}
        <strong>is {initialCountIsPrime ? "a" : "not a"}</strong> prime number.
      </p>
      <p>
        <IconButton icon={MinusIcon} onClick={handleDecrement}>
          Decrement
        </IconButton>
        <CounterOutput value={counter} />
        <IconButton icon={PlusIcon} onClick={handleIncrement}>
          Increment
        </IconButton>
      </p>
    </section>
  );
});

여기서 CounterOutput은 당연히 재실행이 되어야 하지만, +- 버튼은 재실행될 필요가 없다. 즉, IconButton은 재실행이 불필요하다는 것이다.

IconButton 소스를 한번 보자.

export default function IconButton({ children, icon, ...props }) {
  log('<IconButton /> rendered', 2);

  const Icon = icon;
  return (
    <button {...props} className="button">
      <Icon className="button-icon" />
      <span className="button-text">{children}</span>
    </button>
  );
}

코드가 간단하고 복잡하지 않아서 실행해도 크게 문제는 되지는 않지만, 일단 IconButton을 memo로 한번 감싸서 불필요한 재실행을 방지해보자.

const IconButton = memo(function IconButton({ children, icon, ...props }) {
  log("<IconButton /> rendered", 2);

  const Icon = icon;
  return (
    <button {...props} className="button">
      <Icon className="button-icon" />
      <span className="button-text">{children}</span>
    </button>
  );
});

export default IconButton;

하지만 다시 실행을 해보면 여전히 IconButton이 재실행이 된다는 것을 알 수 있다. memo가 제대로 작동을 안한것이다.

다시 말하면, memo가 동작은 했지만 속성값이 변경되었기 때문에 새로 실행된 것이다. IconButton이 받는 속성을 살펴보자. 속성은 3가지가 있다. (children, icon, ...props)

children은 output으로 쓰였으니 버튼의 문자이다. (Increment, Decrement) 이 문자들은 바뀌지 않는다.

icon은 다른 컴포넌트에서 정의되어 있기 때문에 재실행되지 않는다.

나머지 props를 통해 onClick 이벤트를 전달한다. (onClick={handleDecrement})
handleDecrement 함수가 변경되지 않을 것이라 생각되지만 Counter 컴포넌트에 내에 정의되어 있고 Counter 컴포넌트가 재 실행될 때 이 함수(handleDecrement)도 재생성이 된다. 즉, 상태(counter)가 변경될 때마다 새로운 함수가 생성이 된다. 이름은 같아도 메모리 안에 새로운 객체를 참조하게 된다는 것이다.

여기서 발생하는 문제인 상태변경에 따른 새로운 함수가 생성되는 문제를 useCallback 훅(hook)을 통해 막을 수 있다. useCallback은 함수의 재생성을 막을 수 있는 훅이며 가끔씩 useEffect 의존성을 갖고 있을 때 필요하다. 그리고 가끔은 memo를 사용할 때도 필요하다.

handleDecrement에 useCallback을 적용하면 아래와 같다.

const handleDecrement = useCallback(function handleDecrement() {
    setCounter((prevCounter) => prevCounter - 1);
}, []);

첫 번째 인자로 함수가 정의되며, 두 번째 인자로 의존성 배열이 추가되는데 빈 배열인 경우는 함수가 한번만 생성된다.

handleIncrement에도 동일하게 적용하자.

const handleIncrement = useCallback(function handleIncrement() {
  setCounter((prevCounter) => prevCounter + 1);
}, []);

새로고침을 하고 다시 +버튼을 클릭해보자.

이제 CounterOutput만 실행이 되고 Icon 버튼은 재실행이 되지 않는 것을 확인할 수 있다.

5. useMemo() 훅 이해하기

데모 화면에서 + Increment를 클릭하면 소수인지 확인하는 함수가 실행이 되고 버튼을 클릭할 때마다 출력이 된다.

소수를 확인하는 함수는 숫자를 입력하고 Set 버튼을 클릭하면 실행되어야 한다. 코드를 보면 아래와 같다.

function isPrime(number) {
  log("Calculating if is prime number", 2, "other");
  if (number <= 1) {
    return false;
  }

  const limit = Math.sqrt(number);

  for (let i = 2; i <= limit; i++) {
    if (number % i === 0) {
      return false;
    }
  }

  return true;
}

const Counter = memo(function Counter({ initialCount }) {
  log("<Counter /> rendered", 1);
  const initialCountIsPrime = isPrime(initialCount);

  const [counter, setCounter] = useState(initialCount);
  ...
  const handleDecrement = useCallback(function handleDecrement() {
    setCounter((prevCounter) => prevCounter - 1);
  }, []);

  const handleIncrement = useCallback(function handleIncrement() {
    setCounter((prevCounter) => prevCounter + 1);
  }, []);
}

isPrime 함수가 Counter 컴포넌트 함수 안에서 호출되어 컴포넌트 함수가 바뀔 때마다 실행이 된다. initialCount가 변경이 되지 않지만 handleDecrement, handleIncrement 함수가 호출될 때 setCounter를 통해 state가 변경되기 때문에 Counter 컴포넌트가 재실행된다. 만일 isPrime이 복잡하고 좋은 성능이 필요할 때는 더 중요한 문제일 수가 있다.

그래서 memo를 사용하여 컴포넌트 함수가 실행되는 것을 방지하고 싶다면 컴포넌트 내의 함수도 실행되는 것을 방지해야 한다. 물론 입력값(initialCount)이 바뀌지 않는 경우에만이다. 리액트에서 제공하는 훅은 이런 상황에 문제를 해결하기 위해서이다. 바로 useMemo 훅이다. memo와 혼동하면 안된다. memo는 컴포넌트 함수를 감싸는데 사용되고 useMemo는 일반 함수를 감싸고 실행을 방지한다.

useMemo는 복잡한 연산이 있을 경우에만 사용해야 한다. isPrime의 실행을 방지하려면 아래와 같이 하면 된다.

const initialCountIsPrime = useMemo(() => isPrime(initialCount), [initialCount]);

함수를 익명함수로 표현하고 의존성 배열도 추가한다. 의존성 배열이 변경되는 경우, 즉 initialCount가 변경이 되면 함수가 실행이 된다. 하지만 counter가 변경되는 경우는 실행이 되지 않는다.

이렇게 수정하고 다시 실행을 해보자.

isPrime 함수가 실행되지 않은 것을 확인할 수 있다. 하지만 위의 Set Counter에 숫자를 입력하고 Set 버튼을 누르면 isPrime 함수가 다시 실행이 된다.

여기서 강조하고 싶은 것은 useMemo를 남용해서는 안된다는 것이다. 모든 함수를 감싸는데 사용해서는 안된다. memo와 동일하게 의존성 값의 비교를 수행해야 하므로 이 또한 성능에 영향을 주기 때문이다. 함수의 실행시간이 오래걸리는 경우에 사용하는 것이 좋다.

반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유