리액트 디자인 원칙에서 스케줄링의 내용은 다음과 같습니다.
If something is offscreen, we can delay any logic related to it. If data is arriving faster than the frame rate, we can coalesce and batch updates. We can prioritize work coming from user interactions (such as an animation caused by a button click) over less important background work (such as rendering new content just loaded from the network) to avoid dropping frames.
무엇인가가 화면 밖에 있다면 우리는 그것과 관련된 어떤 로직을 지연시킬 수 있습니다. 데이터가 프레임 속도보다 좀 더 빠르게 도착하는 경우 통합 및 일괄 업데이트를 할 수 있습니다. (네트워크에서 갓 로드된 새로운 컨텐츠의 렌더링과 같은) 중요도가 낮은 백그라운드 작업보다 (버튼 클릭에 의한 애니메이션과 같은) 사용자 인터렉션을 우선할 수 있습니다.
렌더링에 다른 타입의 업데이트를 적용할 때는 우선 순위가 다르다는 이야기입니다. 사용자가 직접 눈으로 확인하는 애니메이션을 사용자가 볼 수 없는 데이터 작업보다 우선하겠다는 건데요. 그 이유는 사용자에게 버벅거리는 화면을 보여주지 않기 위해서로 보입니다.
이는 프레임 드랍과 관련이 있는데, 일반적으로 모니터는 초당 60 프레임으로 화면을 재생합니다. 다시 말해 16.67ms(1/60s) 간격으로 새 프레임이 나타나며, 리액트는 이보다 빠르게 리렌더링 해야 한다는 말입니다. 만약 우선순위 없이 순서대로 작업하여 16ms를 넘긴다면, 복잡한 화면에서 사용자는 매번 버벅거림을 느낄 것입니다.
그럼 리액트는 어떻게 리렌더링을 할까요?
파이버
1. 파이버란
복잡한 화면처럼 한 번에 너무 많은 UI 작업이 있거나, 최신 업데이트로 필요 없어진 작업이 쌓여 있다면 어떻게 될까요? 그저 JS의 콜스택에 의존한다면, 급한 작업이 있더라도 작업은 순서대로 진행될 것입니다.
리액트는 어떤 작업이 지금 필요하고 필요하지 않은지를 압니다. 그래서 UI 컴포넌트와 함수를 나누고, 우선순위를 두는 증분 렌더링을 진행합니다. 즉, 리액트 컴포넌트에 특화해 스택을 재구현합니다. 가상 스택 프레임을 구현해, JS의 일반적인 콜스택을 사용하지 않는 것 같은 효과를 냅니다. 이에 대해 잘 설명한 글이 있으니 자세히 알고 싶으신 분은 읽어보시길 권장합니다.
증분 렌더링 (Incremental Rendering)
여러 가지 일을 한 작업자가 처리하려면 “일시 정지”, “재가동”, “우선순위”는 필수 기능입니다. 애플리케이션의 concurrent 렌더링이 가능하다고 함은 "화면 렌더링 태스크에 우선순위를 매겨서 중요한 것을 먼저 처리하고 덜 중요한 것을 나중에 처리하는 것이 가능하다"를 의미합니다. 리액트 공식 문서에서는 이것을 incremental rendering (증분 렌더링)이라고 부릅니다.
파이버의 목표는 리액트가 스케줄링의 이점을 얻는 것이며, 작업을 파이버 단위로 쪼개고, 작업은 다음과 같이 진행할 수 있어야 합니다.
- 멈췄다 나중에 다시 시작
- 작업 유형별로 우선순위 부여
- 완료한 작업 재사용
- 필요 없는 작업 중단
2. 작동 원리
렌더
- 파이버 트리는 current와 workInProgress 두 가지 종류가 있습니다. 리액트가 current 트리를 지나가면서 각 노드에 대응되는 workInProgress 파이버를 생성합니다.
- 재조정자(Reconciler)는 파이버를 하나의 작업 단위로 취급합니다.
- 파이버 트리는 단순히 깊이 우선으로 탐색하지 않습니다. 각 파이버는 return, sibling, child 포인터 값을 가지고, 포인터 값으로 체인 형태의 linked list를 이룹니다. 그래서 작업이 중간에 멈춰도 다시 이어가는 것이 가능합니다.
이펙트와 커밋
- 파이버들은 ‘변경 사항에 대한 정보 (effect)'를 가지고 있습니다.
- 이펙트 변경 정보를 모아둔 이펙트 리스트는 크리스마스 트리의 조명과 같이 일렬로 이어져 있고, 렌더가 완료되는 과정에서 담깁니다. 이를 DOM에 바로 반영하지 않고, 모아뒀다가 모든 파이버 탐색이 끝난 후, 마지막 커밋 단계에서 한 번에 반영합니다.
- 작업이 완료되면 workInProgress 트리는 스크린에 플러시 되고, current 트리로 변경됩니다.
컴포넌트 렌더링에서 파이버 플러시까지 흐름
이제 컴포넌트 코드부터 리액트 렌더링이 완료되기까지 흐름을 따라가보겠습니다.
1. 컴포넌트 렌더링
클래스 컴포넌트의 render 메서드에 count state를 보여주는 span 요소가 있습니다. 이 요소가 어떻게 파이버로 전환될까요?
<span key="2">{this.state.count}</span>
[
React.createElement(
'span',
{
key: '2'
},
this.state.count
)
]
[
{
$$typeof: Symbol(react.element),
type: 'span',
key: "2",
props: {
children: 0
}
}
]
컴포넌트는 JSX 컴파일러와 리액트를 통해 위와 같은 순서로 변환됩니다.
{
type: "span", // 컴포넌트라면 함수/클래스 컴포넌트 텍스트, HTML 요소라면 tag 이름 텍스트.
key: "2", // 고유 식별자.
tag: 5, // 컴포넌트/요소 인스턴스의 유형으로 정수가 할당. FunctionComponent, MemoComponent 등의 이름에 대응.
// 재조정 과정에서 key와 type으로 정체성을 식별.
alternate: null, // workInProgress 파이버 트리.
pendingProps: {children: 0}, // 실행 시작 props.
memoizedProps: {children: 0}, // 실행 끝 props.
// 둘이 같으면 파이버를 재사용.
...
}
최종적으로 span 요소는 JS 객체 형태의 파이버로 전환되며, 컴포넌트에 대한 정보를 담고 있습니다.
할당을 최소화하기 위해 최대한 이미 존재하는 파이버를 재사용합니다.
2. 파이버 render 단계
유저에게 보이지 않는 작업입니다.
스케줄링된 컴포넌트 업데이트가 진행되며, UI 업데이트에 무엇이 필요한지 확인합니다. 이 단계의 결과는 사이드 이펙트가 표시된 파이버 트리입니다.
(아래에 나오는 리액트 소스 코드는 18.3.1 버전 기준입니다.)
이제, 최초로 렌더링 된 이후, state가 변경됐음을 가정해 보겠습니다.
current 파이버의 루트를 복제해서 workInProgress 파이버의 루트로 지정하고 workLoop 함수를 호출합니다.
// packages > react-reconciler > src > ReactFiberWorkLoop.js
/** @noinline */
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
let next;
if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
...
next = beginWork(current, unitOfWork, entangledRenderLanes);
} else {
...
next = beginWork(current, unitOfWork, entangledRenderLanes);
}
...
if (next === null) {
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
workLoopConcurrent 함수에서 첫 파이버부터 나머지 파이버까지 작업을 반복적으로 진행합니다.
단, 작업을 쪼개서 스케줄러가 허락한 시간 안에 진행합니다.
performUnitOfWork 함수에서는 다음 파이버를 beginWork 함수에서 작업합니다.
beginWork 함수에서는 current와 workInProgress 파이버의 인풋 값을 비교해서, 변경됐다면 workInProgress 파이버를 업데이트하고 자식 파이버를 리턴합니다. 그 결과 값이 다음 작업 대상이 되며, workLoopConcurrent 함수에서는 이 과정을, 자식 파이버가 없을 때까지 반복합니다.
// packages > react-reconciler > src > ReactFiberWorkLoop.js
function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
const current = completedWork.alternate;
const returnFiber = completedWork.return;
if ((completedWork.flags & Incomplete) === NoFlags) {
const next = completeWork(current, completedWork, renderLanes); // completeWork 호출
if (next !== null) {
workInProgress = next; // workInProgress에 새로운 자식 파이버에 할당
return;
}
} else {
// ...
}
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
workInProgress = siblingFiber; // workInProgress에 형제 파이버 할당
return;
}
completedWork = returnFiber;
workInProgress = completedWork;
} while (completedWork !== null);
if (workInProgressRootExitStatus === RootInProgress) {
workInProgressRootExitStatus = RootCompleted; // 완료 표시
}
}
더 이상 자식 파이버가 없다면 completeUnitOfWork 함수가 호출됩니다. 이제 해당 파이버를 완료 처리하고 completeWork를 호출합니다. 이제야 말단에 있는 파이버가 하나 완료됐네요.
그다음으로 형제 파이버가 있거나, 상태 변화로 자식 파이버가 생성되면, completeUnitOfWork 함수를 종료하고 performUnitOfWork 함수로 돌아갑니다.
더 이상 처리한 자식이나 형제 파이버가 없으면 return 포인터를 통해 부모 파이버로 돌아가고, completeUnitOfWork 작업을 반복합니다.
루트까지 파이버 처리가 완료되고, 루트에 완료 표시가 되면 이펙트 정보를 포함한 새로운 파이버 트리가 완성된 것입니다.
3. 파이버 commit 단계
이제 유저에게 보이는 작업입니다.
current, workInProgress 파이버 트리와 이펙트 리스트가 있습니다. 이펙트 리스트는 렌더 단계의 결과물이며, 어떤 파이버를 삽입, 업데이트, 삭제해야 하는지 알려줍니다. 파이버 렌더는 일어났지만 이펙트 리스트는 없을 수도 있습니다.
이펙트 리스트에 이펙트가 있다면 하나씩 commitMutationEffects, resetAfterCommit, commitLayoutEffects, requestPaint 같은 작업을 실행해서 화면에 파이버 트리를 flush 해주고 루트의 current 포인터를 바꿔줍니다. 기존에 있던 current 트리는 새로 지정된 current 파이버의 alternate로 재활용됩니다. 이 작업은 동기적으로 한 큐에 실행됩니다.
마무리하며
한 번에 모두 이해하기가 쉽지 않고, 지금도 완전히 안다고 말할 수 없습니다.
계속해서 공부할 필요성을 느끼는 주제이고, 앞으로 쭉 내용을 업데이트해 나갈 생각입니다.
글을 읽으시면서 이해가 안가는 부분이 있다면 얘기해주는 걸 환영합니다.
출처
In-depth overview of the new reconciliation algorithm in React
[React] Fiber 아키텍처의 개념과 Fiber Reconciliation 이해하기
'React.js' 카테고리의 다른 글
RSC가 비동기 함수를 관리하는 방법 (feat. Observer Pattern) (0) | 2024.06.18 |
---|---|
React Server Components 딥 다이브 (0) | 2024.06.11 |
Apollo Client Cache로 전역 상태 관리하기 (0) | 2024.06.06 |