React의 Reconciliation과 최적화 - (1)

shorecrab·2022년 6월 10일
0

우리가 React에서 상태를 변경하면 Reconciliation이 일어나게 된다. 이 때 React는 변화 전, 후의 DOM과 컴포넌트를 비교해서 어떤 부분이 변경되었는지를 검사한다. 이 과정을 Diffing이라고 한다. 오늘은 React 내부적으로 Diffing이 어떻게 일어나는지에 대해서 공식문서를 통해 알아보고, 최적화 과정까지 한번 살펴보도록 할 것이다.

Diffing

React는 내부적으로 Virtual DOM을 유지하고 있다. 이를 통해 과거의 DOM Tree와 현재의 DOM Tree를 비교하여 필요한 부분만 렌더링한다. 그런데 이 비교 알고리즘은 가장 나은 성능을 보이는 알고리즘이 O(n3)의 성능을 보인다고 한다.
이 때문에 React에서는 아래 2가지 가정을 통해 휴리스틱한 O(n) 알고리즘을 구현했다.

  1. 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만든다.
  2. key prop을 통해 명시적으로 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.

UI 갱신 과정

공식문서에서는 실제 어떤 조건에서 다시 렌더링이 일어나는지도 명시해주고 있는데, 3가지의 경우를 나누어서 설명하고 있다.

1) 엘리먼트의 타입이 다른 경우

두 루트 엘리먼트의 타입이 다르면, React는 이전 트리를 버리고 완전히 새로운 트리를 구축합니다.

쉽게 말해서 <div><p>로 변하는 등의 변화가 있다면, 해당 지점에서 트리를 잘라내고 새로운 트리를 만들어낸다. 이 때 파괴되는 DOM 노드에 대해서는 ComponentWillUnmount()가 호출된다. 그리고 새롭게 트리가 구축되는 DOM 노드에 대해서는 ComponentDidMount()가 호출된다.

훅으로 따지자면, DOM 노드가 파괴될 때 useEffect()의 clean-up 함수가 호출될 것이고, 트리를 구축할 때는 해당 DOM 노드에 대해서 useLayoutEffect()가 호출될 것이다.

<div>
  <Counter />
</div>

<span>
  <Counter />
</span>

위 코드를 봤을 때, <div><span>으로 변경되었으므로, 내부의 Counter가 바뀌지 않더라도 전체 트리가 변경될 것이라는 것을 알 수 있다.

(DOM 엘리먼트와 컴포넌트 엘리먼트 모두에 해당하는 설명이다.)

2) DOM 엘리먼트의 타입이 같은 경우

같은 타입의 두 React DOM 엘리먼트를 비교할 때, React는 두 엘리먼트의 속성을 확인하여, 동일한 내역은 유지하고 변경된 속성들만 갱신합니다.

아래 예시를 보면, className이 before에서 after로 변경되었다. 그런데 <div>로 엘리먼트 타입이 같기 때문에 트리를 파괴하지 않고 className속성만 업데이트 하게 된다. 그리고 이러한 변경 후에 재귀적으로 자식들을 다시 검사하게 된다.

<div className="before" title="stuff" />

<div className="after" title="stuff" />

3) 같은 타입의 컴포넌트 엘리먼트

컴포넌트가 갱신되면 인스턴스는 동일하게 유지되어 렌더링 간 state가 유지됩니다. React는 새로운 엘리먼트의 내용을 반영하기 위해 현재 컴포넌트 인스턴스의 props를 갱신합니다.

말 그대로 상태 변경이 있는 컴포넌트에 대해서 update가 이루어지고, 그 자식들에 대해서 props를 갱신한다는 것이다. 그런데 설명을 보면 props를 넘겨주지 않는 경우에 대한 설명이 없어서, 조금 모호하다고 생각되어 아래 예제를 준비했다. (공식 문서에는 예제가 없음)

// App.js
import Counter from "./counter";
import { useState } from "react";

export default function App() {
  const [dummy, setDummy] = useState(false);
  const handleClick = (e) => setDummy(!dummy);

  console.log("App rendered");

  return (
    <>
      <Counter />
      <div>
        <button onClick={handleClick}>dummy</button>
      </div>
    </>
  );
}
// counter.js
import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);
  const handleClick = (e) => setCount(count + 1);

  console.log("Counter rendered");

  return (
    <>
      <div>{count}</div>
      <button onClick={handleClick}> add 1 </button>
    </>
  );
}

위 코드를 실행했을 때, 아래처럼 App 내부에 Counter가 있는 모습을 볼 수 있다. 여기서 add 1 버튼을 누르게 된다면 Counter가 업데이트 될 것이고, dummy 버튼을 누르게 된다면 App이 업데이트 될 것이다. 그리고 버튼을 눌렀을 때 어떤 컴포넌트가 렌더링 될 것인지를 확인하기 위해서 내부에 로그를 찍을 수 있도록 했다.

먼저 add 1 버튼을 눌러보자.

Counter 컴포넌트만 다시 렌더링 되는 것을 확인할 수 있다. state가 변경된 컴포넌트의 부모에 대해서는 UI update를 하지 않는다는 것을 알 수 있다.

이번에는 dummy 버튼을 눌러보자.

AppCounter가 같이 업데이트 되었다. 자식 컴포넌트에서는 전혀 변경이 없었는데, 부모 컴포넌트의 상태가 변화하면서 같이 렌더링 된 것이다. props를 전혀 넘겨주지 않았는데도 이러한 변경이 생겼다.
즉, 자식 컴포넌트를 굳이 update할 필요가 없는데, update를 하게 되는 것이다. 지금은 자식 컴포넌트가 간단하기 때문에 큰 문제는 없지만, 대형 서비스에서는 최상위 컴포넌트의 상태가 변경될 때 페이지 전체에 대한 React DOM Tree가 다시 만들어지는 등의 문제가 있을 것이다.

최적화

이러한 일을 방지하려면 최적화가 필요하다. 자식 컴포넌트에 대해서 렌더링하지 않아도 된다는 것을 React에게 명시적으로 알려주는 것이다. ShouldComponentUpdate() 생명 주기 함수나, React.memo()함수를 통해서 최적화를 할 수 있다. 클래스형 컴포넌트를 잘 다루지 않기 때문에, React.memo()를 사용하는 방법만 보려고 한다.

React.memo()

Counter 컴포넌트를 아래와 같이 변경했다. 눈여겨볼 점은 React.memo()를 사용해서 메모이제이션된 컴포넌트를 export하는 것이다.

// counter.js
import React, { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);
  const handleClick = (e) => setCount(count + 1);

  console.log("Counter rendered");

  return (
    <>
      <div>{count}</div>
      <button onClick={handleClick}> add 1 </button>
    </>
  );
}

const MemoizedCounter = React.memo(Counter);
export default MemoizedCounter;

이제 다시 dummy 버튼을 눌러보자.

이번에는 App만 렌더링 되는 것을 확인할 수 있다. React.memo()에 대한 공식문서 설명을 보자.

컴포넌트가 동일한 props로 동일한 결과를 렌더링해낸다면, React.memo를 호출하고 결과를 메모이징(Memoizing)하도록 래핑하여 경우에 따라 성능 향상을 누릴 수 있습니다. 즉, React는 컴포넌트를 렌더링하지 않고 마지막으로 렌더링된 결과를 재사용합니다.

자식 컴포넌트의 마지막 렌더링 결과를 저장해놓고 사용하기 때문에, DOM Tree에서 Counter의 변경이 일어나지 않았던 것이다.

주의점

실제로 React.memo()를 사용할 때는 주의할 점이 많다. 우선 공식문서에 따르면,

React.memo는 props 변화에만 영향을 줍니다. React.memo로 감싸진 함수 컴포넌트 구현에 useState, useReducer 또는 useContext 훅을 사용한다면, 여전히 state나 context가 변할 때 다시 렌더링됩니다.

또한, props가 자주 변경된다면 메모이제이션을 하는 의미가 없다. 오히려 사용했을 때 성능이 나빠질 수도 있다. 이 부분에 대해서도 할 말이 많은데, 여기서 모두 다루는 것은 글이 길어질 것 같아 추후에 따로 올리도록 하겠다.

이어서...

오늘은 Diffing과 React.memo()에 대해서 알아봤다. 사실 아직 모든 정보를 다루지는 않아서 후속 포스팅에서 추가적으로 부족한 부분에 대해 설명하려고 한다.

profile
주니어 프론트엔드 개발자!

0개의 댓글