● 도입
React Fiber는 React의 핵심 알고리즘을 지속적으로 재구현한 것입니다. React 팀이 2년 넘게 연구한 결과의 결과물입니다.
React Fiber는 애니메이션, 레이아웃, 제스처와 같은 영역에 대한 성능을 높이는 것이 목표입니다. 대표적인 기능은 증분 렌더링으로, 렌더링 작업을 여러 그룹들로 분할하여서 여러 프레임의 구조로 펼쳐 놓는 것입니다. (다루기 편하게 그룹화하여 펼쳐놓는다는 뜻인 것 같습니다.)
다른 핵심 기능으로는 새 업데이트가 들어올 때 진행하고 있던 작업을 일시 중지, 또는 재사용하는 기능, 다양한 유형의 업데이트에 우선순위를 지정하는 기능, 새로운 동시성 프리미티브 등이 있습니다.
리액트에서 렌더링 최적화를 위해 가상돔(virtual DOM)을 사용하여 실제 돔과 바뀐부분을 확인하고, 바뀐부분만 업데이트를 해줍니다. 이는 디핑 알고리즘(diffing-Algorithm)을 기반하여 이뤄진다고 알려져있습니다.
만약에 요소 하나하나를 바꾸다보면 수십만번의 비교가 이루어져야하고, 이 작업은 상당히 비쌀 수 밖에 없습니다.(상당한 낭비) 그래서 리액트에서는 두 개의 트리가 나온다는 점과 모든 개발자들이 key props를 통해 바뀌었다는 것을 명시할 수 있다는 점으로 해당 문제를 해결하고자 했습니다.
우선 fiber가 무엇인지에 대해 제대로 알아보기 전에, virtual DOM의 동작원리에 대해서 알아보겠습니다.
📌 virtual DOM의 동작원리
1. 루트 노드가 아예 다른 경우
이 경우는 아예 처음부터 다르기 때문에 아예 새로운 트리를 구축하게 됩니다. 그리고 이전에 있던 state들은 모두 사라지게 됩니다.
2. DOM 요소 타입이 다른 경우
이전 트리와 연관된 모든 state는 사라지게 됩니다. 아래와 같이 루트 요소의 하위 컴포넌트들도 모두 언마운트됩니다.
// 1
<div>
<Counter />
</div>
// 2
<span>
<Counter />
</span>
3. 타입은 같지만 속성이 다른 경우
속성(스타일)만 달라지는 경우에는 두 속성을 비교하여 바뀐 부분만 변화시킵니다.
// 1
<div style={{color: "red"}} />
// 2
<div style={{color: "blue"}}/>
4. 자식 요소가 바뀌는 경우
동시에 두 리스트를 순회하고 바뀐 점이 있다면 그 부분만 재귀가 돌면서 처리해줍니다. 단, 첫 번째 자식부터 비교하며 변경하기 때문에 첫 자식 요소에 요소를 삽입하는 경우 처음부터 비교를 해야하기 때문에 성능상 좋지 않다고 합니다.
예를들어, 같은 내용의 <li> 태그가 있더라도, 자식요소가 바뀌었다고 생각하고 재렌더링 됩니다.
<ul>
<li>바나나</li>
<li>포도</li>
<li>오렌지</li>
</ul>
<ul>
<li>포도</li>
<li>바나나</li>
<li>오렌지</li>
</ul>
위와 같이 내용은 같으나, 첫번째 자식인 바나나가 포도로 바뀌어 순서가 달라졌기때문에 다르다고 인식하게 되버립니다. 이런 예제는 리액트 공식문서에서도 확인할 수 있었습니다.
이 문제를 리액트에서는 key를 통해 해결했습니다.
위의 문제점처럼 처음부터 하나하나 비교하는 것이 아니라, key속성을 통해 해당 Key가 존재하는지만 확인하면 됩니다.
다만 배열의 경우, index를 key로 사용했을때 재배열한다면 미묘하게 결과가 맞지 않을 수도 있습니다. 그 이유는 index는 유일한 값이 아니기 때문에 오류가 날 수 밖에 없습니다. 따라서 공식문서에 따르면 key로는 id처럼 유일한 값을 사용하는 것을 권장하고 있습니다.
📌 어떤 알고리즘을 사용했나?
디핑 알고리즘에서는 휴리스틱 알고리즘을 이용해 순회를 하게되고 변경된 결과를 업데이트 합니다.
휴리스틱 알고리즘의 정의는 문제를 더 빠르고 효율적으로 해결하기 위해 디자인된 방안이라고 합니다. 그래서 휴리스틱 알고리즘은 비싼 연산이 요구되는 경우나 효율적이면서 빠른 해결방안이 필요한 경우 사용됩니다. 다만, 정확한 연산이 필요한 경우에는 사용이 지양됩니다.
즉, 빠른 시간 내에 해결점을 찾아야할 때, 쉽게 합리적인 방안을 찾아야할 때, 사용되는 알고리즘이라고 생각하면 좋을 것 같습니다. 그래서 그리디랑 차이점이 뭐지? 이런 생각이 들었는데요.
그리디는 수학적 최적해 문제에 많이 사용됩니다. 즉, 최대 최소값을 구할 때 그리디가 가장 많이 사용됩니다. 그리디 알고리즘은 stage를 분할해 각 스테이지별로 최적해를 찾게 됩니다. 하지만 휴리스틱 같은 경우에는 모든 순간에 최적해라는 것을, 그리고 마지막까지도 최적해라는 것을 보장하진 않습니다. 휴리스틱 알고리즘이 최적해를 보장하지 않을 수 있지만 그래도 최적해와 근접한 결과값을 단시간에 제공해준다는 점이 있습니다.
🧐 따라서 제 생각으로는 페이스북에서 모든 상황을 예측하고 정답을 찾을 순 없으니, 모든 순간에 그래도 가장 최적의 알고리즘을 찾아낼 수 있는 휴리스틱 알고리즘을 택했다고 생각했습니다.
📌 그렇다면 왜 Fiber는 왜 등장하게 됐을까?
저는 fiber관련된 블로그들을 찾아보면서 fiber의 대략적인 기능은.. 익히 들었지만 도대체 왜 탄생했을까에 대한 궁금점이 생기기 시작했습니다. 사실 리액트는 fiber가 도입되기 이전에도 굉장히 좋은 라이브러리로 많은 사람들이 이용하고 있었습니다. virtual DOM과 핵심 알고리즘인 reconciliation(재조정) 과정을 통해서 렌더링 효율을 매우 높였기 때문입니다. 하지만 그래도 재조정에 한계가 있었습니다.
old reconciliation의 한계 (stack reconciliation): 처음에는 실제 DOM 트리를 복사한 virtual 트리가 만들어지게 됩니다. 새로운 변경사항이 생기면 새로운 virtual 트리가 만들어지게 되고 이전의 virtual 트리과 새로만들어진 vitual 트리를 비교하게 됩니다.
virtual tree는 익히 알다시피 객체입니다. 이제 두 객체의 차이점을 찾기 위해 디핑알고리즘이 진행이 되는데, 이 객체들을 비교하기 위해서는 재귀의 방식이 활용되게 됩니다.
재귀 알고리즘은 기본적으로 콜스택과 연관이 생기게 되는데요, 재귀방식을 사용하게 되었을때 함수를 호출하게되면 그 함수는 콜스택의 가장 아래로 쌓이게되고, 그리고 함수가 반환되면 그제서야 함수가 콜스택에서 pop되게 됩니다.
비동기 작업들은 event loop가 call stack이 비어있는 여부를 확인한 후에야 콜백함수들을 call stack에 올려 놓고 실행합니다. call stack이 비어있지 않다면, call back queue에 대기중인 함수들은 실행될 수 없게 됩니다. 여기에 들어가 있는 함수들은 유저의 클릭이벤트가 있을 수도 있고, setTimeout, 애니메이션 등등이 있습니다. 예를 들어 사용자가 콜스택이 가득차있는 상황속에서 UI가 변화하는 버튼을 마구 눌렀다면 즉각적으로 변화하지 않고 콜스택이 모두 비워진 후에서야 변화하는걸 볼 수 있을 것 입니다.
이렇게되면 즉각적으로 user event에 대응할 수도 없을 뿐더러, 프레임 드롭이라는 문제를 일으킬 수 있습니다.
프레임 드롭 :
프레임 드롭이란 무엇일까요? 사용하는 기기에 따라 다르겠지만, 현대에서 쓰이는 일반적인 모니터는 초당 60프레임율을 유지한다고 합니다. 이건 뭔소린가요? 저희가 화면에서 보는 영상들은 모두 이미지로 이루어져있습니다. 그 이미지 여러 장을 빠른 속도로 보여주니, 그것이 움직이는 동영상 처럼 보이게 되는 것입니다. 60프레임율이라는 말은, 1초의 시간동안 60장의 이미지를 보여준다는 말입니다. 그렇게 화면의 영상을 표현합니다. 30프레임율은? 1초의 시간동안 30장의 이미지를 보여준다는 말이 됩니다.
1초는 1000ms입니다. 그렇다면 60프레임을 1000으로 나누면 1프레임당 소요할 수 있는 시간은 약 16ms가 될 것입니다. 이 숫자가 중요합니다. 이 숫자에 프레임 드롭의 의미가 담겨있습니다. 한 프레임 안에서 작업을 수행하는데 걸리는 시간이 16ms가 넘어가면, smooth한 화면을 보여줄 수 없게 됩니다. 16ms가 넘어가게 되면 나머지 프레임을 버려버리기 때문에 뚝뚝 끊기는 현상으로 보이게 되는데, 이를 프레임 드롭이라고 합니다. 추가적으로 브라우저가 처음에 실행을 위해 정리정돈을 하는데 필요한 시간은 6ms정도가 됩니다. 그러면 우리에게 10ms가 남게 됩니다. 무슨 말이냐? 따라서 작업이 10ms안에 끝나지 않으면, 프레임 드롭이 발생할 것이라는 것입니다. 그러니까 뭔가 둑둑 끊기는 모습이 화면에 등장할 것이라는 것이죠. 이것은 UX상으로도 좋지 않은 현상입니다.
기존의 문제점 결론 :
앞서 말했듯이, 두 virtual tree를 비교하는 작업은 재귀적으로 이루어집니다. call stack에 쌓여있는 모든 함수들이 return될 때까지 call stack을 비워주지 않습니다. 다른 말로하면, 시작하면 무조건 끝을 봐야한다는 것이죠. 시작했다? 그러면 중간에 멈추지 않겠다라는 말입니다. 그런데 이 재귀적으로 tree를 순회하는 시간이 16ms를 넘어서면, 프레임 드롭이 발생할 뿐더러, 어플의 크기에 따라 순회하는 시간이 길어지면 유저 이벤트에 즉각적으로 대응하는 하는 것이 어려워집니다.
이 지점에서 react fiber가 등장합니다. react fiber가 해결하고자 하는 것은 이런 순회 작업을 멈출 수도 있고, 재개할 수도 있고, 필요에 따라서는 그냥 내다버릴 수도 있게 만드는 것입니다. 더 나아가 우선순위에 따라 이것을 처리함으로써, 더욱 똑똑하게 렌더링을 구현합니다. 다른 말로 하면 리액트 렌더링 알고리즘에 스케줄링을 구현한 것이죠.
react fiber의 목적을 정리하면 다음과 같습니다.
- 작업을 멈추고, 나중에 다시 시작한다.
- 다양한 종류의 작업에 따라서 우선순위를 부여한다.
- 완성된 작업물을 재사용할 수 있다.
- 더 이상 필요하지 않은 작업물이면 버릴 수 있다.
📌 동시성 스케쥴링
위와같은 이유로 React 개발팀은 기존의 알고리즘으로는, 시간이 갈수록 커지고 복잡해지는 앱 컴포넌트를 응답성을 잃지 않으면서 빠르게 업데이트하는 것이 어렵다고 판단하게됩니다. fiber가 기존의 스택 재조정자와 근본적으로 다른 점은 동시성입니다. DOM 업데이트, 렌더링 로직을 작업 단위로 구분하고 이를 비동기로 실행하여 최대 실행 시간이 16ms가 넘지 않도록 제어합니다.
fiber는 단순히 작업을 chunk로 분리하여 실행 시간만을 관리하는 것이 아니라 작업의 유형에 따라 우선순위를 부여하고, 기존에 수행 중인 작업보다 더 우선순위가 높은 작업이 들어오게될 경우 기존의 작업을 일시 중단하고 들어온 작업을 처리 후 다시 돌아오는 기능이 있습니다. 즉, 스케쥴링이 가능해집니다.
📌 콜스택 문제를 어떻게 해결했을까
그렇다면 디핑알고리즘의 재귀로인해 비워지지 않는 문제를 어떻게 해결했을까요?
'아 이렇게 외부 환경에 종속될 수 없다. 그러면 우리가 직접 스케줄링이 가능한 stack을 만드는게 어떨까?'
위와 같은 생각을 한 리액트 팀은, 먼저 리액트는 virtual Stack을 구현했다고 합니다. 실제 stack이 아닌 메모리상의 가상 stack을 구현한 것입니다. 더 나아가 이 stack은 스케쥴링이 가능하게 구현하였습니다.
이전에 디핑 알고리즘을 사용했기 때문에 재귀가 발생하고 -> 콜스택에 대한 문제로 이어졌었기 때문에 리액트 팀은 알고리즘 개선에 대한 방식으로 문제를 해결하려고 했습니다. 즉, 단일 연결 리스트로 구현된 react Fiber을 구현한 것입니다.
구현방법을 알기 전에, 먼저 내가 작성한 함수형 컴포넌트가 어떻게 렌더링 되는지에 대한 과정을 알면 이해하기가 훨씬 쉽습니다.
📌 리액트 동작과정
- Render 단계: JSX 선언 또는 React.createElement()를 통해 일반 객체인 Reat 엘리먼트를 생성한다.
- Reconcile 단계: 이전에 렌더링된 실제 DOM 트리와 새로 렌더링할 React 엘리먼트를 비교하여 변경점을 적용한다.
- Commit 단계: 새로운 DOM 엘리먼트를 브라우저 뷰에 커밋한다.
- Update 단계: props, state 변경 시 해당 컴포넌트와 하위 컴포넌트에 대해 위 과정을 반복한다.
제가 만약에 아래와 같은 리액트 컴포넌트를 작성했다고 가정해보겠습니다.
import React from "react";
export function SimpleComp(): JSX.Element {
const [name, setName] = React.useState<string>("Alice");
return (
<div>
<h1>Hello react!</h1>
<section>
<p>{`Name : ${name}`}</p>
<button
onClick={(e) => setName(name === "Samuel" ? "Alice" : "Samuel")}
>
Click me
</button>
</section>
</div>
);
}
<SimpleComp /> 컴포넌트를 루트에 렌더한다고 가정했을 때, 먼저 리액트에서 내부적으로 루트를 생성하고 이후에 리액트 엘리먼트를 생성하게 됩니다. 저는 리액트 엘리먼트? 라는 말을 들었을 때 잘와닿지 않았는데요, 리액트는 JSX 또는 React.createElement()로 작성된 코드를 React 엘리먼트로 변경하는 작업을 하게 됩니다. 리액트 엘리먼트는 클래스가 아닌 일반 객체로, 사용자가 작성한 컴포넌트 또는 엘리먼트 타입과 어트리뷰트, 자식에 관한 정보를 담고 있는 객체이다.
즉, 리액트 엘리먼트는 컴포넌트의 정보를 담고 있는 객체입니다. 아래는 실제 React Element의 객체를 보여줍니다.
순서 : JSX > React.createElement > ReactElement가 됨
// Transpiled <SimpleComp />
export function SimpleComp() {
const [name, setName] = React.useState("Alice");
return React.createElement(
"div",
null,
React.createElement("h1", null, "Hello react!"),
React.createElement(
"section",
null,
React.createElement("p", null, `Name : ${name}`),
React.createElement(
"button",
{ onClick: (e) => setName(name === "Samuel" ? "Alice" : "Samuel") },
"Click me"
)
)
);
}
const ReactElement = function(type, key, ref, self, source, owner, props) {
// 실제 객체임을 알 수 있는 부분
const element = {
// 실제 $$typeof로 REACT_ELEMENT_TYPE으로 객체를 구분하게 된다.
$$typeof: REACT_ELEMENT_TYPE,
// Built-in properties that belong on the element
type: type,
key: key,
ref: ref,
props: props,
// Record the component responsible for creating this element.
_owner: owner,
};
이 리액트 엘리먼트는(객체) render함수의 인자로 넘어가게되면, 모두 하나하나씩 fiber Node로 변환됩니다. 아까 위에서는 리액트 엘리먼트가 컴포넌트 정보를 담고 있는 객체라고 했는데요, fiber Node는 리액트 엘리먼트에 더해서 더 자세한정보들이 붙은 객체라고 생각하면 됩니다.
또한 재조정자는 FiberNode를 하나의 작업 단위(unitOfWork)로 취급하게 됩니다.
즉, FiberNode는 자체로 렌더링에 필요한 정보를 담고 있는 객체이자 재조정 작업 단위인 것이다.
부모에서 자식으로 차례대로 fiber Node로 변환되면서 자식이 없다면 DOM 인스턴스로 생성됩니다.
import React from "react";
export function SimpleComp(): JSX.Element {
const [name, setName] = React.useState<string>("Alice");
return (
<div>
<h1>Hello react!</h1>
<section>
<p>{`Name : ${name}`}</p>
<button
onClick={(e) => setName(name === "Samuel" ? "Alice" : "Samuel")}
>
Click me
</button>
</section>
</div>
);
}
예를 들어, 아까 예시의 코드를 기반으로 보자면 <div> 태그를 fiberNode로 변환하고, 자식이 있기 때문에 DOM 인스턴스릉 생성하지 않고 다음 <h1>태그의 리액트 엘리먼트를 fiberNode로 변환시켜줍니다.
<h1>태그는 자식 엘리먼트가 없기 때문에 바로 DOM 인스턴스를 생성하게됩니다. 이과정이 끝나게 되면 HTML 엘리먼트로 commit하게 됩니다.
너무 어렵다..
이후 업데이트가 되면 매번 리액트 엘리먼트를 생성하고, 실제 DOM 트리와 비교하며 fiber Node로 변환시키고 DOM 인스턴스를 만들게 됩니다. 그렇다고 모든 리액트 엘리먼트를 생성하냐..?고한다면 아까 virtual DOM 동작원리에서 적은 것과 같이 그런 경우에만 비교하고 리액트 엘리먼트를 생성하고 아까 위와같은 작업을 반복한다고 보면되겠다..
내가 제대로 이해한바가 맞는지 모르겠다.. 너무 어렵다..
https://velog.io/@joy37/Fiber%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90
https://velog.io/@joy37/Heuristic-Algorithm%EC%9D%B4%EB%9E%80
https://d2.naver.com/helloworld/2690975
https://d2.naver.com/helloworld/2690975
https://github.com/acdlite/react-fiber-architecture#what-is-a-fiber