리액트 Reconciliation

률루랄라·2022년 6월 25일
0
post-thumbnail

render()함수는 브라우저에서 UI를 render 하려할 때 마다 불리우는 함수이다. 최상위 render 함수는 React element들의 트리를 재귀적으로 리턴하는 함수라고 볼 수 있다. 그 후 다음 렌더 cycle에서 해당 컴포넌트의 트리는 다시 생성된다(regenerated). 리액트는 위 두개의 트리를 diff (변경점을 찾는것)해야 하고 실제 브라우저 DOM에 해당 diff를 적용해야한다. 리액트가 어느 부분의 UI가 변경되어야 하는지 결정하기 위해 하나의 트리와 다른 트리의diff에 사용하는 알고리즘을 reconciliation이라고 한다. 📎React Internals (Part 1) - The Basic Concepts and the Prerequisites


1. Reconciliation

리액트는 매 업데이트마다 개발자가 정확히 무엇이 변경되는지 걱정할 필요없게 선언적 API를 제공한다. 이로 인해 어플리케이션 개발 (writing applications)은 엄청 쉬워졌지만 이것이 어떻게 React에 적용되었는지 (implemented, 주입되었는지) 명확하지 않다. 이 후의 글들은 high-performance app에서도 충분히 빠를 수 있게 컴포넌트 업데이트가 예측가능할 수 있도록 React의 diffing 알고리즘에 어떠한 결정을 내렸는지 설명할 것이다.


2. Motivation of reconciliation

우리가 React를 사용할 때, render() 함수는 React 요소의 트리를 생성하는 것으로 생각이 드는 순간이 있을 것이다. state나 props가 갱신되면 render() 함수는 새로운 React 엘리먼트 트리를 return 한다. 그 후 React는 가장 최신의 트리 (the most recent tree)와 일치하는 UI로 업데이트 하기 위해 효과적으로 (efficiently) 할 수 있는 방법을 찾아내야한다.

하나의 트리를 다른 트리로 변환하기 위해 최소한의 연산을 하는 알고리즘 문제를 풀기 위해 일반적인 해결책들이 있다. 그러나 이러한 최첨단의 알고리즘도 n개의 엘리먼트가 있는 트리에 대해 O(n3)의 복잡도를 가진다.

React에 이 알고리즘을 적용한다면, 1000개의 엘리먼트를 그리기 위해 10억 번의 비교 연산을 수행해야 한다. 너무나도 비싼 연산이기에 React는 대신, 두 가지 가정을 기반하여 O(n) 복잡도의 휴리스틱 알고리즘을 구현했습니다.

첫번째로, 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
두번째로, 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.

실제로 거의 모든 사용 사례에서 이 가정들은 들어맞았다.


3. The Diffing Algorithm: 비교 알고리즘

두 개의 트리를 비교(diffing) 할 때, React는 먼저 두 엘리먼트의 루트(root) 엘리먼트부터 비교한다. 이후의 동작은 루트 엘리먼트의 타입에 따라 달라진다.

3.1. DOM 엘리먼트의 타입이 다른 경우

두 루트 엘리먼트의 타입이 다른 모든 경우에는, React는 이전 트리를 버리고 처음부터 완전히 새로운 트리를 만든다. <a>에서 <img>로, <Article>에서 <Comment>로, 혹은 <Button>에서 <div>로 바뀌는 것 모두 트리 전체를 재구축하는 경우다.

트리를 버릴 때 이전 DOM 노드들은 모두 파괴되고 컴포넌트 인스턴스는 componentWillUnmount()가 실행된다. 새로운 트리가 만들어질 때, 새로운 DOM 노드들이 DOM에 삽입된다. 그에 따라 컴포넌트 인스턴스는 UNSAFE_componentWillMount()가 실행되고 componentDidMount()가 이어서 실행된다. 이전 트리와 연관된 모든 state는 사라진다.

루트 엘리먼트 아래의 모든 컴포넌트도 당연 모두 언마운트되고 그 state도 사라집니다. 예를 들어, 아래와 같은 비교가 일어나면,

<div>
  <Counter />
</div>

// when obove updated into below

<span>
  <Counter />
</span>

// then the explanation above the code block will be executed or getting into process

이전 Counter는 사라지고, 새로 다시 마운트가 된다.

3.2. DOM 엘리먼트의 타입이 같은 경우

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

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

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

위 두 엘리먼트를 비교할 때, React는 현재 DOM 노드 상에 className만 수정한다.
style이 갱신될 때, React는 또한 변경된 속성만을 갱신한다. 예를 들면,

<div style={{color: 'red', fontWeight: 'bold'}} />

<div style={{color: 'green', fontWeight: 'bold'}} />

위 두 엘리먼트 사이에서 변경될 때, React는 fontWeight는 수정하지 않고 color 속성 만을 수정한다.
DOM 노드의 처리가 끝나면, React는 이어서 해당 노드의 children을 재귀적으로 처리한다.

3.3. 컴포넌트 엘리먼트의 타입이 같은 경우

컴포넌트가 업데이트될 때, 인스턴스는 동일하게 유지되어 렌더링 간 state가 유지된다. React는 새로운 엘리먼트의 내용을 반영하기 위해 현재 컴포넌트 인스턴스의 props를 업데이트 하고 이때 해당 인스턴스의 UNSAFE_componentWillReceiveProps(), UNSAFE_componentWillUpdate(), componentDidUpdate를 호출합니다.
그 후, render() 메서드가 호출되고 비교 알고리즘이 이전 결과와 새로운 결과를 재귀적으로 처리합니다.

3.4. children에 대한 재귀적 처리

DOM 노드의 children에 대해 재귀적 처리를 할 때, 기본적으로 React는 list of children을 동시에 순회하고 차이점이 있으면 변경을 생성할 것이다.
예를 들어, children의 끝에 엘리먼트를 추가하면, 두 트리 사이의 변경은 잘 작동할 것이다.

<ul>
  <li>first</li>
  <li>second</li>
</ul>

<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

React는 두 트리에서 <li>first</li>가 일치하는 것을 확인하고, <li>second</li>가 일치하는 것을 확인합니다. 그리고 마지막으로 <li>third</li>를 트리에 추가한다.

하지만 위와 같이 단순하게 구현하면, 리스트의 맨 앞에 엘리먼트를 추가하는 경우 성능이 좋지 않을 것이다. 예를 들어, 아래의 두 트리 변환 예제는 형편없이 (poorly) 작동한다.

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

React는 <li>Duke</li><li>Villanova</li> 종속 트리를 그대로 유지하는 대신 모든 자식을 변경할 것이다. 이러한 비효율은 문제가 될 수 있다.

3.5. key

이러한 위의 문제를 해결하기 위해, React는 key 속성을 지원한다. 자식들이 key를 가지고 있다면, React는 key를 통해 기존 트리와 이후 트리의 자식들이 일치하는지 확인할 것이다. 예를 들어, 위 비효율적인 예시에 key를 추가하여 트리의 변환 작업이 효율적으로 수행되도록 수정할 수 있다.

<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

이제 React는 '2014' key를 가진 엘리먼트가 새로 추가되었고, '2015'와 '2016' key를 가진 엘리먼트는 그저 이동만 하면 되는 것을 알게 된다.
실제로, key로 사용할 값을 정하는 것은 어렵지 않다. 그리려고 하는 엘리먼트는 일반적으로 식별자를 가지고 있을 것이고, 그대로 해당 데이터를 key로 사용하면 된다.

이러한 상황에 해당하지 않는다면, 여러분의 데이터 구조에 ID라는 속성을 추가해주거나 데이터 일부에 해시를 적용해서 key를 생성할 수 있다. 해당 key는 오로지 형제 사이에서만 유일하면 되고, 전역에서 유일할 필요는 없다.

최후의 수단으로 배열의 인덱스를 key로 사용할 수 있다. 항목들이 재배열되지 않는다면 이 방법도 잘 동작할 것이지만, 재배열되는 경우 비효율적으로 동작할 것이다.

인덱스를 key로 사용 중 배열이 재배열되면 컴포넌트의 state와 관련된 문제가 발생할 수 있는데, 컴포넌트 인스턴스는 key를 기반으로 갱신되고 재사용된다. 인덱스를 key로 사용하면, 항목의 순서가 바뀌었을 때 key 또한 바뀌게 되고 그 결과, 컴포넌트의 state가 엉망이 되거나 의도하지 않은 방식으로 바뀔 수도 있다.


4. 고려 사항

reconciliation 알고리즘은 구현상의 세부사항일뿐이다. React는 항상 전체 앱을 재렌더링할 수도 있지만, 최종적으로 출력되는 결과는 항상 같을 것이다. 좀 더 정확히 말하자면, 여기서 말하는 재렌더링은 모든 컴포넌트의 render를 호출하는 것이지 React가 언마운트시키고 다시 마운트하는 것은 아니다. 즉, 앞서 설명했던 규칙에 따라 렌더링 전후에 변경된 부분만을 적용한다.

우리는 일반적인 사용 사례에서 더 빠르게 작동할 수 있도록 계속 휴리스틱 알고리즘을 개선하고 있다. 현재 적용된 방법은 한 종속 트리가 그 형제 사이에서 이동했다는 사실을 표현할 수는 있지만, 아예 다른 곳으로 이동했다는 사실은 표현할 수 없다. 알고리즘은 전체 종속 트리를 재렌더링할 것입니다.

React는 휴리스틱에 의존하고 있기 때문에, 휴리스틱이 기반하고 있는 가정에 부합하지 않는 경우 성능이 나빠질 수 있다.

알고리즘은 다른 컴포넌트 타입을 갖는 종속 트리들의 일치 여부를 확인하지 않는다. 매우 비슷한 결과물을 출력하는 두 컴포넌트를 교체하고 있다면, 그 둘을 같은 타입으로 만드는 것이 더 나을 수도 있다. React team은 실제 사용 사례에서 이 가정이 문제가 되는 경우를 발견하지 못했다.
key는 반드시 변하지 않고, 예상 가능하며, 유일해야 한다. 변하는 key(Math.random()으로 생성된 값 등)를 사용하면 많은 컴포넌트 인스턴스와 DOM 노드를 불필요하게 재생성하여 성능이 나빠지거나 자식 컴포넌트의 state가 유실될 수 있다.

profile
💻 소프트웨어 엔지니어를 꿈꾸는 개발 신생아👶

0개의 댓글