개념 정리
본격적으로 렌더링에 대해서 보기 전에 렌더링과 관련된 리액트 패키지 4가지를 먼저 살펴보겠습니다.
패키지 구조
React
리액트 코어 패키지는 컴포넌트를 정의하는 역할만 합니다. 그리고 다른 패키지에 의존성이 없어서 다양한 플랫폼에 올려서 사용이 가능합니다. 그래서 화면을 그리는 패키지가 따로 있는데 그게 바로 렌더러 패키지 입니다.
Renderer
렌더러는 저희한테 익숙한 리액트-돔과 모바일에서 사용하는 네이티브 렌더러 등이 있고 호스트 렌더링 환경, 그러니까 플랫폼에 의존적입니다. 이렇게 렌더러가 다양하기 때문에 리액트가 그려질 수 있는 플랫폼 환경이 다양합니다. 즉 렌더러의 역할은 브라우저나 모바일 같은 호스트와 리액트를 연결하는 그런 역할을 합니다.
Scheduler
스케줄러는 이름 그대로 스케줄링을 담당합니다. 리액트는 태스크 단위로 작업을 실행하는데, 이 태스크를 비동기로 실행합니다. setState를 실행하고 바로 아래서 콘솔을 찍으면 수정된 상태값이 바로 찍히지 않고, 이전 state가 찍히는 것과 같이 리액트는 비동기로 태스크를 실행하는데요, 이 태스크들의 실행 타이밍을 결정하는 것이 바로 이 스케줄러라는 패키지입니다. 즉 리액트는 태스크의 실행 타이밍 결정을 스케쥴러에게 위임한겁니다.
Reconciler
마지막으로 리컨실러입니다.
리컨실러는 fiber 아키텍쳐에서 VDOM을 재조정하는 역할을 담당합니다. 또 리컨실러의 중요한 역할은 컴포넌트를 호출하는 곳이라는 겁니다.
이렇게 리액트는 패키지 마다 관심사를 분리해서 서로 의존도를 낮춤으로써 유지 보수를 쉽게 할 수 있고, 재사용성도 높혔습니다.
다음은 리액트 렌더링 프로세스를 간략하게 정리해보겠습니다.
렌더링 프로세스
- 리액트 컴포넌트는 JSX 구문으로 작성되는데요, 자바스크립트가 컴파일 됐을때 React.createElement()호출로 변환됩니다.
- createElement는 UI의 구조를 설명하는 자바스크립트 객체인 React element를 반환합니다. 이 React element 안에는 타입, 키, 프롭스, ref 등의 정보가 담겨져 있습니다.
- 그 다음 이 엘리먼츠들을 파이버 노드라는걸로 확장하고
- VDOM에 반영하는 작업을 진행하는데, 여기까지가 저희가 일반적으로 리액트 렌더링 이라고 부르는 작업입니다.
- 이후에 재조정된 VDOM을 가지고 렌더러가 돔에 마운트하고
- 그걸 브라우저가 화면에 페인트하게 되는겁니다.
여기서 나오는 파이버가 뭔지 궁금해집니다.
Fiber
파이버는 VDOM의 노드 객체입니다.
리액트 엘리먼트를 VDOM에 추가하기 위해서는 리액트 엘리먼트 자체로는 추가하기가 어렵고, 몇가지 정보가 더해져야 합니다. 이때 이 몇가지 정보를 더해서 확장한 객체가 바로 파이버입니다.
컴포넌트의 상태, 다음 fiber로 향하는 포인터, 변경 사항에 대한 정보 같은걸 담고 있습니다.
이렇게 Fiber는 하나의 요소에 대응하는 역할도 하고, ‘일’을 관리하는 가상 스택 프레임의 역할도 합니다.
reconciler가 렌더링 작업을 잘게 나누고 우선 순위에 따라서 실행하는데, 여기서 이 작업이 잘게 쪼개진 단위가 하나 하나가 파이버입니다.
조금 이따가 좀 더 자세히 보도록 하고, 파이버의 주요 속성들을 몇 가지 먼저 보겠습니다.
type과 key가 있는데, 엘리먼트도 가지고 있는 속성이죠. 이건 재조정 시에 fiber가 재사용될 수 있는지 판단할 때 사용합니다.
child, sibling, return은 Fiber tree에서 다음 파이버 노드를 가리키는 포인터 역할을 하는데 여기서 리턴은 부모 파이버 노드를 참조합니다.
alternate도 있는데, 더블 버퍼링 구조의 이중 트리에서 반대편 노드를 참조하는 역할을 합니다.
다음으로 버츄얼 돔에 대해 알아보면 이 속성들이 어떤 역할을 하는지 자연스럽게 알 수 있습니다.
Virtual DOM
VDOM은 메모리상에 UI와 관련된 정보를 띄우고 reactDOM과 같은 라이브러리에 의해서 실제 돔과 싱크를 맞추는 프로그래밍 컨셉입니다.
이 전체적인 과정을 재조정(reconciliation)이라고 부르는데 리컨실러 패키지가 관여를 하게 됩니다.
그럼 왜 가상으로 만들어야 하는가라는 질문이 생기는데요. 그 이유는 만약에 실제로 돔을 조작하고 페인트하게 되면 가상으로 조작하는 것보다 비용이 더 크고 오래걸리기 때문입니다.
그럼 이제 한번 버츄얼 돔이 실제로 어떻게 구현되어 있을까를 살펴보겠습니다.
이 버츄얼 돔을 표현한 그림을 보시면 먼저 노드들이 보이죠, 이 노드 하나 하나가 바로 파이버입니다.
그래서 버츄얼 돔은 파이버 노드로 구성된 트리 형태인 거죠.
그런데 VDOM은 진짜 돔 트리와는 다르게 트리가 2개죠. 하나는 Current 트리고 하나는 WorkInProgress 트리입니다. VDOM은 트리가 2개이기 때문에 더블 버퍼링 구조를 가지고있다고 표현합니다. 더블 버퍼링을 설명하기 위해서 각 트리의 역할에 대해서 먼저 알아야합니다.
Current 트리
current 트리는 돔에 마운트된 정보들이 파이버 노드들로 표현돼 있는 트리 입니다.
커렌트 트리에서 커렌트로 연결된걸 타고 올라가면 루트 노드가 있고, 이 루트 노드는 App이라는 컴포넌트에 연결되어져 있죠.
이건 리액트 프로젝트의 코드를 살펴보면 쉽게 찾을 수 있습니다.
import * as React from 'react';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
const root = createRoot(rootElement);
root.render(
<StrictMode>
<App />
</StrictMode>
);
console.log(App());
index.tsx 파일로 가보면 root div를 가져와서 루트 노드를 만들고, 여기서 app을 받아서 렌더하는걸 볼 수 있죠.
즉 커렌트 트리는 실제 html 태그에 적용이 되어있는 정보를 가지고있는 파이버 트리입니다.
workInProgress 트리
workInProgress 트리는 작업중인 트리고 커렌트 트리에서 자기 복제를 통해 만들어집니다.
렌더 페이즈에서 workInProgress 트리를 조작하고 커밋 페이즈를 지나면서 커렌트 트리가 됩니다.
루트 노드의 참조가 커렌트에서 워크인으로 바뀌는 거죠.
그러면 새로운 커렌트 트리에서 다시 자기 복제를해서 워크인 트리를 만듦니다. 이렇게 똑같은 정보를 두 벌씩 가지고 있는 구조를 더블 버퍼링 구조라고 부릅니다.
리액트는 더블 버퍼링 형태이기 때문에 workInProgress에 작업을 하다가도 언제든지 버리고 처음부터 다시 작업하거나 또는 중지시켰다가 다시 시작하는 등 작업 우선순위에 맞게 유연하게 대처할 수 있습니다.
워크인 트리도 동일하게 파이버 노드로 구성되어 있고 커렌트 트리와 alternate로 참조되어 있습니다.
파이버 노드는 그냥 일반 JS객체이고 객체 안에 alternate라는 키를 가지고 있습니다.
이 키의 값으로 상대 트리 노드의 객체 레퍼런스가 담겨 있습니다. alternate를 통해 서로 참조하고 있기 때문에 React는 커밋 페이즈에서 현재 트리와 workInProgress 트리를 교환할 수 있습니다.
이 fiber workInProgress 트리를 reconciler가 재조정 하기 위해서 탐색하게 되는데, 이 탐색 과정을 시각화 한 애니메이션을 보면 쉽게 이해할 수 있습니다.
https://codepen.io/ejilee/pen/eYMXJPN
보이는 것 처럼, Fiber 트리에서는 각 노드가 return, sibling, child 포인터 값을 사용하여 체인 형태의 singly linked list(단일 연결 리스트)를 이룹니다.
child가 있으면 child, child가 없으면 sibling, sibling이 없으면 return으로 가는 순서대로 다음 파이버로 이동합니다. 각 fiber는 다음으로 처리해야 할 fiber를 가리키고 있기 때문에, 이 긴 일련의 작업이 중간에 멈춰도, 지금 작업 중인 fiber만 알고 있다면 돌아와서 같은 위치에서 작업을 이어가는 것이 가능합니다.
다음으로 reconciliation의 2가지 작업 단계에 대해 알아보겠습니다.
Reconciliation 작업 단계
Render phase
먼저 Render phase입니다. 재조정(reconciliation)이 일어나기 위해서는 리컨실러가 work를 스케쥴러에 등록해야합니다. (work: reconciler가 컴포넌트의 변경을 DOM에 적용하기 위해 수행하는 작업)
스케쥴러는 등록된 work를 타이밍에 맞게 실행하는데, 이 전체적인 과정을 reconciler가 담당하는 것이죠.
이때 이 리컨실러가 리액트 16 버전에서 굉장히 큰 변화를 겪었습니다. 리컨실러의 아키텍처가 스택에서 파이버로 바뀌면서 렌더링의 순서를 변경할 수 있게 됐죠. 스택구조는 무조건 맨 마지막에 넣은 태스크를 처음으로 빼내야 하는데 파이버를 사용하면서 이 순서가 변경 가능해졌습니다.
abort, stop, restart과 같은 메서드를 호출하면서 렌더링 순서를 조작 가능해졌습니다. 이렇게 파이버를 만들고 렌더링 순서를 변경 가능하도록 개선한 주된 이유는 동시성 렌더링(Concurrent Rendering)을 가능하게 만들기 위해서 였습니다. 그리고 리액트 18버전 부터는 리액트에 concurrent 모드가 정식 도입되었습니다.
Commit phase
렌더 페이즈 다음 커밋 페이즈에선 리컨실리에이션이 끝난 VDOM을 DOM에 적용하는 일이 일어납니다. 그리고 라이프사이클을 실행하게 되는데요. 돔에 적용할때 일관성을 유지하기 위해 동기로 실행됩니다. DOM 조작이 처리된 후에 리액트가 콜스택을 비워줘야지 브라우저가 페인트할 수 있죠.
그럼 이제 드디어 리컨실러와 동시성 렌더링이 뭔지 알아보겠습니다.
동시성(Concurrent)
Stack reconciler의 문제
브라우저의 메인스레드는 싱글 스레드라서 한번에 하나의 작업만 처리할 수 있습니다. 보통은 변경 비교 알고리즘이 매우 빠르기 때문에 멈출 수 없더라도 큰 상관 없습니다. 그런데 렌더링 연산이 매우 길어지면 문제가 생깁니다. 리액트도 Stack reconciler를 사용하던 때에는 화면을 그리기 위한 내부 연산을 시작하면 도중에 멈출 수 없었습니다.
이런 코드를 렌더링 한다고 생각해봤을때, Stack reconciler는 virtual DOM 트리를 비교하고 화면에 변경 사항을 푸시하는 이 모든 작업을 동기적으로, 하나의 큰 테스크로 실행합니다. 이 작업은 일시 중지되거나 취소될 수 없어서, 부하가 많은 연산이 걸리면 처리되기 전까지는 메인 스레드는 다른 작업을 할 수 없고, 앱은 일시적으로 반응이 없어지거나 버벅거리게 됩니다. 이걸 블로킹 렌더링 문제라고 합니다.
블로킹 렌더링을 간단한 데모코드를 통해서 살펴보겠습니다.
https://react-rendering-demo.vercel.app/
입력창에 텍스트를 입력해서 상태를 변경하면 입력된 텍스트의 길이에 비례해서 색상 목록의 길이가 늘어납니다.
글이 많지 않을때는 부하가 적어서 렌더링이 잘 되지만 글을 많이 입력하면 화면이 블로킹되고 반응이 없어집니다. 색상 목록은 물론이고 입력창까지 블로킹되면 사용자 경험이 매우 안좋아집니다.
리액트는 이 블로킹 문제를 해결하기 위해 동시성 렌더링을 도입했습니다.
동시성이란?
동시성이란 “2개 이상의 독립적인 작업을 잘게 나누어 동시에 실행되는 것처럼 보이도록 프로그램을 구조화하는 방법”입니다.
Stack Reconciler가 렌더링 작업을 동기적으로 큰 덩어리로 처리했다면, Fiber Reconciler는 렌더링 작업을 잘게 쪼개서 여러 프레임에 걸쳐 실행할 수 있도록 했고, 작업들의 우선순위를 파악해서 점수를 매김으로써 우선순위가 높은 작업이 들어오면 일시 정지했다가 나중에 재가동 할 수 있도록 비동기적으로 만들었습니다. 파이버를 설명할때도 나왔지만 여기서 이 작업이 잘게 쪼개진 단위 하나 하나가 파이버입니다.
리액트에서는 ReactDom.createRoot로 앱을 실행하면 내부적으로 concurrent mode를 활성화시킵니다. 그렇다고 항상 concurrent rendering을 하는 것은 아니고 Concurrent 관련 기능을 사용할때만 활성화가 됩니다.
동시성 렌더링으로 해결하기
앞에서 봤던 블로킹 예제를 동시성 렌더링으로 해결해보겠습니다.
먼저 작업의 우선 순위를 정해야 하는데, 인풋에 입력으로 인한 텍스트 갱신은 빠르게 처리되길 기대하고 그에 대한 목록 갱신은 상대적으로 늦게 처리돼도 괜찮죠.
아까 봤던 블로킹 렌더링을 1차선 도로에 비유됩니다. 입력이 적을때는 상관 없는데, 입력이 많아질수록 목록을 렌더링하는데 CPU가 많은 시간을 점유하면서 D와 E의 입력에 대한 이벤트를 처리하지 못합니다.
동시성 렌더링은 2차선 도로입니다. 입력 렌더링 작업은 고속 차선에 배치하고 목록 렌더링 작업은 저속 차선에 배치했습니다.
또 그림에서 목록 렌더링 작업을 2개로 분리된걸 확인 할 수 있는데, 전에 말씀드린 것처럼 동시성 렌더링에선 하나의 렌더링 작업을 잘게 분해해서 처리합니다.
입력이 적으면 1차선만 사용할때랑 크게 다르지 않지만 입력이 많아지면 차이가 납니다.
아까 cde를 한번에 입력하면서 블로킹 됐던 부분입니다. 이미 ab가 입력된 상대에서 c를 입력하면 상태가 변경되고 렌더링이 시작됩니다.
그 도중에 D를 입력하면 c로 인한 목록 렌더링 작업을 일시 중단합니다. 그리고 더 급한 작업인 d에 대한 입력 렌더링 작업을 먼저 수행합니다. 그래서 텍스트만 먼저 보이게 되고 사용자는 인풋을 먼저 확인할 수 있게 되는 거죠. 그러고 나서 펜딩 상태인 목록 렌더링을 깃 브랜치처럼 리베이스합니다.
리액트에선 이런 고속 차선과 저속 차선을 나눈걸 레인이라고 부르고 우선순위를 제어하기 위해서 사용합니다.
동시성 렌더링 구현
리액트에선 동시성 렌더링을 사용하기 위해서 앞서 말씀드린 저속 차선을 만들어주는 useTransition이라는 훅을 사용합니다.
useTransition은 isPending이랑 startTransition을 담은 배열을 리턴하는데, startTransition에 상태 업데이트를 콜백 함수로 넘기면 그 업데이트는 낮은 우선순위를 갖게 됩니다.
이렇게 낮은 우선순위를 가지면 보다 중요한 업데이트에 cpu 사용을 양보하게 되고, 그래서 대규모 렌더링 중에 응답성을 유지할 수 있습니다.
블로킹 데모를 useTransition을 사용해서 개선한 코드를 보겠습니다.
useTransition을 불러오고 startTransition 함수의 콜백으로 색상 목록의 상태를 변경하는 setColors 상태 업데이트 함수를 전달했습니다. 그리고 isPending 상태를 통해서 사용자에게 현재 펜딩 상태인지 아닌지를 알려줬습니다. 한 번 입력해보면 블로킹 없이 한번에 입력을 할 수 있는걸 알 수 있죠.
브라우저의 성능 탭에서 동시성 렌더링과 블로킹 렌더링의 프레임 차트가 다른걸 알 수 있습니다.
블로킹 렌더링이 하나의 긴 스택을 가진 것과 달리 동시성 렌더링에서는 짧은 여러개의 스택으로 나눠져 있습니다.
각각의 스택이 비워질때 브라우저는 입력과 같이 더 중요한 일을 처리할 수 있습니다.
레퍼런스
https://react.dev/blog/2022/03/29/react-v18
https://tv.naver.com/v/23652451
https://blog.mathpresso.com/react-deep-dive-fiber-88860f6edbd0