비교조정 (Reconciliation)

React는 선언적 API를 제공하기 때문에 갱신이 될 때에 정확히 무엇이 바뀌었는지를 걱정할 필요가 없습니다. 이는 어플리케이션 작성을 더욱 쉽게 만들어주지만, React 내부에서 어떤 일이 일어나고 있는지는 명확히 눈에 보이지 않습니다. 이 글에서는 우리가 React의 “비교” 알고리즘을 만들 때 어떤 선택을 했는지를 소개합니다. 이 비교 알고리즘 덕분에 컴포넌트의 갱신이 예측 가능해지면서도 고성능 앱이라고 불러도 손색 없을 만큼 충분히 빠른 앱을 만들 수 있습니다.

동기

여러분이 React를 사용하면서, ’render() 함수는 React 엘리먼트의 트리를 만들어주는 것이다’라고 생각하게 되는 시점이 있을 것입니다. 다음 번의 state 혹은 props 변경 시점에, render() 함수는 다르게 생긴 React 엘리먼트 트리를 반환할 것입니다. 이 때 React 입장에서는 방금 만들어진 트리에 부합하도록, 기존 UI를 효율적으로 갱신하는 방법을 알아내야 할 필요가 있습니다.

이러한 알고리즘 문제, 즉 하나의 트리를 다른 트리로 변형시키는 가장 작은 조작 방식을 알아내는 문제에 대한 몇 가지 일반적인 해결책이 있습니다. 하지만, 최첨단의 알고리즘도 n개의 엘리먼트를 갖는 트리에 대해 O(n3)의 복잡도를 가집니다.

만약 이 알고리즘을 그대로 React에 적용하면, 1000개의 엘리먼트를 표시하는 작업은 10억 번의 비교 연산을 필요로 하게 됩니다. 이는 너무 비싼 연산입니다. 대신, React는 두 가지 가정에 기반해 O(n) 복잡도의 휴리스틱 알고리즘을 구현했습니다:

  1. 다른 타입을 가진 두 엘리먼트는 다른 트리를 만들어 낼 것이다.
  2. 개발자가 제공한 key prop을 이용해, 여러 번의 렌더링 속에서도 변경되지 말아야 할 자식 엘리먼트가 무엇인지를 알아낼 수 있을 것이다.

실제로 거의 모든 사용 사례에 대해 이 가정들이 들어맞습니다.

비교 알고리즘 (Diffing Algorithm)

두 트리를 비교할 때 React는 가장 먼저 두 루트 엘리먼트를 비교합니다. 이후의 동작은 루트 엘리먼트들의 타입에 따라 다릅니다.

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

루트 엘리먼트들의 타입이 다르다면, React는 이전 트리를 버리고 트리를 완전히 새로 구축합니다. <a>에서 <img>로, 혹은 <Article>에서 <Comment>로, 혹은 <Button> 에서 <div>로 바뀌는 것 모두가 트리 전체를 새로 구축하는 결과를 낳습니다.

트리를 버릴 때, 이전 DOM 노드들은 모두 파괴됩니다. 또한 컴포넌트 인스턴스의 componentWillUnmount() 라이프 사이클 훅이 실행됩니다. 새 트리가 구축될 때, 새 DOM 노드들이 DOM 안에 삽입됩니다. 그에 따라 컴포넌트 인스턴스의 componentWillMount() 훅이 실행되고, 그 다음 componentDidMount() 훅이 실행됩니다. 이전 트리에 연결되어 있던 모든 state가 유실됩니다.

루트 엘리먼트 아래에 있는 모든 컴포넌트가 언마운트되고 그 state 또한 파괴됩니다. 예를 들어, 아래와 같은 비교가 일어나면:

<div>
  <Counter />
</div>

<span>
  <Counter />
</span>

이전 Counter는 파괴되고 새 것이 다시 마운트될 것입니다.

같은 타입의 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는 color 스타일만을 수정하고 fontWeight는 수정하지 않습니다.

하나의 DOM 노드를 처리한 뒤에, React는 뒤이어 해당 노드의 자식들을 재귀적으로 처리합니다.

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

컴포넌트의 내용이 갱신될 때, 해당 인스턴스는 유지되고 그에 따라 state가 여러 렌더링에 걸쳐 유지될 수 있습니다. React는 새로운 엘리먼트의 내용을 반영하기 위해 컴포넌트 인스턴스의 props를 갱신합니다. 또한 그 인스턴스의 componentWillReceiveProps()componentWillUpdate()를 호출합니다.

다음으로, render() 메소드가 호출되고 비교 알고리즘은 이전 트리와 새로운 트리를 재귀적으로 처리합니다.

자식에 대한 재귀적 처리

DOM 노드의 자식에 대한 재귀적 처리가 이루어질 때, 별다른 설정을 해주지 않으면 React는 단순하게 두 자식 리스트를 동시에 순회하면서 차이가 발견될 때마다 변경을 가합니다.

예를 들면, 자식 리스트의 끝에 새 엘리먼트를 추가한 경우에는 두 트리 간의 변환이 우리가 기대한 대로 잘 동작합니다:

<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> 트리를 삽입합니다.

별다른 고민없이 구현하면, 리스트의 처음에 엘리먼트를 삽입하는 것의 성능은 좋지않은 성능을 내게 됩니다. 예를 들어, 아래의 두 트리 간 변환은 우리의 기대대로 동작하지 않습니다:

<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>을 온전히 유지시켜도 된다는 것을 깨닫지 못하고 모든 자식에 변경을 가할 것입니다. 이 비효율은 문제가 될 수 있습니다.

React는 이 문제를 해결하기 위한 방법으로 key 속성을 지원하고 있습니다. 만약 자식이 키를 갖고 있다면, React는 그 키를 이용해 원래 트리의 자식과 새 트리의 자식 간이 일치하는 지를 결정할 수 있습니다. 예를 들어, 우리의 비효율적인 예제에 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'라는 키를 가진 엘리먼트가 새롭게 생긴 것이라는 사실과, ’2015', '2016'의 키를 가진 엘리먼트는 그저 이동한 것이라는 사실을 알 수 있습니다.

실제로, 키로 사용할 값을 정하는 것은 어렵지 않습니다. 보통 여러분이 출력하려고 하는 엘리먼트는 식별자를 가지고 있을 것이고, 따라서 여러분의 데이터를 그대로 키로 사용할 수 있습니다:

<li key={item.id}>{item.name}</li>

만약 이러한 경우에 해당하지 않는다면, 여러분의 데이터 모델에 ID라는 새로운 속성을 추가하거나 혹은 데이터의 일부에 해시를 적용해서 키를 생성할 수 있습니다. 그 키는 오로지 형제 사이에서만 유일하면 되고, 전역에서 유일할 필요는 없습니다.

최후의 수단으로 배열의 인덱스를 키로 사용할 수 있습니다. 만약 배열이 다르게 정렬될 일이 없다면 이 방법도 잘 동작할 것이지만, 항목의 순서가 바뀌는 경우 비효율적으로 동작할 것입니다.

인덱스를 키로써 사용한 경우 배열 항목의 순서가 바뀌면 컴포넌트 state와 관련된 문제가 발생할 수 있습니다. 컴포넌트 인스턴스는 키에 어떤 값이 주어졌는지에 따라 갱신되기도 하고 재사용되기도 합니다. 만약 인덱스를 키로 사용하면, 항목의 순서가 바뀌었을 때 키 역시 바뀔 것입니다. 그 결과로, 컴포넌트의 상태가 엉망이 되거나 예기지 않은 방식으로 바뀔 수도 있습니다.

인덱스를 키로 사용해서 문제가 발생한 CodePen 예제를 확인해보세요. 그리고 인덱스를 키로 사용하지 않으면서도 앞에서 다뤘던 여러 문제를 어떻게 해결할 수 있는지를 이 예제를 통해서 알아보세요.

고려해야 할 점

비교조정 알고리즘이 구현 상의 세부사항이라는 것을 기억하세요. React가 앱 전체를 다시 렌더링할 수도 있지만, 최종적으로 출력되는 결과는 언제나 같을 것입니다. 좀 더 정확히 말하자면, 이 문맥에서의 ‘다시 렌더링’이란 모든 컴포넌트에 대해 render 를 호출하는 걸 의미하는 것이지 언마운트를 의미하는 것이 아닙니다. 즉, 앞서 설명했던 규칙에 따라 렌더링 전후에 변경된 부분만을 DOM 트리에 적용할 것입니다.

우리는 여러분들의 일반적인 사용 사례를 더 빠르게 만들기 위해 휴리스틱 알고리즘을 계속 다듬고 있는 중입니다. 현재의 구현체에서는, 한 서브트리가 그 형제들 사이에서 이동했다는 사실을 표현할 수 있지만, 아예 다른 곳으로 이동했다는 사실은 표현할 수 없습니다. 이 경우 알고리즘은 전체 서브트리를 다시 렌더링 할 것입니다.

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

  1. 알고리즘은 다른 컴포넌트 타입을 갖는 서브트리들의 일치 여부를 확인하지 않을 것입니다. 만약 여러분이 굉장히 비슷한 출력를 하는 두 컴포넌트 간에 교체를 하고 있다는 사실을 깨달았다면, 그 둘을 같은 타입으로 만드는 것이 더 나을 수도 있습니다. 우리는 실제 사용 사례에서 이 가정이 문제가 되는 경우를 아직 발견하지 못했습니다.

  2. 키로 사용할 값은 안정적이고, 예측 가능하며, 유일한 값이어야 합니다. Math.random()에 의해 생성된 값처럼 안정적이지 않은 값을 키로 사용하면 다수의 컴포넌트 인스턴스 혹은 DOM 노드가 불필요하게 재생성되어 성능이 나빠지거나 state가 유실되는 현상이 나타날 수 있습니다.