React (6) 렌더링 최적화

이종호·2022년 8월 2일
0

React

목록 보기
6/7
post-thumbnail

영상링크

렌더링

브라우저 렌더링

렌더링 최적화에 들어가기 전에, 브라우저 렌더링이 어떻게 이루어지는지 먼저 보고간다.

  1. HTML을 파싱해서 DOM을 만든다
  2. CSS를 파싱해서 CSSOM을 만든다
  3. DOM과 CSSOM을 결합해서 Render Tree를 만든다
  4. layout과 paint과정을 거친다
  5. 화면에 웹사이트가 렌더링된다.

리액트에서의 렌더링

function App() {
	const handleClick = () => {
    // something
    };
  	return <h1 onClick={handleClick}>hi</h1>
}

리액트에서의 렌더링은 리액트가 함수를 호출하는 것이다.
위 코드를 보자. App컴포넌트가 있다.
이 컴포넌트가 렌더링 된다는 뜻은 App컴포넌트가 호출이되서 내부 로직이 실행이되고 return을 통해 element들을 반환하는것 이다.

function Parent() {
  const [valueForFirstChild, setValueForFirstChild] = useState(null);

  const handleClick = () => {};

  useEffect(()=>{
    setTimeout(()=>{
      setValueForFirstChild('changeValue');
    },3000)
  },[])

  return (
    <>
      <FirstChild value={valueForFirstChild} />
      <SecondChild value={handleClick}
    </>
  );
}
function FirstChild ({value}) {
  return <div>{value}</div>;
}
function SecondChild ({onClick}) {
  return (
    <div onClick={onClick}>
      {Array.from({length:1000}).map((_, idx)=>(
        <GrandChild key={idx + 1} order={idx} />
      ))}
    </div>
  );
}

Parent 컴포넌트 내부에 FistChild컴포넌트와 SecondChild컴포넌트가 있다. 그리고 Parent에 state를 선언하고 FirstChild에 prop으로 state를 전달해주고, SecondChild에는 handleClick만 전달한다.
그리고 SecondChild에는 약 1000개의 자식을 갖고있고 자식들은 SecondChild 기준으로 몇 번째 자식 컴포넌트인지를 콘솔에 출력해 주는 기능을 갖고 있다.

동작은 이렇게 된다.

  1. Parent 컴포넌트 렌더링
  2. useEffect 내부 로직 실행
  3. setTimeout을 통해 state에 변화를 준다
  4. state가 변화되어 Parent컴포넌트가 리렌더링
  5. 이때 Parent는 함수로 재 실행이 된다.
  6. 그러므로 return에 담긴 두개의 자식 컴포넌트는 리렌더링 된다.

만약 효율을 따진다면, 이러한 리렌더링이 신경쓰일것이다.

그림과 같이 Parent와 First는 본인들이 사용하는 값의 변경이 생겨 렌더링이 당연하지만, Second 입장에서는 똑같은 정보를 보여주는데도 리렌더링을 한다. 그 뿐만 아니라 Second의 GrandChild 컴포넌트도 리렌더링이된다.

그렇다면 이를 방지하기위해 어떻게 해야 할까?

리렌더링 조건

먼저 리렌더링 조건이다.

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

그런데 Second 컴포넌트는 state 및 props가 변경이 없어 보이는데도 리렌더링이 되었다.
이유는 매번 Parent 컴포넌트가 리렌더링 될 때마다 handleClick이라는 함수가 재성성 되고 이전과 현재 함수는 다른 참조값을 갖게되어 그렇다.

결국 props로 handleClick 함수를 받고 있으므로 props가 변경되어 리렌더링 되는것

함수의 참조값 유지하기

그렇다면 함수의 참조값을 그대로 사용한다면 props는 변경이 없으므로 리렌더링이 일어나지 않을것이다. 그방법으로

useCallback

useCallback은 함수를 메모이제이션 해주는 hook이다.

메모이제이션
기존에 수행한 연산의 결과값을 저장하고 필요할 때 재사용하는 기법

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

의존성 배열이 변하지 않는 이상 컴포넌트가 리렌더링 될 때마다 변수에 같은 함수가 할당 된다.

function Parent() {
  const [valueForFirstChild, setValueForFirstChild] = useState(null);

  const handleClick = useCallback(() => {},[]);

  useEffect(()=>{
    setTimeout(()=>{
      setValueForFirstChild('changeValue');
    },3000)
  },[])

  return (
    <>
      <FirstChild value={valueForFirstChild} />
      <SecondChild value={handleClick}
    </>
  );
}

이제 handleClick에 useCallback훅을 적용해보자 결과는...여전히 똑같았다.

왜 그럴까?

function Parent() {
  const [valueForFirstChild, setValueForFirstChild] = useState(null);
  const handleClick = useCallback(() => {},[]);

  useEffect(()=>{
    setTimeout(()=>{
      setValueForFirstChild('changeValue');
    },3000)
  },[])

  return React.createElement(
  	React.Fragment,
    null,
    React.createElement(FirstChild, {
    	value: valueForFirstChild,
    });
    React.createElement(SecondChild, {
    	onClick: handleClick,
    });
  );
}

Parent를 babel로 컴파일한 결과 코드이다.
각 자식을 표현하는 컴포넌트는 React.createElement였다.

React.createElement
새로운 리액트 앨리먼트를 생성해서 반환해 준다.

컴포넌트가 호출이 되면 계층으로 구성된 react.createElement를 순차적으로 호출한다.

다시 돌아와서, Parent가 호출되면 그 내부의 createElement가 실행이 되는 것이다. 그래서 useCallback을 사용해 주었음에도 리렌더링이 되었다.

그렇다면 useCallback은 렌더링 최적화에 아무런 소용이 없을까? 아니다 있다.

리액트 렌더링 과정

  1. Render Phase

렌더페이즈에서는 컴포넌트를 호출하여 react Element를 반환하고 새로운 가상 DOM을 생성해준다.
만약 이번이 첫 번째 렌더링이 아니면 재조정 과정을 거친 후 Real DOM에 변경이 필요한 목록들을 체크한다.

재조정
이전 가상 DOM과 현재 DOM을 비교하는 과정

  1. Commit Phase

Render Phase가 끝난 후 존재한다.
이전 페이즈에서 체크해놨던 변경이 필요한 부분을 Real DOM에 반영해주는 단계다. 만약 변경이 필요한 부분이 없다면 이 페이즈는 생략된다.

결론
useCallback은 render페이즈는 실행되지만 props를 이전과 같게 유지해서 commit페이즈는 실행되지 않았다.

그렇다면 이번엔 Render페이즈 조차 막아보자.

React.memo

전달받은 props가 이전 props와 비교했을 때 같으면 컴포넌트의 리렌더링을 막아주고 마지막으로 렌더링 된 결과를 재사용하는 고차 컴포넌트이다.

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

다만 props를 비교할때 얕은 비교를 사용한다.

얕은비교
원시타입 데이터의 경우 값이 다른지 비교하고
참조타입 데이터는 참조값이 같은지 비교

이제 SecondChild컴포넌트에 적용해보자.

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

export default React.memo(SecondChild);

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

객체를 props로 넘겨주면?

앞서 React.memo는 얕은비교를 한다고 했다.
그러면 참조형 데이터인 객체를 prop으로 받으면 어떻게 될까?

function Parent() {
  const [valueForFirstChild, setValueForFirstChild] = useState(null);

  const item = {
  	name: 'Thinkpad',
    price: '1,000,000',
  };

  useEffect(()=>{
    setTimeout(()=>{
      setValueForFirstChild('changeValue');
    },3000)
  },[])

  return (
    <>
      <FirstChild value={valueForFirstChild} />
      <SecondChild item={item}
    </>
  );
}
function SecondChild ({item}) {
  return (
    <div>
      {item.name}
      {item.price}
      {Array.from({length:1000}).map((_, idx)=>(
        <GrandChild key={idx + 1} order={idx} />
      ))}
    </div>
  );
}

export default React.memo(SecondChild);

결과는, 이제는 리렌더링이 된다.
그 이유는,

  1. item이라는 객체가 매번 생성(참조값이 바뀜)
  2. secondChild 컴포넌트에게 매번 다른 참조값을 가진 item을 props로 전달
  3. React.memo가 의도대로 작동하지 않음

이것마저도 막고자 한다면...

useMemo

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

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

그러면 또 적용해보자.

function Parent() {
  const [valueForFirstChild, setValueForFirstChild] = useState(null);

  const item = {
  	name: 'Thinkpad',
    price: '1,000,000',
  };
  
  const memoizedItem = useMemo(() => item,[]);

  useEffect(()=>{
    setTimeout(()=>{
      setValueForFirstChild('changeValue');
    },3000)
  },[])

  return (
    <>
      <FirstChild value={valueForFirstChild} />
      <SecondChild item={item}
    </>
  );
}

이제 참조값도 메모이제이션이 되어 리렌더링이 일어나지 않는다.

렌더링 최적화 의문점

1. 무작정 사용해주는 것이 맞을까?

지금까지 여러 훅을 사용했는데 이것들을 무분별하게 사용하면 어떻게 될까? 좋을수도 나쁠수도 있다. 다만 훅 안에 어쨋든 로직을 구성해줘야 해서 하나하나가 모두 비용이다.
또, 더 안좋은 사용자 경험을 주기도 해서 적절하게 사용할 수 있도록 생각하는것이 좋을것이다.

2. 근본적인 코드를 먼저 개선하자

렌더링 최적화의 가장 좋은 방법은 불필요한 렌더링이 일어나지 않도록 처음부터 코드를 작성하는 것이 중요하다.
근본적인 원인을 제거하지 않고 도구 사용에 의존하면 나중에 큰 문제가 야기 될수도 있으니 처음부터 코드를 잘 작성하자.

근본적 코드 개선 예시

function Component() {
  const forceUpdate = useForceUpdate();

  return (
    <>
      <button value={forceUpdate}>force</button>
      <Consoler value="fixedValue" />
    </>
  );
}

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

이를 훅을 쓰지않고 리팩토링하면,

function Component({ children }) {
  const forceUpdate = useForceUpdate();

  return (
    <>
      <button value={forceUpdate}>force</button>
      {children}
    </>
  );
}

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

이렇게 children으로 받게 만들고 babel파싱을 해보면 더이상 createElement의 영향을 받지 않는 모습을 볼수있다.

마무리

미리 최적화 하지 말자, 필요할 때 하자.

진짜 명강의 였다. 생각할게 많았다.

profile
Frontend

0개의 댓글