이전 글에서 Counter를 예시로 들며 Render Phase의 렌더링은 state가 변경된 컴포넌트로부터 재귀적으로 자식 컴포넌트를 돌며 element를 확인하는 식으로 진행된다고 얘기했다.
이제 알아야 하는 것은, Render Phase의 변경이 필요한 부분을 찾아내는 과정이다.
React는 렌더링 과정에서 확인한 ReactElements로 이루어진 하나의 트리 구조를 만든다.
이를 흔히 가상돔(Virtual DOM)이라고 하는데, 우선 이 용어를 지양해야 하는 이유를 짚고 가자.
React는 여러 환경에서 앱을 렌더할 수 있다. DOM 뿐만이 아니라, React Native에서도 사용되어 IOS, Android View에서도 그려지므로 Virtual "DOM" 이라는 표현 자체가 맞지 않는다.
React 공식문서에서도, 가상돔은 기술이라기보단 패턴에 가깝다고 설명한다.
저는 "가상 DOM"이라는 표현이 폐기되길 바랍니다. - Dan Abramov(Redux 개발자)의 트윗
이후 포스팅할 내용들에는, 가상돔을 ReactElement를 모아서 구축한 Tree라고 일컫겠다.
근데 너무 기니깐, 그냥 Tree라고 부르겠다.
가상돔과 현재 노드 트리를 비교해서 변경점들을 DOM에 적용시키는 것도 결국 같은 종류의 노드 트리끼리 비교하는 것이다. 렌더링 결과물도 가상돔이고 만들어져 있던 이전의 트리도 가상돔이라면 그냥 둘 다 트리로 부르는 게 맞지 않을까?
React는 이렇게 만든 렌더 트리 구조를 활용해 DOM을 그리고 트리를 유지한다.
이후에 앱의 업데이트가 발생했을 때, 렌더링 과정에서 만들어진 트리와, 이전 트리를 비교한다.
이전 트리와, 새로 만들어진 트리 두 개가 있으니, 이전과 비교해 트리의 어느 부분이 바뀌었는지를 파악하면 새로 DOM을 수정하는 것도 그 부분만 바꾸면 된다.
문제는 비교하며 변경점을 파악하는 과정인데, React팀은 이 과정을 재조정(Reconciliation)이라고 부른다.
재조정은 React에서 어떤 부분들이 변해야 하는지 두 개의 트리를 비교하는 데 사용하는 알고리즘이다.
-Andrew Clark(React core 개발팀)
사실 두 개의 트리를 비교하는 것은, 머리로만 굴려봐도 꽤나 시간이 소요되는 작업이다.
모든 노드를 탐색해야하고, 재귀적으로 탐색해야 하기에, 최첨단 알고리즘도 O(n^3)의 시간복잡도를 갖는다고 한다.
이 과정을 휴리스틱을 통해 세제곱근인 O(n)으로 만들었다는데, 핵심 이론은 이러하다.
휴리스틱은 알고리즘 이론 중 가지치기 방법으로, 진행 중에 이 값이 아니다 싶으면 남은 연산을 포기하고 다른 방향으로 진행해버리는 방법이다. 시간복잡도가 O(n^3)이면, 노드가 10개일 때 비교해야 하는 횟수는 1000번이다.
- 서로 다른 type의 element는 완전히 다른 트리를 생성할 것이라고 예상한다. React는 이런 경우에 둘을 비교하지 않고, 이전의 트리를 버린 뒤 완전히 새로 만든다.
- List는 key 속성을 이용해서 비교한다. key값은 안정적이고, 예측 가능하며, 유일해야 한다.
type은 ReactElement에서 찾아볼 수 있었는데, "태그명" 혹은 "컴포넌트명"에 해당된다.
1번을 조금은 억지스러운 예제로 증명해보겠다.
function Counter(){
// Deps가 빈 배열인 useEffect Hook은 렌더링때마다 호출되지 않고,
// 인스턴스가 생성될 때 한번만 호출됩니다.
useEffect(() => {
console.log("Create Counter Instance!")
}, [])
return (<div>{/* 카운터 UI.. */}</div>)
}
function App() {
const [flag, setFlag] = useState(true);
console.log("App Rendered!!")
return (
<div className="App">
{/* 삼항연산자를 사용해서 flag값에 따라 다른 JSX를 리턴하도록 작성했다. */}
{flag ?
<div>
<Counter />
</div>:
<div style={{backgroundColor: "gray"}}>
<Counter />
</div>
}
<button onClick={() => setFlag(prev => !prev)}>change!</button>
</div>
);
}
렌더링은 되지만 새로 만들어지진 않는다!
그럼 렌더링할 컴포넌트의 type이 바뀐 경우에는 어떻게 될까?
상위 태그를 span으로 바꾸고, 렌더링을 진행해보자.
완전히 달라진 output과 DOM 업데이트
렌더링 트리 상 같은 위치에, 다른 태그(타입)가 나오니 바로 DOM을 처음부터 다시 생성하는 것을 볼 수 있다.
새로 생긴 인스턴스이기에 <Counter />
또한 새로 만들어져 useEffect 내부 콜백이 매번 실행된다.
그런데 꼭 상위 태그가 달라야 하는 것도 아니다.
좀 더 자세히 알아가면, React 16 버전부터는 Fiber라는 재조정 엔진을 사용하기 시작했다고 한다.
사실 ReactElement는 그 자체만으로 VDOM을 위한 트리를 구성하지 않는다.
ReactElement를 확장시켜 만드는 Fiber라는 객체에 더 다양한 정보를 담아 트리를 만든다.
Fiber객체는 다음과 같은 데이터들을 포함한다.
- 컴포넌트 트리에서, 이 시점에 렌더링되어야 하는 컴포넌트의 type
- 현재 컴포넌트와 연관된 props와 state
- 부모 컴포넌트, 형제 컴포넌트, 자식 컴포넌트를 향한 포인터
- React가 렌더링 과정에서 추적하기 위해 사용하는 다른 내부 메타데이터
UI를 표현하기 위한, 렌더링 과정에서 추적하기 위한 다양한 데이터를 담고 있다.
여기서 이 시점에 렌더링되어야하는 컴포넌트의 type과 부모, 형제, 자식 컴포넌트를 향한 포인터에 주목해보자.
"조금 억지스러웠던 예제"로 돌아가, Counter 컴포넌트 위에 텍스트를 하나 추가해보겠다.
텍스트가 추가된 이전과 다른 구조
"아무 텍스트 1"은 기존 Counter 컴포넌트의 자리를 빼앗았다.
주변의 다른 Fiber의 포인터로 다가왔을 때, 기존의 이 시점에서 렌더링되어야 하는 type이 아니다.
예상대로면 <span>
태그부터 이전 레퍼런스를 완전히 버리고 새로운 인스턴스를 만들어 그릴 것이다.
매 렌더링마다 새로운 인스턴스를 생성한다
Counter 컴포넌트와 span의 인스턴스가 새로 만들어지는 것은 맞는데, Counter를 감싸는 div태그도 동시에 바뀐다.
이는 props안에 담긴 children 즉, 태그 사이의 값이 바뀌었기 때문에 해당 요소를 다시 만든 것이다.
그럼 Counter 컴포넌트를 "기존의 그 시점에서 그대로 렌더링되도록 만들면" 인스턴스는 유지될까?
구조상 Counter의 위치는 그대로라면?
인스턴스가 유지된다?
Counter의 인스턴스는 그대로 유지되고, DOM에서도 딱 필요한 부분만 업데이트되는 것을 볼 수 있다.
DOM을 다시 만들지 않으니 속도면에서 큰 이점을 가져올 것이다.
React는 렌더링 과정에서 JSX로 만든 ReactElement를 확장시킨 Fiber 객체를 만든다.
Fiber객체들로 이루어진 트리를 만들어 이전에 만들어둔 트리와 비교하고, DOM에 대입시킨다.
React가 두 트리를 비교하는 과정인 재조정은 아래 두 가지 핵심 알고리즘을 통해 이전 트리와 새로운 트리의 비교를 진행한다.
- 서로 다른 type의 element는 완전히 다른 트리를 생성할 것이라고 예상한다. React는 이런 경우에 둘을 비교하지 않고, 이전의 트리를 버린 뒤 완전히 새로 만든다.
- List는 key 속성을 이용해서 비교한다. key값은 안정적이고, 예측 가능하며, 유일해야한다.
이번 포스팅의 여러 예시들로 1번을 이해할 수 있었다.
다음 내용은 2번 key와 관련된 재조정 로직부터 이해할 예정이다.
참고:
https://github.com/acdlite/react-fiber-architecture
https://ko.reactjs.org/blog/2015/12/18/react-components-elements-and-instances.html
https://ko.reactjs.org/docs/reconciliation.html