미니 리액트 구현하기
기존 코드의 문제점
render 함수를 구현을 통해 jsx로 작성한 코드를 실제 DOM에 추가할 수 있도록 하는 작업을 진행했다.
하지만 이 로직은 한 가지 문제가 있다.
구현한 코드 중 updateContainer을 살펴보면 요소를 생성하는 즉시 appendChild를 통해 DOM에 렌더링하고 있다.
if (typeof children === "string" || typeof children === "number") {
const textNode = document.createTextNode(element.props.children);
dom.appendChild(textNode);
} else if (Array.isArray(children)) {
element.props.children.forEach((child) => {
updateContainer(child, dom);
});
} else {
updateContainer(children, dom);
}
container.appendChild(dom); // DOM에 즉시 추가
jsx로 작성한 요소가 전부 렌더링 되기 전까지 메인 스레드는 계속해서 요소를 생성하고 DOM에 추가하는 작업을 진행해야 한다.
그렇다면 렌더링 해야하는 요소가 정말 정말 많다면?
메인 스레드는 요소 렌더링에 잡혀서 다른 작업들을 처리하지 못하게 된다.
이럴경우 애니메이션이 부드럽게 그려지지 않거나 유저 이벤트가 제대로 동작하지 않을 수 있다.
직접 코드로 비교를 해보자.
10만 개를 렌더링 하기 전에는 부드러운 애니메이션, 60 fps, input 창에 입력 시에도 즉각적으로 작성된다.
하지만 10만 개 렌더링 버튼을 클릭한 순간 애니메이션은 멈추고, fps도 점점 떨어지며 input 창에서는 순간적으로 입력이 되지 않음을 확인할 수 있다.
프로젝트가 커져 화면에 렌더링 해야하는 컴포넌트가 많아질수록 이러한 문제는 사용자의 경험과 직결될 수 있기 때문에 이를 해결하는 것이 중요하다.
React Fiber 등장 배경
결국 위 문제를 해결하는 핵심은 우선순위가 다른 여러 작업들이 있을 때, 우선순위에 따라 작업을 잘 스케쥴링 하는 것이다!
리액트가 스케쥴링을 더 잘할 수 있도록 등장하게 된 게 바로 Fiber이다
구체적으로는 아래 작업들이 가능해야 한다.
- 작업을 일시중지 했다가 재개 할 수 있어야 함
- 다른 종류의 작업에 우선순위 부여할 수 있어야 함
- 이전에 완료된 작업을 재사용 할 수 있어야 함
- 더이상 필요 없으면 작업 중지 할 수 있어야 함
이를 지원하기 위해서는 작업을 작은 단위로 나눌 수 있어야 한다.
즉, fiber는 작업의 단위 를 나타낸다고 할 수 있다.
fiber를 통해 리액트는 동시성(concurrent) 과 재조정(reconciliation) 을 지원할 수 있게 된다.
기존의 stack 기반 알고리즘과 fiber 기반 알고리즘의 차이는 데모 사이트에서 확인할 수 있다!
React Fiber 구조
fiber는 컴포넌트의 정보를 담고 있는 자바스크립트 객체이다.
function FiberNode(
this: $FlowFixMe,
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode
) {
// Instance
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null;
// Fiber
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;
this.ref = null;
this.refCleanup = null;
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
// Effects
this.flags = NoFlags;
this.subtreeFlags = NoFlags;
this.deletions = null;
this.lanes = NoLanes;
this.childLanes = NoLanes;
this.alternate = null;
// ...
}
fiber에 대해 조금 더 자세히 알아보도록 하자.
type, key
type과 key는 리액트 컴포넌트를 구분하기 위한 목적으로 사용되며, 재조정 단계에서 fiber를 재사용할 수 있는지 확인하는데 사용된다.
child, sibling, return
fiber는 트리 구조를 가지는데, 하나의 fiber는 다음 fiber(작업)을 빠르게 찾아가기 위해 다른 fiber를 참조한다.
fiber의 필드 중 child, sibling, return이 각각 다른 fiber를 가리키는 값이다.
child: 자식 fibersibling: 형제 fiberreturn: 부모 fiber
const Counter = () => {
let count = 0;
return (
<button
id="counter"
type="button"
style={{ color: "white", backgroundColor: "black" }}
onClick={() => {
alert(++count);
}}
>
click
</button>
);
};
const App = () => {
return (
<div>
<h1 id="title" className="title">
Hello, world!
</h1>
<Counter />
</div>
);
};
위 jsx를 fiber tree로 나타내면 아래와 같다.
여러개 자식 fiber가 있는 경우에는 child와 sibling은 어떻게 될까?
위 fiber 트리에서 보이는 것처럼 여러개의 자식 fiber가 있을 때, 첫 번째 자식 fiber가 자식 fiber가 되고 그 외 나머지 자식 fiber들은 첫 번째 자식 fiber의 형제로 등록된다.
그래서 div은 자식으로 h1, button 을 가지고 있지만, 자식 fiber는 h1이고, button은 h1의 형제 fiber가 된다.
리액트가 fiber를 탐색하는 순서
이렇게 fiber 트리에서 fiber를 하나씩 탐색하며 작업을 처리하고 다음 처리할 fiber를 찾아간다.
그러면 리액트는 어떤 순서로 fiber 트리를 탐색을 할까?
하나의 fiber에 대해 작업이 끝나면 child를 확인한다. child가 있다면 child가 다음 작업 대상이 된다.

예를 들어 위 fiber 트리 이미지에서 div fiber에 대한 작업이 끝났다면, 다음 작업은 h1 fiber이다.

만약 fiber가 child가 없다면, sibling이 다음 작업 대상이된다. h1 fiber는 child가 없기 때문에 작업이 끝나면 button으로 다음 작업이 이어진다.

fiber가 child도 sibling도 없다면, return fiber 즉, 부모 fiber로 올라간다.
return fiber가 sibling이 없다면 계속 sibling이나 root에 도착할 때까지 계속해서 return fiber로 거슬러 올라간다.
root fiber에 도착했다면, 모든 렌더링이 끝났다는 것을 의미한다.
// [Link](https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberWorkLoop.js#L3273)
function completeUnitOfWork(unitOfWork: Fiber): void {
// ...
if (next !== null) {
// Completing this fiber spawned new work. Work on that next.
workInProgress = next;
return;
}
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// If there is more work to do in this returnFiber, do that next.
workInProgress = siblingFiber;
return;
}
// Otherwise, return to the parent
// $FlowFixMe[incompatible-type] we bail out when we get a null
completedWork = returnFiber;
// Update the next thing we're working on in case something throws.
workInProgress = completedWork;
}
while (completedWork !== null);
// We've reached the root.
if (workInProgressRootExitStatus === RootInProgress) {
workInProgressRootExitStatus = RootCompleted;
}
}
next가 없으면 sibling을 workInProgress에 할당하고, sibling도 없으면 returnFiber를 workInProgress에 할당하는 것을 확일 할 수 있다.
child->sibling->return순으로 탐색이 진행된다.
alternate
리액트는 매끄러운 화면 전환을 위해 내부적으로 두 개의 fiber 트리를 관리한다.
flush(current): 현재 DOM에 렌더링된 트리work-in-progress: 작업중인 fiber 트리
이 두 트리는 재조정(Reconciliation) 처리를 위한 핵심 개념이다.
업데이트가 발생 시, 리액트는 workInProgress 트리를 생성한다. 이 트리는 업데이트 된 내용을 반영한 새로운 fiber 트리이다. 작업이 끝나면, workInProgress를 current 트리로 교체해 DOM에 변경된 UI를 반영한다.
이러한 방식을 double-buffering 이라고 하며, 이를 통해 부드러운 유저 경험을 제공한다.

🍕 회고
새로운 기술의 등장은 이전의 문제를 해결한다. 라는 걸 fiber에 대해 공부하면서 더 잘 와닿게 되었다.
특히 fiber와 Stack을 비교한 데모를 보면서 눈으로도 체감을 했다.
사실, 이만큼 정리하는 것도 쉽지 않았다. fiber에 대한 여러 블로그와 내용들을 찾아보며 읽고, 이해하고자 노력(?)하고 react 레포에서 코드도 따라가보려고 하고, 이해 안되는 부분은 다른 글을 찾고 정리하고 다시 봤던 글을 또 보고 또 봤다.
그 중 갖아 많이 참고했던 자료가 react core 팀의 개발자가 작성한 문서이다. react core 팀의 내용이라 더 봤던 것도 있지만, 이해하기 힘들고 나의 더딘 속도에 지칠 때 많이 위로가 되어 줬다. 🥲
If you find yourself frustrated in your attempts to understand it, don't feel discouraged. Keep trying and it will eventually make sense.
구래.. 아직 완벽하게 이해한 것도 아니고, fiber 아키텍쳐를 직접 구현하는 다음 스텝도 있지만 계속 봐서 이렇게 정리할 수 있었으니, 직접 만들어보면 제대로 이해하지 못한 부분도 찾고 더 깊이 이해할 수 있지 않을까?!🧐
처음에도 얘기한 말이지만 새로운 기술의 등장은 이전의 문제를 해결한다. 말이 참 좋은 것 같다. fiber는 여전히 어렵지만, 알아가고 이해함으로써 유익을 주는 새로운 기술을 배운다는 게 설레는 일인 것 같다. 기술로 문제를 해결하는 게 멋있고 좋아서 개발자를 꿈꿨었는데, 언젠가 나도 이런 서비스를 만들어가는데 기여하고 싶다.☘️