본문 바로가기
React 완벽 가이드

컴포넌트 재생성 및 Rerendering, Optimizing React(with useCallback ,memo and useMemo)

by ash9river 2024. 3. 29.

컴포넌트 재생성과 리렌더링의 차이

재성성 (Remounting)

  • 재성성은 컴포넌트가 완전히 파괴되고 다시 생성되는 과정.
  • 주로 부모 컴포넌트에서 해당 자식 컴포넌트를 제거하고 다시 추가하는 경우에 발생.
  • 부모 컴포넌트의 상태나 속성이 변경되어 자식 컴포넌트를 다시 마운트해야 하는 상황에서 발생.

리렌더링 (Rerendering)

  • 리액트 컴포넌트가 상태(state) 또는 속성(props)의 변경으로 인해 다시 그려지는 과정.
  • 리렌더링은 컴포넌트의 가상 돔(Virtual DOM)을 다시 계산하고, 변경된 부분만 실제 DOM에 반영하는 과정.
  • 함수 컴포넌트에서는 상태(state)나 속성(props)이 변경될 때마다 컴포넌트가 리렌더링된다.
  • 클래스 컴포넌트에서는 render 메서드가 호출되고, 가상 돔이 업데이트되어 실제 DOM에 반영된다.

만약 리렌더링이 일어나고, 재생성이 일어나지 않을 때, 컴포넌트를 재생성 시키고 싶으면 컴포넌트의 key 속성을 이용한다.

예시

  • 자식 컴포넌트로 progress bar가 있는 상황에서, useCallback을 이용해서, 전달되는 함수의 객체가 재생성하지 않게 되었다.(함수의 불변성 유지)
  • 부모 컴포넌트의 userAnswer 상태가 변경되어서 리렌더링이 발생했을 때, 자식컴포넌트의 progress bar상태가 변하지 않는 상황이 발생했다.
  • 그 이유는 부모 컴포넌트가 리렌더링이 발생하여도 자식컴포넌트로 전달되는 값이 변경되지 않기 때문에, 자식 컴포넌트가 리렌더링은 발생하지만 재생성 되지 않아, 자식 컴포넌트의 remainingTime 상태가 초기화되지 않기 때문이다.
  • 그 해결법으로 하위 컴포넌트에 key 값을 주어서 재생성을 일으키도록 강제한다.

리렌더링은 컴포넌트의 UI를 갱신하고, 상태와 속성 값의 변경 등을 반영하는 과정이지만, 상태의 초기값이나 컴포넌트가 처음 마운트되었을 때의 설정은 리렌더링 시에 재설정되지 않는다.

❗❗❗ 초기 설정은 컴포넌트가 처음 마운트될 때만 이루어지고, 리렌더링이 발생하더라도 초기화되지 않는다.

import { useState, useCallback } from 'react';

import QUSETIONS from '../question';
import quizCompleteImg from '../assets/quiz-complete.png';
import QuestionTimer from './QuestionTimer';

export default function Quiz() {
  const [userAnswers, setUserAnswers] = useState([]);

  const activeQuestionIndex = userAnswers.length;

  const handleSelectAnswer = useCallback(function handleSelectAnswer(
    selectedAnswer,
  ) {
    setUserAnswers((prev) => {
      return [...prev, selectedAnswer];
    });
  }, []);

  const handleSkipAnswer = useCallback(
    () => handleSelectAnswer(null),
    [handleSelectAnswer],
  );

  return (
    <div id="quiz">
      <div id="question">
        <QuestionTimer
          key={activeQuestionIndex}
          timeout={5000}
          onTimeout={handleSkipAnswer}
        />
      </div>
    </div>
  );
}
import { useEffect, useState } from 'react';

export default function QuestionTimer({ timeout, onTimeout }) {
  const [remainingTime, setRemainingTime] = useState(timeout);

  useEffect(() => {
    const timer = setTimeout(onTimeout, timeout);

    return () => {
      clearTimeout(timer);
    };
  }, [onTimeout, timeout]);

  useEffect(() => {
    const interval = setInterval(() => {
      setRemainingTime((prevRemainingTime) => prevRemainingTime - 100);
    }, 100);

    return () => {
      clearTimeout(interval);
    };
  }, []);

  return <progress id="question-time" max={timeout} value={remainingTime} />;
}

불변성 유지하며 업데이트

function handleSelectAnswer(selectedAnswer) {
  setUserAnswers((prev) => {
    return [...prev, selectedAnswer];
  });
}

간단한 자바스크립트 랜덤으로 섞기

const shuffledAnswers = [...QUSETIONS[activeQuestionIndex].answers];
shuffledAnswers.sort((a, b) => Math.random() - 0.5);

Optimizing React

React dev Profiler

Flamegraph chart

  • 컴포넌트의 실행 순서와 관계를 알 수 있다.

image

Ranked chart

  • 리렌더링된 컴포넌트들만 볼 수 있고, 리랜더링 사이클에 필요한 컴포넌트들을 확인할 수 있다.

image

자식 컴포넌트 리렌더링

  • App.jsxinput 태그의 값이 바뀔 때마다 모든 하위 컴포넌트들이 리렌더링되었다.

import { useState } from 'react';

import Counter from './components/Counter/Counter';
import Header from './components/Header';
import { log } from './log';

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>
    </>
  );
}

export default App;

해결법

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

  • memo() 이용
    • 컴포넌트 함수를 memo로 감싸고, 그 결과를 변수나 상수를 통해 저장.
    • 보통 const를 이용해 따로 저장해두고 이름은 컴포넌트 함수 이름과 동일하게 지어준다.
    • 그리고 이 변수 혹은 상수를 내보낸다.
  • memo()가 하는 일은 컴포넌트 함수의 props를 살펴본다.
  • 그 후 컴포넌트 함수가 정상적으로 다시 실행될 때, props의 값이 이전 값과 동일하다면 컴포넌트 함수의 리렌더링을 방지한다.
  • 만약 컴포넌트 내부 상태가 바뀌면, memo()는 저지하지 않고 단순히 컴포넌트의 함수를 작동시킨다.

memo()는 내부 상태(state) 변화로 인한 컴포넌트 리렌더링을 저지하지 않는다.

마찬가지로, 외부 상태(props) 변화로 인한 컴포넌트 리렌더링을 저지하지 않는다.

단순히, 외부 상태(props)가 변하지 않았는데, 상위 컴포넌트의 리렌더링이 발생한다면, memo()가 있는 컴포넌트의 리렌더링을 저지한다.

import { useState, memo } from 'react';

const Counter = memo(function Counter({ initialCount }) {
  log('<Counter /> rendered', 1);
  // 로직

  return (
    <section className="counter">
      {/* 내용 */}
    </section>
  );
});

export default Counter;
  • ❗ 그러나 모든 컴포넌트를 memo()로 감싸면 안된다.
  • 최대한 상위 컴포넌트를 memo()로 감싸는 것은 가능하나, 모든 컴포넌트를 memo()로 감싸게 되면, React는 렌더링하기 전에 해당 컴포넌트들의 props를 항상 확인하게 되고, 그것이 성능의 저하로 이어진다.

컴포넌트 구조 설계

  • App.jsxinput 태그의 값이 변동할 때마다 하위 컴포넌트가 전부 리렌더링되었다.
  • App.jsxinput 태그를 App.jsx의 하위 컴포넌트로 분리하여, input 태그가 있는 컴포넌트만 리렌더링을 시킨다.

자식 컴포넌트의 재실행은 부모 컴포넌트의 재실행을 야기시키지 않는다.

분리 전

image

분리 후

image

useCallback()

  • 카운터의 값을 증가시켰을 때, Counter.jsx 컴포넌트의 상태가 변하여 리렌더링이 일어난다.
  • 그런데 리렌더링을 하면 전달되는 함수 객체의 자체 값이 변하여 하위 컴포넌트 전체가 리렌더링을 하게 되었다.
  • props가 변하지 않는 증가, 감소 버튼이 리렌더링이 일어날 필요가 없는데, 최적화가 안되어 있는 것이다.
  • 그러므로, 함수 객체의 불변성을 지키면서 useCallbackmemo를 활용하여 리렌더링을 방지한다.

변경 전

  • 증가, 감소 버튼 로직
<IconButton icon={MinusIcon} onClick={handleDecrement}>
  Decrement
</IconButton>
<CounterOutput value={counter} />
<IconButton icon={PlusIcon} onClick={handleIncrement}>
  Increment
</IconButton>
import { log } from '../../log';

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>
  );
}

변경 후

  • 상위 컴포넌트의 내용
import { useState, useCallback } from 'react';

import IconButton from '../UI/IconButton';
import MinusIcon from '../UI/Icons/MinusIcon';
import PlusIcon from '../UI/Icons/PlusIcon';
import CounterOutput from './CounterOutput';
import { log } from '../../log';

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 += 1) {
    if (number % i === 0) {
      return false;
    }
  }

  return true;
}

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);
  }, []);

  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>
  );
}

export default Counter;
  • memo를 이용한 버튼 리렌더링 방지
import { memo } from 'react';
import { log } from '../../log';

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;

useMemo()

  • useMemo()는 컴포넌트에 있는 함수들을 감싸고 처음에 계산된 결과값을 메모리에 저장한다.
  • 컴포넌트가 반복적으로 렌더링 되어도 이전에 이미 계산된 결과값을 메모리에서 꺼내와서 재사용 할 수 있게 하는 메모이제이션 기법이다.
  • useMemo()는 두 개의 인자가 필요하다. 첫 번째는 콜백 함수, 두 번째는 의존성 배열이다.
    • 첫 번째 인자인 콜백 함수는 메모이제이션을 해줄 값을 계산해서 리턴해 주는 함수이다.
    • 두 번째 인자인 의존성 배열 안에 요소가 업데이트 될때만, useMemo()가 배열 안의 요소의 값이 업데이트될 때만 콜백 함수를 다시 호출하고, 메모이제이션 된 값을 업데이트한다.

useMemo()memo()와 마찬가지로 추가적인 의존성 값 비교를 수행해야 하기 때문에 남용은 금물이다.

매 컴포넌트 함수가 실행될 때마다 재실행되어야 하는 함수가 있다면, useMemo()의 추가적인 확인 과정은 성능 낭비로만 이어진다.

그러나 지나친 실행을 피할 수 있고 어떤 코드를 실행할지에 따라 실행 시간이 길어지는 경우, useMemo()를 사용하는 것이 더 합리적이다.

React의 Virtual DOM

  • 컴포넌트 함수가 실행되고, 그 컴포넌트 함수의 JSX 코드가 반환함으로써, 그 JSX를 기반으로 ReactVirtual DOM을 생성한다.
    • JSX가 React 엔진에서 관리되는 가상의 트리를 형성한다.
  • Virtual DOM을 이용하여 최종적으로는 브라우저가 이해할 수 있는 실제 HTML 요소로 변환되고, 이 HTML 코드가 실제 DOM에 삽입되어 화면에 나타난다.
  • ReactVirtual DOM을 사용하여, 변경된 부분이 있을 때만, 실제 DOM의 어떤 부분이 업데이트되어야 하는지 찾고, 변동된 Virtual DOMReact가 실제 DOM에 적절한 업데이트를 한다.

이를 통해 React는 실제 DOM에 필요한 최소한의 변경만을 수행하여 성능을 최적화한다.

Key

State with Key

  • 컴포넌트 함수에 등록된 state는 항상 해당 컴포넌트의 범위 내에 속해있다.
    • 컴포넌트를 재사용을 가능하게 한다.

state는 컴포넌트 안에 속해 있다.

그리고 만약 똑같은 컴포넌트 함수를 사용해 그 함수를 기반으로 여러 컴포넌트 인스턴스(instance)를 만들면, 모든 인스턴스는 각자의 state를 갖는다.

List with Key

  • 키값은 비슷한 컴포넌트를 포함한 동적인 리스트에서 React가 컴포넌트를 확실히 식별할 수 있게 한다.

State Scheduling

  • 이전 글 참조
  • state batching
    • React는 같은 함수 내에서 다수의 state 업데이트가 있을 때, 한 번에 배칭하여서 단 한 번의 컴포넌트 함수 실행을 유도한다.
function handleSetCount(newCount) {
  setChosenCount(newCount);
  setChosenCount((prevChosenCount) => prevChosenCount + 1);
  console.log(chosenCount); // console.log()는 제대로 작동하지 않는다.
}

'React 완벽 가이드' 카테고리의 다른 글

Custom Hooks  (0) 2024.03.29
Data Fetch & HTTP Requests  (0) 2024.03.29
Side Effects & useEffect  (0) 2024.03.29
Context API & useReducer  (0) 2024.03.29
Refs & Portals  (0) 2024.03.29