원본 링크
편의를 위한 의역이 존재합니다.
페이스북은 리액트 fiber를 2015년에 만들기 시작했습니다. (지금은 기본적인 reconciler입니다.)
주요한 과제는 어플리케이션의 성능을 높이기 위해서 시스템 리소스의 소비를 줄이고, UI의 응답성을 높이는 것이었습니다.
어떻게 리액트의 fiber가 이를 가능하게 했는지 이해해봅시다.
이 아티클에서 이런걸 배우시게 될거에요.
리액트가 UI를 렌더링 하기 위해서 사용하는 코어 컴포넌트들은 무엇인가 ?
Fiber Reconciliation 알고리즘은 무엇인가 ?
fiber의 이점들은 무엇이며, 이는 어떻게 당신의 리액트의 성능을 향상 시키는가 ?
fiber 객체는 무엇이고, 이의 중요성은 무엇인가 ?
fiber 알고리즘은 어떻게 작동하며, 이를 어떻게 사용하는가 ?
리액트 fiber 를 이해하기 위해선, 우리는 reconciler와 renderer가 무엇인지 알아야합니다.
이것들은 리액트가 우리의 어플리케이션을 렌더링 할때 사용하는 중요한 코어 컴포넌트들입니다.
Reconciler : UI의 업데이트 된 상태를 반영하기 위해서, 리액트 엘리먼트의 current tree에 적용되어야하는 모든 변경사항들은, 재조정(reconciliation)과정 동안 reconciler에 의해 결정 됩니다.
Renderer : 당신의 트리가 준비가 된다면, 당신은 이러한 변경들을 UI에 적용시킬 필요가 있습니다. renderer가 이러한 역할을 해줍니다. React DOM이 이러한 예인데, 이는 브라우저 내에서 DOM을 업데이트 하거나, ios나 android 환경에서 리액트 네이티브를 업데이트 해줍니다.
이러한 분리는 리액트 코어로부터 제공된, 똑같은 reconciler를 공유하면서도 그들의 renderer 도구를 사용할수 있다는것을 의미합니다.
Scheduling : Scheduling은 동작 처리(work)들이 수행 될때를 결정하는 역할을 합니다.
여기서 work란 수행되어야만 하는 모든 계산들을 의미합니다. work는 보통 업데이트의 결과입니다(상태 변화, 라이프사이클 함수, DOM내에서의 변화)
리액트 디자인 원칙에 관한 공식 문서에 따르면 이렇습니다.
리액트에서 컴포넌트를 함수로 정의할 때, 이 컴포넌트들을 직접 호출하지는 않습니다.
각 컴포넌트는 렌더링이 필요한 요소들의 설명을 반환합니다. 이 설명에는 사용자가 작성한 컴포넌트(LikeButton 같은)와 플랫폼 특정 컴포넌트(div 같은)가 모두 포함될 수 있습니다.
LikeButton 과 같은 컴포넌트를 언제, 어떻게 확장할지는 리액트가 결정합니다.
리액트는 나중에 이러한 컴포넌트들을 실제 사용자 인터페이스 트리에 적용하여, 컴포넌트들의 렌더링 결과에 따라 반복적으로 변경사항을 적용합니다.
(간단히 말해, 리액트에서는 컴포넌트를 정의하고 사용할 때, 실제 DOM에 직접 접근하여 요소를 생성하거나 조작하지 않습니다. 대신, 컴포넌트는 UI가 어떻게 보여야 하는지에 대한 '설명'을 제공하고, 리액트는 이 설명에 따라 실제 DOM을 업데이트합니다. 이 과정은 리액트가 내부적으로 관리하며, 개발자는 이 과정에 대해 직접적으로 걱정할 필요가 없습니다.)
이는 미묘해보여도 강력한 차이점을 가집니다.
우리가 컴포넌트를 호출하지 않고, 리액트가 호출하게둔다는것은, 리액트가 만약 필요하다면 이 컴포넌트의 호출을 늦출수도 있는 힘을 갖는다는 뜻입니다.
현재 구조에서는, 단일 수정에도 전체의 트리가 재귀적으로 호출되면서 컴포넌트 트리가 다시 그려지고 있습니다.
하지만 이는 추후에 프레임 드롭(프레임 속도저하)를 피하기 위해서 일부 업데이트를 지연시키기 시작할수도 있습니다.
이건 리액트 디자인의 대표적인 테마입니다.
몇몇 유명한 라이브러리는 새로운 데이터가 사용 가능해질때 바로 연산이 시작되는 "push" 방법을 사용하지만, 리액트는 필요할때까지 그 연산을 지연시킬수 있는 "pull" 접근법을 사용합니다.
여기서 주요한 점들은
UI에서, 모든 업데이트들이 즉시 적용될 필요가 없습니다. 사실 이런 방식들은 프레임 드롭을 유발하고 사용자 경험을 떨어뜨립니다.
많은 업데이트들의 다른 유형들이 각각의 중요도를 가집니다. (예를 들어서 애니메이션 업데이트는 Api 요청 같은 데이터 업데이트보다 더 빨리 수행되어야할 필요가 있는것처럼요)
"push" 접근법을 가진 어플리케이션들은, 어떻게 작업 단위들이 스케줄링 되어야하는지 결정해야합니다. 하지만 React같은 "pull" 접근법에 기반한 프레임워크들은 더 똑똑하고 합리적으로 이런 결정들을 할수 있도록 해줍니다.
간단히 말해서, 리액트 Fiber는 리액트 코어의 재조정 알고리즘
을 다시 구현한 것입니다.
이건 스택의 재구현이라고 말씀하실수도 있는데, 스택이 마음대로 중단되거나 스택들이 수동적으로 조작될수 있는 리액트 컴포넌트를 위해 특정화 되어진 것입니다.
이는 React 16부터 기본적으로 내장 되었습니다.
리액트의 fiber reconciliation
알고리즘의 주요한 목적은 리액트가 (이전 스택 reconciler와 호환이 안됐었던) 스케줄링의 이점을 활용할수 있도록 하는 것입니다.
리액트는 이제 이런것들이 가능해졌습니다.
작업을 멈추고 나중에 다시 돌아올수 있습니다.
작업들을 덩어리 단위로 분리하고, 작업들에 대한 중요도를 나누어볼수 있습니다.
이전에 완료 됐던 작업들은 다시 재사용할수 있습니다.
더이상 작업이 필요하지 않다면, 해당 작업을 중단시킬수 있습니다.
결과적으로, 전체적인 UI의 반응성과 어플리케이션의 성능이 향상됐습니다. 특히 애니메이션을 사용하는 어플리케이션들에서요.
보시다시피, Stack 기반 reconciler와 fiber 기반 reconciler의 차이점을 애니메이션으로 구현해본것입니다.
fiber 기반일때 더욱 부드럽게 애니메이션이 진행됩니다. 이는 데모 사이트에서 직접 보실수 있습니다.
앞선 섹션에서 말씀드린 React fiber가 작업을 수행하기 위해선, 이를 작업 단위로 나눌수 있는 방법이 필요합니다.
이것이 바로 fiber입니다. (대문자로 시작하지 않습니다.)
하나의 fiber는 작업의 단위를 나타냅니다.
리액트 fiber는 더 맞춤화된 스택의 재구현이기 때문에, 우리는 하나의 단일 fiber를 가상의 스택 프레임이라고 생각해볼수 있습니다.
이는 스택 프레임과 일치하지만 리액트 컴포넌트의 인스턴스와 일대일 관계를 유지합니다.
실질적인 fiber의 구현체는 객체입니다.
이는 리액트 컴포넌트의 정보를 포함하고 있는 간단한 자바스크립트 객체입니다.
리액트에 의해서 생성된 첫번째 Fiber 노드는 루트 노드
입니다.
이는 컨테이너 DOM 노드를 나타냅니다. (ReactDOM.render()라는 메서드로 DOM 엘리먼트를 전달했지만, React 18버전부터는 root.render() 메서드를 사용합니다)
fiber 객체는 fiber 노드들간의 관계와 정보를 추적할수 있도록 하는 특정한 프로퍼티들을 가지고 있습니다.
fiberNode{
stateNode,
child,
sibling,
return,
type,
alternate,
key,
updateQueue,
memoizedState,
pendingProps,
memoizedProps,
tag,
effectTag,
nextEffect
}
stateNode는 해당 fiber가 속해 있는 컴포넌트 인스턴스를 나타냅니다.
child와 sibling은 현재 fiber노드와 관계 되어있는 다른 fiber들을 가리킵니다.
자식 fiber는 컴포넌트의 render 메서드에 의해 리턴받는 값과 일치합니다.
예를 한번 들어볼게요
function Parent() {
return <Child />
}
sibling 항목은 여러개의 children을 렌더링 할때의 경우를 의미합니다.
function Parent() {
return [<Child1 />, <Child2 />]
}
return : return 속성은 현재의 fiber에서 처리를 완료 한후, 프로그램이 돌아가야만 하는 상위 fiber를 말합니다. 이를 부모 fiber라고 말할수 있습니다. 만약 하나의 fiber가 여러개의 자식 fiber를 가진다면, 각각 자식 fiber들의 return 속성 값은 부모 fiber가 됩니다.
이전의 섹션에서 예를 들어보자면, Child1과 Child2의 Return 값은 Parent 인것입니다.
type: 타입은 컴포넌트의 구성요소를 설명합니다. class가 될수도, function이 될수도,DOM element 가 될수도 있습니다.
key : 이 key는 재조정 과정에서 해당 fiber가 변화나, 요소 추가, 삭제 같은 것들을 식별함으로써 재사용 될수 있는지 아닌지를 결정합니다.
alternate : fiber를 flush 한다는 것은, 렌더링 된 결과물을 화면에 출력한다는 것을 의미합니다.
어느 시점에서든, 컴포넌트 인스턴스는 2개의 동일한 fiber를 가지고 있습니다. current Fiber와 work-in-progress Fiber입니다. work-in-progress Fiber는 아직 작업이 완료되지 않은 fiber입니다. 개념적으로 아직 리턴되지 않은 스택프레임 입니다.
current fiber의 alternate는 work-in-progress이며, 그 반대로 work-in-progress의 alternate는 current fiber 입니다.
fiber의 alternate는 cloneFiber라고 불리는 함수에 의해서 필요에 따라 생성됩니다.
항상 새로운 객체를 만들기보다, cloneFiber는 객체 할당을 최소화하여 만약 fiber의 alternate가 존재한다면 이를 재사용하려고 시도합니다.
updateQueue : 이 큐들은, 모든 상태와 DOM 업데이트들, 그리고 다른 효과(effect)들을 큐에 추가하여 관리합니다.
memoizedState : 이전 렌더링때의 상태값을 참조합니다.
memoizedProps and pendingProps : 개념적으로, props는 함수의 인자로 생각할 수 있습니다.
fiber의 pendingProps는 실행의 시작 부분에서 설정되며, memoizedProps는 끝 부분에서 설정됩니다.
들어오는 pendingProps가 memoizedProps와 같을 때, 이는 Fiber의 이전 출력을 재사용할 수 있다는 신호이며, 불필요한 작업을 방지합니다.
tag : 이것은 fiber의 타입을 명시합니다. 예를 들어, 클래스 컴포넌트, 함수 컴포넌트, 호스트 포털 등이 있습니다. (tag 속성이 type속성보다 더 넓은 범위의 의미를 가지고 있습니다.)
effectTag : 이것은 적용되어야 할 부수 효과(side-effect)에 대한 정보를 담고 있습니다.
nextEffect : 이것은 이펙트 리스트 안에 있는 업데이트 되어야할 다음 노드를 가리킵니다.
여기서 리액트 fiber들이 리액트 엘리먼트들과 비슷하지 않다는 점을 알아두는것이 중요합니다.
비록 fiber들이 리액트 엘리먼트들로부터 만들어지고 type이나 key같은 몇몇 속성들을 공유한다고 할지라도, 두개는 엄연히 다릅니다.
리액트 엘리먼트들은 매번 다시 만들어지지만, fiber는 가능한 최대로 재사용 되어집니다. (물론 초기 렌더링 때는 만들어져야만 합니다.)
React에서는 'fiber'라는 개념이 작업 단위로 사용됩니다.
React가 DOM에 어떤 것을 렌더링하기 전에, 각각의 'fiber'(작업 단위)를 처리하여 '완성된 작업'이라고 불리는 결과물을 만들어냅니다.
그 후, React는 이 '완성된 작업'을 커밋하여 DOM에서 시각적인 변화가 발생하게 됩니다. 이 모든 과정은 두 단계로 나누어져 일어납니다.
우리의 UI를 렌더 하기 위해서, 2가지 종류의 fiber tree가 있습니다.
하나는 current tree, 또 다른 하나는 workInProgress tree 입니다.
current tree는 현재 우리의 UI에 렌더된 트리입니다.
리액트는 일관되지 않은 UI를 나타낼수도 있기 때문에, 이 tree를 변경하지 못합니다.
대신에 리액트는 workInProgress라는 트리를 교체하고 모든 변경 사항들이 적용되면 포인터를 교체합니다.
모든 사항이 적용된 workInProgress트리는 current tree가 되고, 해당 current tree의 복제본을 만들어서 또 다른 workInProgress 트리를 생성해냅니다.
(개인적인 의견) 해당 그림에서는 기존 current tree가 workInProgress로 교체 된다고 되어있는데, 이는 사실이 아니라고 알고 있습니다
workInProgress트리가 current tree로 업데이트 되면서, 이의 복제본을 만들어서 이를 새로운 workInProgress트리로 할당합니다. 그리고 기존의 current tree는 메모리에서 해제합니다.
만약 제가 잘못 알고 있다면 댓글 부탁드립니다 :)
어떻게 리액트는 UI의 불일치성을 피할까요 ?
2가지의 phase로 간단히 분리하는 작업을 통해 이를 가능케 합니다.
이 단계에서 리액트는 아래의 단계를 따르는 workInProgress트리를 만들기 시작합니다.
setState() 메서드 호출: 컴포넌트의 상태를 업데이트하기 위해 setState() 메서드가 호출되면, React는 requestIdleCallback()을 사용하여 작업을 예약합니다. 이 함수는 메인 스레드에게 유휴(Idle) 시간이 생기면 해당 작업을 수행하라고 알립니다.
'workInProgress' 파이버 트리 생성: 이제 React는 현재의 파이버 트리에서 요소들을 복제하여 'workInProgress' 파이버 트리를 만들기 시작합니다. 그리고는 각 노드를 순회하면서 변경이 필요한지 결정합니다.
변경된 노드 처리: 특정 노드가 업데이트된 경우, 그 노드는 'effects list'라고 불리는 다른 리스트에 추가됩니다. 이 리스트는 변경이 필요한 모든 사항들의 선형 연결 리스트입니다.
첫 번째 phase 완료: 'workInProgress' 트리 전체를 순회하고, 모든 업데이트된 노드가 태그되면 첫 번째 phase가 완료됩니다.
이 단계에서는 'effects list'에 있는 노드들의 모든 업데이트가 수행되고 DOM에 반영됩니다.
메인 스레드는 이러한 모든 변경사항을 한 번에 적용합니다.
이 단계는 렌더링 단계와 달리 일시 중지되거나 재개될 수 있는 것이 아니라 동기적으로 진행됩니다.
현재 fiber를 이용해서 리액트에서 사용할수 있는 몇몇 기능들이 있습니다.
에러 바운더리 => 이전에, render 메서드에서 에러가 발생했다면 내부적으로 리액트가 엉망이 됐습니다. 하지만 에러 바운더리를 통해 우리는 이를 방지할수 있습니다.
이는 getDerivedStateFromError나 componentDidCatch같은 메서드들을 통해 이루어집니다.
코드 스플리팅과 동시성 => 이는 React 18부터 활성화 되었습니다.