2.2 가상 DOM과 리액트 파이버
DOM의 모든 변경 사항을 추적하는 것은 너무 수고스럽다.
대부분의 경우 모든 DOM의 변경사항보다는 최종 DOM 결과물 하나만 알고 싶을 것이다.
이를 위한 것이 가상 DOM이다. 가상 DOM은 웹페이지가 표시할 DOM을 메모리에 저장하고 리액트가 실제 변경에 대한 준비가 끝나면 브라우저 DOM에 반영한다.
이런 방식으로 DOM 계산을 메모리에서 계산하는 과정을 거치면 여러 번 발생했을 렌더링 과정을 최소화한다.
다만 리액트의 가상 DOM 방식이 일반적인 DOM을 관리하는 브라우저보다 빠르다는 것은 오해다.
가상 DOM이 실제 DOM보다 빠르지도 않다면, 왜 사용하는건가?
브라우저의 리플로우, 리페인트를 최적화한다. 즉, CPU 사용률, 메모리 사용량, 렌더링 성능을 최적화한다.
가상 DOM은 소규모 앱에서는 오버헤드로 인해 오히려 성능이 떨어질 수 있지만, 중~대규모 앱에서 진가를 발휘한다.
메모이제이션을 할 때 메모 비용과 메모 효율을 비교하는 것과 비슷한 논리다.
2.2.3 가상 DOM을 위한 아키텍처, 리액트 파이버
리액트 파이버는 리액트에서 관리하는 JS 객체다. 이는 파이버 재조정자(fiber reconciler)가 관리한다.
이는 가상 DOM과 실제 DOM을 비교해 변경 사항을 비교해 변경 정보를 가진 파이버를 기준으로 화면에 렌더링을 요청한다.
리액트 파이버의 목표는 리액트 앱에서의 반응성 문제를 해결하는 것이다.
- 작업을 작은 단위로 분할해 우선순위를 설정
- 작업들은 일시 정지 후 나중에 시작 가능
- 이전 작업을 재사용할 수 있으며 필요 없을시 폐기 가능
위 모든 과정이 비동기적으로 일어난다. (만약 동기적이라면 웹사이트의 성능이 처참할 것..)
파이버는 하나의 작업 단위로 구성되어 있으며, 리액트는 이를 하나씩 처리하고 finishedWork()라는 작업으로 마무리한다.
그리고 이 작업을 커밋해 실제 DOM에 가시적인 변경을 만든다.
- 렌더 단계에서 사용자에게 노출되지 않는 비동기 작업 수행 - 파이버의 작업(우선순위 지정 및 중지, 폐기)
- 커밋 단계에서 DOM에 실제 변경 사항을 반영하기 위한 작업 수행 - commitWork() 실행(동기적, 중단 X)
파이버는 컴포넌트가 최초 마운트되는 시점에 생성되어 이후 가급적 재사용된다.
파이버 트리에 대해 알아보자.
리액트 내부에 총 두 개가 존재하는데, 하나는 현 모습을 담은 것이고, 다른 하나는 작업 중인 상태를 나타내는 workInProgress 트리다. 파이버 작업이 끝나면 리액트는 포인터만 변경해 workInProgess 트리를 현 트리로 바꾼다. 이 기술을 더블 버퍼링이라 한다. 이 더블 버퍼링은 커밋 단계에서 수행된다.
현재 UI 렌더링을 위한 트리인 current를 기준으로 업데이트가 발생하면 파이버는 리액트에서 새로 받은 데이터로 새로운 workInProgress 트리를 빌드하고, 작업이 끝나면 다음 렌더링에 이 트리를 사용한다.
최종적으로 렌더링되어 반영이 끝나면 current가 workInProgress로 변경된다.
이제 파이버 트리와 파이버가 어떻게 작동하는지 흐름을 살펴본다.
- 리액트는 beginWork() 함수를 호출해 파이버 작업을 수행한다. 자식이 없는 파이버를 만날 때까지 트리 형식으로 시작된다.
- 끝나면 completeWork() 함수를 실행해 파이버 작업을 완료한다.
- 형제가 있다면 형제로 넘어간다.
- 위 작업이 모두 끝나면 return으로 돌아가 작업 완료를 알린다.
만약 업데이트가 발생하면 workInProgress 트리를 다시 빌드하는데 이때는 이미 파이버가 존재하므로 props를 받아 파이버 내부에서 처리한다.
2.2.4 파이버와 가상 DOM
리액트 파이버는 리액트 네이티브 같은 비-브라우저 환경에서도 사용 가능하기 때문에 파이버 === 가상 DOM은 아니다.
가상 DOM과 리액트의 핵심은 브라우저 DOM을 빠르게 반영하는 것이 아니라 값으로 UI를 표현하는 것이다.
UI를 JS 문자열, 배열 처럼 값으로 관리하여 이러한 흐름을 효율적으로 관리하기 위한 메커니즘이 리액트의 핵심이다.
2.4 렌더링은 어떻게 일어나는가?
리액트의 렌더링은 브라우저가 렌더링에 필요한 DOM 트리를 만드는 과정을 말한다.
2.4.2 리액트의 렌더링이 일어나는 이유
- 최초 렌더링: 사용자가 처음 앱에 진입하면 보일 결과물이 필요하기에 최초 렌더링을 수행
- 리렌더링: 최초 렌더링 이후 발생하는 모든 렌더링
- useState()의 setter가 실행되는 경우
- useReducer()의 dispatch가 실행되는 경우
- 컴포넌트의 key props가 변경되는 경우
ex) <li key={index}>{index}</li>
이것이 redux와 react-redux가 둘 다 필요한 이유이다. redux는 자체적으로 상태를 관리하지만 리렌더링을 일으키지는 않기에 react-redux가 위 방법 중 하나를 선택해 리렌더링을 일으켜주는 것이다.
2.4.3 리액트의 렌더링 프로세스
렌더링이 시작되면 루트 컴포넌트부터 차례로 내려가며 업데이트가 필요한 컴포넌트를 찾아 FunctionComponent()를 호출하고 결과를 저장한다. 각 컴포넌트의 렌더링 결과물을 수집하고 가상 DOM과 비교해 실제 DOM에 반영하기 위한 모든 변경 사항을 차례차례 수집한다. 이것이 재조정(Reconciliation)이다.
2.4.4 렌더와 커밋
렌더 단계는 컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업이다. 이전 가상 DOM을 비교하는 과정을 거친다.
비교하는 것은 크게 type, props, key다. 커밋 단계는 렌더 단계의 변경 사항을 실제 DOM에 적용해 사용자에게 보여준다.
이렇게 만들어진 모든 DOM 노드 및 인스턴스를 가리키도록 리액트 내부 참조를 업데이트한다. 이후 useLayoutEffect 훅을 호출한다.
리액트의 렌더링이 일어난다고 해서 무조건 DOM 업데이트가 일어나는 것은 아니다.