프론트엔드/React

리액트 렌더링 최적화

Hyeon_E 2023. 7. 1. 01:45

[ 미리 알고 있어야 하는 개념들 ]

▶ 브라우저 렌더링

Html 파싱을 해서 DOM을 만들고 CSS를 파싱 해서 CSSOM을 만듦

DOM과 CSSOM을 활용해서 렌더트리를 만듦 그리고 layout과 repaint 과정을 거쳐 화면에 웹사이트가 렌더링 됨

즉 사용자가 웹 사이트를 볼 수 있게 되는 것

 

▶ 리액트에서의 렌더링

리액트가 함수를 호출하는 것. 리액트 렌더링은 render phase와 commit phase로 구성되어 있음

 

- render phase

render phase에서는 컴포넌트를 호출하여 리액트 엘리먼트를 반환하고 새로운 virtual DOM을 생성해 줌

만약 이번이 첫 번째 렌더링이 아니라면 재조정과정을 거친 후 Real DOM에 변경이 필요한 목록들을 체크

재조정이란 간단히 말해서 이전 virtual DOM과 현재 virtual DOM을 비교하는 과정이라고 보면 됨

 render 이후에는 commit phase가 존재

 

- commit phase

render phase에서 체크해 놓았던 변경이 필요한 부분들을 Real DOM에 반영해 주는 단계

만약 변경이 필요한 부분들이 없다면 commit phase는 skip이 됨

 

최종적으로 정리하면 리액트에서 렌더링이 일어날 때마다 재조정 과정이 포함된 render phase와 commit phase로 구성된 렌더링 프로세스를 거치게 되는 것

 

▶ 리렌더링이 되는 조건

컴포넌트가 리렌더링 되는 조건은

  1. state가 바뀌었을 때
  2. props가 바뀌었을 때

 

▶ profiler

콘솔로그와 profiler를 활용해서 렌더링을 확인할 수 있음

profiler는 profiler dev tools는 웹사이트의 컴포넌트들이 어떻게 렌더링 되고 있는지를 보여주는 도구

profiler를 이용해서 렌더링에 대해 정보를 수집할 수 있는데 어디 부분에서 렌더링이 오래 걸리고 불필요한 렌더링이 걸리는지 등을 확인할 수 있음

 

▶ React.Memo

function Component({ content }) {
  return <p>{content}</p>;
}
export default React.memo(Component);

 

React.Memo는 전달받은 props가 이전 props와 비교했을 때 같으면 컴포넌트의 리렌더링을 막아주고 마지막으로 렌더링 된 결과를 재사용하는 고차 컴포넌트. react.memo가 props를 비교할 때는 기본적으로 얕은 비교를 통해서 진행됨.  얕은 비교는 원시타입의 데이터의 경우 값이 다른지 비교하고 참조 타입의 데이터는 참조 값이 같은지를 비교함. React.memo의 두 번째 인자로 비교 함수를 넣어주게 되면 해당 함수를 활용해서 비교를 해줄 수도 있음

 

▶ useCallback

function Component(){
  const memoizedFunction = useCallback(() => someFunction(), []);
}

 

useCallback은 함수를 메모이제이션 해주는 hook. 여기서 메모이제이션이란 기존에 수행한 연산의 결괏값을 어딘가 저장해 두고 필요할 때 재 사용하는 기법. useCallback으로 함수를 감싸주게 되다면 의존성 배열이 변하지 않는 이상 컴포넌트가 리렌더링 될 때마다 변수에 같은 함수가 할당이 됨

 

▶useMemo

function Component(){
  const memoizedValue = useMemo(() => getSomeValue(), []);
}

 

 useMemo는 값에 대한 메모이제이션을 제공하는 hook. 의존성 배열에 들어있는 값이 변경되지 않는 이상 매번 리렌더링 될 때마다 같은 값을 반환해 주게

 

▶ React.createElement

React.createElement는 새로운 리액트 엘리먼트를 생성해서 반환해 줌(계층구조)

호출이 되면 계층으로 구성된 React.createElement를 순차적으로 호출해 주게

리액트에서는 JSX를 사용해서 개발자가 보기 편하게 되어있음

 

[ 예시 ]

▶ 함수를 props로 넘겨주게 되는 예시

import React from 'react';

function App() {
  const handleClick = () => {};
  return <h1 onClick={handleClick}>rendering is just calling function</h1>;
}
export default App;

 

이 컴포넌트가 렌더링 된다라는 말의 뜻은 App 컴포넌트가 호출이 되어서 내부 로직이 실행돼서 return문을 통해 리액트 element를 반환하는 것

 

function Parent() {
  const [valueForFirstChild, setValueForFirstChild] = useState(null);
  const handleClick = () => {};
  useEffect(() => {
    setTimeout(() => {
      setValueForFirstChild('changedValue');
    }, 3000);
  }, []);
  return (
    <>
      <FirstChild value={valueForFirstChild} />
      <SecondChild onClick={handleClick} />
    </>
  );
}
export default Parent;
function FirstChild({ value }) {
  return <div>{value}</div>;
}
function SecondChild({ onClick }) {
  return (
    <div onClick={onClick}>
      {Array.from({ length: 1000 }).map((_, i) => (
        <GrandChild key={i + 1} order={i} />
      ))}
    </div>
  );
}

 

SecondChild 컴포넌트는 GrandChild 컴포넌트들을 자식으로 가지고 있고 GrandChild 컴포넌트는 단순히 자기가 secondChild 컴포넌트를 기준으로 몇 번째 자식 컴포넌트인지를 콘솔에 출력해 주는 기능을 가지고 있음

Parent 컴포넌트가 렌더링 되고 난 후 useEffect 로직이 실행됨. setTimeout을 통해서 state에 변화를 주고 state 변화가 생겼으니 parent 컴포넌트가 리렌더링 됨. 렌더링은 함수를 호출하는 것이기 때문에 Parent 컴포넌트가 호출이 됨

Parent 컴포넌트의 return문을 실행하면서 FirstChild와 SecondChild 컴포넌트도 리렌더링 됨

 

여기서 문제는 변경된 state 전달을 한 FristChild(state 사용)와는 달리 변경된 state를 전달받지 않는 SecondChild(state 사용 X)도 리렌더링이 발생하다는 것. 변경된 값도 없는데 똑같은 정보를 보여주기 위해 리렌더링 되는 게 불필요해 보임

SecondChild 컴포넌트가 리렌더링이 되면 약 1000개의 GrandChild 컴포넌트들도 특정한 상황이 아닌 이상 리렌더링 될 것

 

Parent 컴포넌트가 리렌더링 되면 FirstChild 컴포넌트에게 전달되는 props값이 달라졌기 때문에 FirstChild 컴포넌트가 리렌더링 됨. 하지만 SecondChild 컴포넌트는 Parent컴포넌트의 변경된 state가 아닌 함수만을  props로 받고 있기 때문에 state와 props 중 변한 것이 없는 것처럼 보이지만 리렌더링이 됨. 이유는 매번 Parent 컴포넌트가 리렌더링 될 때마다 handleClick이 재생성이 되고 이전에 handleClick과 현재의 handleClick 함수는 다른 참조 값을 갖게 되어 다른 함수가 됨

즉 handleClick 함수가 매번 재 생성되기 때문에 props가 달라지면 컴포넌트가 리렌더링 된다라는 조건이 충족되어 리렌더링이 되는 것!!

 

이때 useCallback으로 함수를 감싸주어도 SecondChild는 리렌더링 됨 왜냐하면 Parent 컴포넌트의 return문안에는

FirstChild 컴포넌트와 SecondChild 컴포넌트에 해당하는 React.createElement가 존재함

Parent 컴포넌트가 리렌더링 되면 내부 로직들이 다 실행이 되면서 SecondChild 컴포넌트에 해당하는 createElement도 실행됨. 그래서 useCallback을 사용해서 SecondChild 컴포넌트에 전달되는 props를 이전과 동일하게 해 주었더라도 이에 상관없이 리렌더링 될 수밖에 없음. 하지만 useCallback은 놀랍게도 렌더링이 되었지만 최적화에 효과가 있음

render phase는 실행되지만 useCallback을 활용해서 props 값을 이전과 같게 유지해 주었기 때문에 commit phase는 실행되지 않는 것. render phase 조차도 실행되지 않게 해주고 싶다면 React.memo를 사용해 주면 가능함

 

function SecondChild({ onClick }) {
  return (
    <div onClick={onClick}>
      {Array.from({ length: 1000 }).map((_, i) => (
        <GrandChild key={i + 1} order={i} />
      ))}
    </div>
  );
}
export default React.memo(SecondChild);

 

SecondChild 컴포넌트가 렌더링 과정에 진입하기 전에 props값인 onClick에 대해서 이전 값과 현재 값이 다른 지를 비교해 줌. 서로 값은 같이라면 SecondChild 컴포넌트의 렌더링 과정은 진행이 되지 않음

profile 결과를 확인하면 secondChild 컴포넌트가 react.memo에 의해서 렌더링 되지 않았다는 것을 알 수 있음

secondChild 컴포넌트가 렌더링 즉 secondChild 컴포넌트가 호출되지 않았기 때문에 그의 자식 컴포넌트들인 grandChild 컴포넌트들도 렌더링 되지 않는 것을 profiler를 통해 확인할 수 있음. 이렇게 react memo를 활용해서 secondChild가 render phase조차 발생하지 않도록 방지할 수 있음

 

▶ 객체를 props로 넘겨주게 되는 예시

function Parent() {
  const [valueForFirstChild, setValueForFirstChild] = useState(null);
  const item = {
    name: 'HE',
    price: '1,000,000',
  }
  const handleClick = () => {};
  useEffect(() => {
    setTimeout(() => {
      setValueForFirstChild('changedValue');
    }, 3000);
  }, []);
  return (
    <>
      <FirstChild value={valueForFirstChild} />
      <SecondChild onClick={handleClick} />
    </>
  );
}
export default Parent;
function SecondChild({ item }) {
  return (
    <div onClick={onClick}>
      {Array.from({ length: 1000 }).map((_, i) => (
        <GrandChild key={i + 1} order={i} />
      ))}
    </div>
  );
}
export default React.memo(SecondChild);

 

react.memo를 사용했는데도 불구하고 리렌더링이 발생됨. 이유는 item이라는  객체(참조 타입의 데이터)가 매번 새로 생성되고 secondChild 컴포넌트에게 매번 다른 참조값을 가진 item을 props로 전달되고 React.memo가 정상적으로 작동하지 않는 것

 

function Parent() {
  const [valueForFirstChild, setValueForFirstChild] = useState(null);
  const item = {
    name: 'HE',
    price: '1,000,000',
  }
  const memoizedItem = useMemo(() => item, []);
  const handleClick = () => {};
  useEffect(() => {
    setTimeout(() => {
      setValueForFirstChild('changedValue');
    }, 3000);
  }, []);
  return (
    <>
      <FirstChild value={valueForFirstChild} />
      <SecondChild onClick={handleClick} />
    </>
  );
}
export default Parent;

 

item 객체에 useMemo를 사용해 주면 의존성 배열이 변경되지 않는 이상 이 저에 사용했던 값을 반환해 주게 됨

메모이제이션이 적용된 item 객체를 SecondChild 컴포넌트에 전달해 주면 매번 새로운 item을 전달해주지 않고 이전과 동일한 item 객체를 전달해 주기 때문에 정상적으로 react memo가 동작하면서 불필요한 렌더링을 방지해 줌

profile을 확인해 보면 렌더링이 되지 않는 것을 확인해 줄 수 있음

 

[ 최종결론 ]

▶ 생각해 보기

리액트에서의 렌더링 최적화를 도와주는 useCallback, useMemo, React.memo를 모든 곳에서 사용해 주면 과연 좋을 것인가 한다면 좋을 수도 있지만 useCallback, useMemo, React.memo도 하나의 코드이고 내부적으로 특정한 동작을 실행시켜줘야 하기 때문에 하나하나 모두 비용이라고 생각하면 됨

리렌더링이 자주 되는 컴포넌트라고 해서 컴포넌트 내부의  함수를 무작정 useCallback으로 감싸주는 경우도 있을 것이고 부모로부터 전달받는 props가 매번 바뀔 수밖에 없는 상황에서 자식 컴포넌트에서  React.memo를 적용해 주는 경우도 있음. 이러한 상황들에서는 최적화를 시도하기 전보다 웹 사이트 성능이 더 안 좋아질 수 있음. useCallback, useMemo, React.memo를 어떻게 사용해 주는 것이 적절한지는 많은 프런트 개발자들이 여전히 하고 있는 고민

 

▶ 근본적인 코드를 먼저 개선

최적화 도구들을 사용하기 전에 먼저 근본적인 코드를 개선해야 함. 가장 좋은 최적화 방법은 불필요한 렌더링이 발생되지 않도록 처음부터 코드를 작성하는 것. 근본적으로 불필요한 렌더링을 발생할 수밖에 없도록 작성된 코드 상태에서 근본적인 문제를 해결하지 않고 최적화 도구를 무분별하게 사용한다면 이는 문제를 해결하는 것이 아니라 추후 해결해야 할 문제들을 더 쌓아가는 꼴이 됨. 그래서 코드를 개선해 보고 도구를 사용하는 것이 무작정 도구부터 사용하는 것보다 더 좋음

 

근본적인 코드 개선 예시

문제 예시

function Component(){
 const forceUpdate = useForceUpdate();
 return(
  <>
    <button onClick={forceUpdate}>force</button>
    <Consoler value="fixedValue" />
  </>
  );
};

 

button을 클릭하면 컴포넌트가 강제로 리렌더링 됨. 그리고 consolor 컴포넌트에는 고정된 값을 전달해주고 있지만 부모 컴포넌트가 리렌더링 되므로 consolor 컴포넌트도 불필요하게 리렌더링 되고 있음

 

개선된 예시

function Component({ children }){
  const forceUpdate = useForceUpdate();
  return (
  <>
    <button onClick={forceUpdate}>force</button>
  </>
  );
}
function App(){
  <div>
    <Component>
      <Consoler value="fixedValue"/>
    </Component>
  </div>
}
//부모 컴포넌트를 바벨로 컴파일한 코드
function Component({ children }){
  const forceUpdate = useForceUpdate();
  return React.createElement(
    React.Fragment,
    null,
    React.createElement(
      'button',
      {
        onClick: forceUpdate,
      }
      'force'
    ),
    children
  );
};

 

기존에는 부모 Component 내부에서 consoler 컴포넌트를 사용해 주었다면 지금은 children을 활용해서 consoler 컴포넌트를 주입해 주었음. 이렇게 코드를 리팩토링 해주면 컴포넌트가 리렌더링 되더라도 consolor 컴포넌트는 리렌더링 되지 않음. 부모 컴포넌트 return문에는 Consoler 컴포넌트에 대한 react.createElement가 존재하지 않기 때문에 부모 컴포넌트가 렌더링 즉 호출되더라도 Consoler 컴포넌트는 호출되지 않음

 

 

출처:  우아한 태그유튜브 - https://youtu.be/1YAWshEGU6g