리액트 재조정(Reconciliation)

React에서 diff 알고리즘을 만들기 위해 고려한 재조정 알고리즘을 정리해보았다.

선행 개념

리액트 재조정에 대한 설명 전에 간단이 알아두어야 할 용어이다.

엘리먼트 (Element)

  • 실제 화면에 랜더링할 DOM 노드의 정보이다.
  • 즉 js의 불변객체이다.
  • 일반적으로 jsx로 적혀지고 아래와 같이 저장된다.
1
2
3
4
5
<div clasName="hi">
<div>
<div></div>
</div>
</div>

다음과 같이 type(문자열,컴포넌트 함수/클래스)과 props(객체)필드로 표현된다.

1
2
3
4
5
6
7
8
9
{
type:"div"
props:{
className:'user_info',
children:[
...
]
}
}
  • 엘리먼트들로 이뤄진 트리를 엘리먼트 트리라고 부르며, 이것이 메모리 상에만 존재하는 가상 DOM(virtual DOM)이다.
  • React가 실제로 화면에 랜더링하는 대상이다.

컴포넌트 엘리먼트 (Component Element)

  • type이 컴포넌트 클래스/함수(대문자로 시작)인 엘리먼트이다.

  • 사용자가 직접 정의한 컴포넌트이며 입력으로 props를 받으면 랜더링할 엘리먼트 트리를 반환한다.

  • 위 엘리먼트 트리의 노드는 다른 DOM 엘리먼트나 컴포넌트 엘리먼트들로 이루어진다.

  • React는 랜더링을 위해서 모든 컴포넌트 엘리먼트들에게 어떠한 엘리먼트를 반환해야 하는지 묻는다.

  • 클래스형 컴포넌트

    • 지역 상태를 가질 수 있다.
    • 인스턴스에 대응하는 DOM노드가 생성, 수정, 삭제될 때의 동작을 제어할 수 있다.(생명주기,life cycle)
  • 함수형 컴포넌트

    • render()함수만 가지는 클래스형 컴포넌트와 동일하다.

엘리먼트 관련 핵심 아이디어

  • 엘리먼트들끼리는 서로 섞이거나 중첩되는것이 가능하다.
  • 즉 다른 컴포넌트를 정의하는데에 있어서 재사용이 가능하다.
  • 컴포넌트들의 분리를 가능하게 하여 복잡한 UI를 쉽게 구성할 수 있다.

컴포넌트 인스턴스(Component Instance)

  • 클래스의 인스턴스이다.
  • 클래스 내부에서 this키워드를 통해 참조하는 대상이다.
  • 지역상태를 저장하고 생명주기(life cycle), 이벤트에 대한 반응을 제어할 때 유용하다.

DOM 엘리먼트(DOM Element)

  • 말 그대로 DOM에 존재하는 엘리먼트들이다.
  • type이 태그 이름에 해당하는 문자열(소문자로 시작)하는 엘리먼트이다.
  • props 정보를 통해 해당 노드의 attribute들을 표현한다.

동기

state나 props가 갱신되면 render() 함수는 새로운 React 엘리먼트 트리를 반환한다.
React는 만들어진 트리에 맞게 효과적으로 UI를 갱신할 필요가 있다.

하나의 트리를 가지고 다른 트리로 변환하기 위한 연산 수를 구하는 알고리즘 문제를 푸는 해결책은
주로 O(n3)의 복잡도를 가진다.

React는 더욱 효율적인 계산을 위해 두 가지 가정을 기반하여 O(n) 복잡도의 휴리스틱 알고리즘을 구현하였다.

  1. 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.

  2. 개발자가 key prop를 통해 여러 랜더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할 지 표시해 줄 수 있다

    • 이는 key element에 index를 쓰는것이 지양 되는 이유이다.

비교 알고리즘(Diffing Algorithm)

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

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

두 루트 엘리먼트의 타입이 다르면 React는 기존의 트리(루트 엘리먼트와 하위 컴포넌트들)를 버리고 새로운 트리를 구축한다.

ex) <a>에서 <img>로 바뀌는 경우

트리를 버릴 때 이전 DOM 노드들은 모두 파괴된다.

  1. 컴포넌트 인스턴스는 componentWillUnmount()가 실행된다.
  2. 새로운 트리가 만들어진다.
  3. 새로운 트리가 DOM에 삽입될 때 UNSAFE_componentWillMount()가 실행되고 이후 componentDidMount()가 실행된다.
  4. 이전 트리와 관련된 모든 state가 사라진다.

또한 루트 엘리먼트 아래의 모든 컴포넌트도 언마운트 되고 그 state도 사라진다.

아래와 같은 비교에선 이전 Counter는 사라지고 새로 다시 마운트가 된다.

1
2
3
4
5
6
7
<div>
<Counter/>
</div>

<span>
<Counter/>
</span>

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

같은 타입의 React DOM 엘리먼트를 비교할 때 React는 두 엘리먼트의 attribute를 확인하여 변경된 attribute만 갱신한다.

아래의 비교에선 React는 현재 DOM 노드상의 className만 수정한다.

1
2
3
<div className="before" title="stuff" />

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

style이 갱신될 떄 또한 React는 변경된 attribute만을 갱신한다.

아래의 비교에선 React는 현재 DOM 노드 상의 style에서 color만들 수정한다.

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

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

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

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

이 때 해당 인스턴스의 UNSAFE_componentWillReceiveProps(),UNSAFE_componentWillUpdate(),componentDidUpdate를 호출한다.

다음으로 render() 메서드가 호출되고 비교 알고리즘이 이전 결과와 새로운 결과를 재귀적으로 처리한다.

자식에 대한 재귀적 처리

DOM 노드들의 자식들을 재귀적으로 처리할 때 React는 기본적으로 동시에 두 리스트를 순회하고 차이점이 있으면 변경을 생성한다.

아래의 변경은 마지막 요소에 <li?>third</li>를 추가하였다.
first가 일치함을 확인하고 second가 일치하는 것을 확인 후 third를 트리에 추가한다.

1
2
3
4
5
6
7
8
9
10
<ul>
<li>first</li>
<li>second</li>
</ul>

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

하지만 이 경우 리스트의 맨 앞에 엘리먼트를 추가하는 경우 성능이 좋지 않다.
아래의 경우 처음과 마지막까지 모두 순회하여 변경하거나 자식 엘리먼트 모두를 변경해야한다.
이는 성능저하의 요인이 된다.

1
2
3
4
5
6
7
8
9
10
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>

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

keys

이러한 문제를 해결하기 위해 React에선 key 속성을 지원한다.
자식들이 key를 가지고 있다면 React는 key를 통해 기존 트리와 이후 트리의 자식들이 일치하는지 확인한다.

아래의 변경은 key를 추가하여 엘리먼트의 맨 앞에 자식을 추가하는 경우도 효율적으로 작동하도록 하였다.

1
2
3
4
5
6
7
8
9
10
<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>

이것이 배열의 index가 key로 권장되지 않는 이유이다.
항목들이 재배열되지 않을 경우 유용하지만 삭제와 재배열이 필요할 경우 비효율적으로 동작한다.

컴포넌트 인스턴스는 key를 기반으로 갱신되고 재사용되기 떄문에 인덱스를 key로 사용할 경우 항목의 순서가 바뀌었을 때 key또한 바뀌어 state가 정상적으로 작동하지 않을 수 있다.

또한 key는 형제 사이에서만 유일하면 된다.

고려사항

위의 재조정 알고리즘(Reconciliation Algorithm)이 랜더링 전후에 변경된 부분만을 변경하는 알고리즘이다.

react는 휴리스틱에 의존하고 있기 때문에 다음과 같은 것을 고려해야한다.

  1. React는 다른 컴포넌트 타입의 경우 전부 버리고 교체해버린다.
    때문에 비슷한 결과물을 출력하는 두 컴포넌트를 작성해야 한다면 같은 타입으로 만드는 것이 더 나을 수도 있다.

  2. key는 변하지 않고 예상 가능하며 유일해야한다.
    그렇지 않을 경우 성능이 나빠지거나 자식 컴포넌트의 state가 유실될 수 있다.

느낀 점

불변성을 지키기에 용이하고 값을 직접 변경하지 않아 가능한 방식이라고 느꼈다.

또한 직접적인 DOM조작이 지양되는 이유라고 생각했다.

ref

리액트 엘리먼트
리액트 공식문서

댓글