본문 바로가기

React

[ React ] 재조정(Reconciliation)과 Key 사용 이유

반응형

배경

리액트는 가상돔을 저장하고 있다가 DOM에 변화가 생기면 변경된 부분만 동기화시키는 방식으로 렌더링을 진행한다. 공식문서에는 가상돔에 대해 아래와 같이 기술하고 있다.

 

Virtual DOM (VDOM)은 UI의 이상적인 또는 “가상”적인 표현을 메모리에 저장하고 ReactDOM과 같은 라이브러리에 의해 “실제” DOM과 동기화하는 프로그래밍 개념입니다. 이 과정을 '재조정(Reconciliation)' 이라고 합니다.

 

 

위와 같이 리액트는 뷰(HTML)에 변화가 있을 때, 구 가상돔과 새 가상돔을 비교하여 변경된 내용만 DOM에 적용한다. 그리고 이러한 비교 과정을 ‘재조정(Reconciliation)'이라고 한다. DOM은 트리 자료구조를 가지므로 재조정은 두 트리간의 차이를 비교하는 과정으로 볼 수 있다.

 

그런데 지금까지 알려진 ‘한 트리를 다른 트리로 변환하기 위한 최소한의 연산을 제공하는 알고리즘은 O(n^3)의 시간복잡도를 가진다고 한다. 이 알고리즘을 React에서 사용하면 1000개 요소를 표시하는데 무려 10억번의 비교가 필요하다. 그래서 당연히 정석적인 알고리즘은 사용하지 않는다.

 

리액트는 그 대신 아래 두 가지 가정에 따른 휴리스틱 알고리즘을 채택했는데, 이를 통해 시간복잡도를 O(n)으로 낮췄다. 아래는 리액트 공식문서에서 그대로 가져온 것이다.

 

  1. 다른 타입의 두 요소는 서로 다른 트리를 생성합니다.
  2. 개발자는 key prop을 이용해 다른 렌더링 사이에서 안정적인 자식 요소에 대한 힌트를 얻을 수 있습니다.

비교 알고리즘 (Diffing Algorithm)

두 개의 트리를 비교할 때, React는 두 엘리먼트의 루트(root) 엘리먼트부터 비교한다. 이후의 동작은 루트 요소의 타입에 따라 달라진다. 비교하는 두 요소의 타입이 다를 때와, 같을 때로 나눌 수 있다.

 

 

1. DOM 요소 타입이 다른 경우

 

비교하는 두 루트 요소의 타입이 다르면, 리액트는 이전 트리를 버리고 완전히 새로운 트리를 구축한다. 트리를 버릴 때 이전 DOM 노드들은 모두 파괴되고, 새로운 DOM 노드들이 DOM에 삽입된다. 이때 이전 트리와 연관된 모든 state는 사라진다. 루트 요소 하위의 모든 컴포넌트도 언마운트되고 state까지 사라진다.

 

예를 들어 아래처럼 div에서 span으로 바뀌었다고 가정하면, 이전 Counter는 사라지고, 새로 다시 마운트 된다.

 

<div>
  <Counter />
</div>

<span>
  <Counter />
</span>

 

 

2. DOM 요소 타입이 같은 경우

 

React는 두 요소의 속성을 확인하여, 동일한 내역은 유지하고 변경된 속성들만 갱신한다. 예를 들어 아래 코드에서 리액트는 color 속성만 수정한다.

 

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

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

 

이렇게 DOM 노드의 처리가 끝나면, React는 해당 노드의 자식들을 재귀적으로 처리한다.

 

자식에 대한 재귀 처리를 할 때, 리액트는 기본적으로 동시에 두 리스트를 순회하면서 차이점이 있는지 검사하고 변경을 생성한다. 아래와 같이 자식의 끝에 요소를 추가하면, 두 트리 사이의 변경은 문제없이 잘 작동한다.

 

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

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

 

동일 인덱스로 순회하면 first, second가 일치하는 것을 확인할 수 있고, thrid가 추가된 것을 쉽게 파악할 수 있다. 따라서 첫번째, 두번째 요소는 그대로 놔두고, 세 번째 자식만 추가해주면 된다. 그런데 아래와 같이 첫번째 인덱스에 요소가 추가되면 트리 변환 작업의 성능이 아주 나빠진다.

 

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

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

 

첫번째 요소부터 일치하지 않으므로 리액트는 다른 타입으로 판단해 모든 자식을 변경한다. 사실은 한 요소만 추가된 것인데 전체 자식을 변경하는 것이다! 이는 성능적인 측면에서 심각한 낭비다.

 

리액트에서 key의 역할

아까와 같은 상황에서 key를 사용하면 성능 문제를 해결할 수 있다. 자식들이 key를 가지고 있다면, 리액트는 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>

 

이제 리액트는 자식 요소들을 인덱스로 순회하지 않고, key 속성으로 어떤 요쇼가 추가 또는 삭제 되었는지 쉽게 알 수 있다. 위 예제에서는 key가 2014인 요소만 추가되었다. 따라서 리액트는 key가 2014인 요소를 추가해주고, 나머지 요소는 이동시켜주기만 하면 된다.

 

그러면 key 값으로 인덱스를 사용하면 어떻게 될까?

 

만약에 요소들이 재배열되지 않는다면 문제 없지만, 재배열되는 경우 문제가 발생할 수 있다.

 

왜? 인덱스를 key로 사용하면 항목의 순서가 바뀔 때 key 또한 바뀌기 때문이다. 즉, key값이 요소의 식별자로서의 역할을 하지 못하기 때문이다. 아래 예제를 보자.

 

 

 

 

코드를 간단하게 설명하자면 요소가 추가될 때마다 몇번째 요소인지가 id 값으로 저장된다. 따라서 요소 추가 버튼을 눌렀을 때 표시되는 숫자는 요소의 실제 id 값이다. 초기 key 값은 인덱스로 설정되어 있으며 이는 'id를 key로 설정' 버튼을 통해 변경할 수 있다. 버튼을 조작해보면서 차이를 살펴보자. 

 

index를 key로 설정하는 경우 정렬을 해도 input 박스의 위치는 변함이 없다. 이는 key값이 인덱스와 같으므로 요소의 순서 또한 인덱스 순서로 항상 고정되기 때문이다. 헷갈리지 말아야 할 것은 key의 위치는 고정이지만 내용물은 바뀌기 때문에 화면에 표시된 id 값은 바뀐다.

 

반면에 id를 key로 설정하는 경우 정렬을 하면 input 요소도 같이 정렬되는 것을 확인할 수 있다. 이는 key가 고정된 고유 id를 가지게 되면서 key값 자체가 순서가 되기 때문이다. 

 

위 예제의 경우 'key값이 요소의 위치를 결정한다'로 이해하면 좀 쉽다. 요소의 위치와 상관없이 key 값은 상항 동일해야 그 역할을 제대로 할 수 있다. 인덱스를 key로 사용하면 이 규칙을 위반하기 때문에 앱이 의도한 대로 동작하지 않을 수 있다. 

 

 

key는 오직 형제 사이에서만 유일하면 되고, 전역에서 유일할 필요는 없다. key의 존재 이유가 두 트리의 자식 요소를 비교하여 변경된 부분을 효율적으로 찾기 위함이기 때문이다. 요소 검사가 끝나면 key는 그 역할을 다 한 것이다. 

 

 

 

<출처>

https://ko.reactjs.org/docs/reconciliation.html  

 

 

반응형