[React] 재조정(Reconciliation)이란?
리액트가 새 엘리먼트 트리와 이전 트리를 O(n)으로 비교하기 위해 두는 두 가지 가정과, 거기서 나오는 타입·key·위치 규칙을 정리한다.
📖 들어가며
리렌더링이 일어나면 리액트는 새로운 엘리먼트 트리(가상 돔)를 만들고 이전 트리와 비교한다. 이 비교로 "실제 DOM에서 무엇을 바꿔야 하는가"를 찾아내는 과정이 **재조정(Reconciliation)**이고, 렌더 단계 안에서 일어난다.
그런데 트리 두 개를 비교하는 일은 생각보다 어렵다. 오늘은 리액트가 어떤 가정을 두고 이 문제를 풀었는지, 그 가정에서 어떤 규칙이 나오는지 정리해보겠다!
왜 휴리스틱이 필요할까?
두 트리 사이의 최소 변환을 정확히 구하는 일반적인 알고리즘은 O(n³)의 복잡도를 갖는다. 엘리먼트가 1,000개면 10억 번의 연산이 필요하니 매 렌더마다 돌릴 수는 없다.
그래서 리액트는 두 가지 가정을 두고 O(n)으로 비교하는 휴리스틱(Heuristic) 알고리즘을 쓴다.
- 타입이 다른 두 엘리먼트는 서로 다른 트리를 만든다.
- 개발자는
key로 어떤 자식이 렌더 사이에 유지되어야 하는지 알려줄 수 있다.
이 두 가정에서 아래 규칙들이 나온다.
규칙 1. 타입이 다르면 서브트리를 통째로 교체한다
// 변경 전
<div>
<Counter />
</div>
// 변경 후
<span>
<Counter />
</span>루트의 타입이 div에서 span으로 바뀌었으므로 리액트는 이전 트리를 버리고 새로 만든다. 그 아래에 있는 Counter도 언마운트된 뒤 다시 마운트되므로, Counter가 갖고 있던 state는 사라진다.
규칙 2. 같은 타입의 DOM 엘리먼트면 바뀐 속성만 갱신한다
// 변경 전
<div className="before" title="stuff" />
// 변경 후
<div className="after" title="stuff" />같은 div이므로 DOM 노드를 유지하고 className만 바꾼다. style 객체도 마찬가지로 바뀐 속성만 갱신한다.
// 변경 전
<div style={{ color: "red", fontWeight: "bold" }} />
// 변경 후
<div style={{ color: "green", fontWeight: "bold" }} />color만 수정하고 fontWeight는 건드리지 않는다. 이후 자식들에 대해 같은 비교를 재귀적으로 반복한다.
규칙 3. 같은 타입의 컴포넌트면 인스턴스와 state를 유지한다
같은 위치에 같은 컴포넌트가 다시 렌더되면 리액트는 그 컴포넌트를 새로 만들지 않고 props만 갱신한 뒤 다시 호출한다. 그래서 state가 유지된다.
여기서 중요한 포인트. state는 컴포넌트 이름이 아니라 트리에서의 위치에 연결된다. 같은 위치·같은 타입이면 state가 유지되고, 같은 위치라도 타입이 바뀌면 state가 초기화된다.
{isFancy ? <Counter isFancy={true} /> : <Counter isFancy={false} />}위 코드에서 두 분기 모두 같은 위치에 Counter가 오므로 isFancy를 토글해도 카운터 값은 유지된다. 반대로 한쪽이 <p>이고 다른 쪽이 <Counter />라면 전환할 때마다 state가 초기화된다.
이 규칙 때문에 컴포넌트 안에서 다른 컴포넌트를 정의하면 안 된다. 부모가 렌더될 때마다 새로운 함수가 만들어져 "다른 타입"으로 취급되고, 자식의 state가 매번 초기화되기 때문이다.
규칙 4. 자식 목록은 key로 짝을 맞춘다
자식 배열을 비교할 때 리액트는 기본적으로 앞에서부터 순서대로 짝을 맞춘다. 끝에 항목을 추가하면 문제가 없다.
// 변경 전
<ul>
<li>first</li>
<li>second</li>
</ul>
// 변경 후
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>하지만 맨 앞에 항목을 추가하면 순서가 밀리면서 모든 자식이 바뀐 것으로 보인다.
// 변경 전
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>
// 변경 후
<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>key를 주면 리액트는 순서가 아니라 key로 짝을 맞춘다.
<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>이제 리액트는 "2014가 새로 추가되었고 2015, 2016은 위치만 이동했다"고 판단한다.
key는 형제 사이에서만 유일하면 되고, 렌더 사이에 변하지 않아야 한다. 배열의 index를 key로 쓰면 재정렬하거나 앞에 삽입할 때 key가 다른 항목을 가리키게 되어 state가 엉킨다. Math.random()처럼 매번 바뀌는 key는 모든 항목을 새로 만들게 되어 더 나쁘다. index를 key로 쓰면 왜 꼬이는지는 따로 글을 써서 정리할 예정!
재조정은 구현 세부사항이다
리액트 공식 문서는 재조정 알고리즘을 "구현상의 세부사항"이라고 못 박는다. 리액트가 매번 전체 앱을 다시 렌더하더라도 최종 결과는 같아야 하고, 휴리스틱은 흔한 사용 패턴에서 빠르게 동작하도록 계속 바뀐다.
그래서 개발자가 지켜야 할 것은 알고리즘의 내부가 아니라 앞서 본 두 가지 가정이다.
- 비슷한 결과를 내는 두 컴포넌트를 같은 위치에서 번갈아 렌더하면 매번 서브트리가 교체된다. 이런 경우 하나의 컴포넌트로 만드는 편이 낫다.
- key는 변하지 않고, 예측 가능하고, 형제 사이에서 유일해야 한다.
참고로 리액트 16부터 이 알고리즘을 실행하는 엔진을 **파이버(Fiber)**라고 부른다. 각 엘리먼트를 작업 단위로 나눠 렌더 단계를 중단하고 재개할 수 있게 만든 구조인데, 재조정이 "무엇을 비교하는가"라면 파이버는 "그걸 어떻게 실행하는가"에 가깝다. 이건 나중에 따로 다룬다.
관련 글
- 가상 돔(Virtual DOM)이란?
- 렌더 단계와 커밋 단계
- key와 state 초기화 (작성 예정)
- 파이버 아키텍처 (작성 예정)