모던 리액트 Deep Dive 스터디(3) [리액트 파이버]

이승훈·2024년 3월 3일
1
post-thumbnail

들어가기 전에

리액트는 실제 브라우저에 화면을 보여주기 위해서 실제 DOM과 virtual DOM을 비교하여 변경된 부분만 DOM을 업데이트한다.

리액트 파이버 아키텍쳐는 react version 16 부터 적용된 코어 아키텍쳐이며 virtual DOM을 다루는 방식의 변경과 그로인한 DOM의 업데이트 성능을 향상시킨 알고리즘이다.

리액트 파이버

리액트 파이버는 리액트에서 관리하는 평범한 Javascript 객체다.
파이버는 파이버 재조정자(fiber reconciler)가 관리하는데, 이는 앞서 이야기한 virtual DOM과 실제 DOM을 비교해 변경을 수집하며, 만약 이 둘(virtual DOM vs 실제 DOM) 사이에 차이가 있으면 변경에 관한 정보를 가지고 있는 파이버를 기준으로 화면에 렌더링을 요청하는 역할을 한다.

Reconcilation

ReactDOM.render(<App />, document.getElementById('root'))

<App/> 은 우리가 작성한 엘리먼트인데, 이 엘리먼트라는것이 정확하게 무엇일까?

우리가 만든 이 엘리먼트는 단순하게 어떤 속성을 가지고 있는지, 어떤 children을 갖고 있는지 등에 대한 내용을 나타내는 하나의 객체인 것이다.

<div> 라는 태그를 쓰던, <Div> 라는 리액트 컴포넌트를 만들어 쓰던 모두 단순한 객체라는것이 핵심이다.

결국 화면에 어떻게 렌더링을 해야하는지에 대한 설명들일 뿐이다. 이렇게 객체로 구성한 뒤에 React에서 Tree를 구축하고 파싱해서 어디를 수정해야하는구나 라고 판단이 된 뒤에 실제 렌더링(뒤에서 설명하겠지만 정확하게는 커밋)이 일어나게 된다.

어떤 부분이 어떻게 다른가를 판별하기 위한 과정이 무엇일까?
모든 엘리먼트들을 돌아 기본적인 DOM 태그를 얻어내는 과정이 필요할것이다.

따라서 React는 render()를 호출한 뒤 최종적인 자식 요소가 무엇인지 알아내기 위해 재귀적으로 React 내 트리를 탐색하며 기본적인 DOM Tag를 얻어낸다.

아래의 코드에서 div태그와 같은 기본적인 DOM Tag를 얻어내는 과정을 말하는 것이다.

<Hello>
     <Button>
        I am button!
     </Button>
</Hello>


const Hello = (props) => {
   return (
        <div className="hello">{props.children}</div>
   )
}

이처럼 가장 기본적인 DOM Tag를 얻어낸 뒤 기존 뷰와 다른점을 찾아내는 과정을 React에서는 Reconilation(재조정)이라고 부른다.

ReactDOM.render()나 setState()가 호출되면 이 reconilation 과정을 수행하게 되는데, 재귀적으로 엘리먼트들을 돌며 얻어낸 기본적인 DOM Tag들을 기반으로 기존에 렌더링되었던 트리와 새 트리를 비교하며 변경된 사항들을 확인한다.

이 후 변경사항을 트리에 반영한다.

이렇게 reconcilation이 진행된 뒤 실제 DOM에 변경되어야할 최소 변화들을 적용하는것이다.

휴리스틱 알고리즘

n개의 엘리먼트가 있는 트리를 다른 트리로 변환하는 알고리즘의 복잡도는 최소 O(n^3)로 알려져 있다고 한다.
이는 간단치 않은 알고리즘으로 React는 아래의 두 조건을 전제로 복잡도가 O(n)인 휴리스틱 알고리즘을 reconcilation에 사용한다.

1. 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
2. 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.

즉, 다음과 같은 가정을 통해 O(n)의 휴리스틱을 달성했다.

1. 이전과 다른 타입의 React 엘리먼트로 교체되었다면 하위 트리는 더 이상 비교하지 않고 전체를 교체한다.
2. key가 동일한 React 엘리먼트는 이전과 동일한 엘리먼트로 취급한다.

즉, 이전 트리와 새 트리를 비교할 때 위와같은 약속을 통해서 비교하는 절차를 대폭 감소시켜 빠른 렌더링 성능을 구현한 것이다.

Stack Reconcilation

React version 16 이전의 조정 알고리즘은 Stack Reconciler 알고리즘으로 이루어져 있었다.
스택이라는 이름에서 유추할 수 있듯 과거에는 하나의 스택에 렌더링에 필요한 작업들이 쌓이면 이 스택이 빌 때 까지 동기적으로 작업이 이루어졌다.

자바스크립트의 특징인 싱글스레드라는 점으로 인해 이 동기 작업은 중단될 수 없고, 다른 작업이 수행되고 싶어도 중단할 수 없었으며, 결국 이는 리액트의 비효율성으로 이어졌다.

Stack Reconciler가 어떤 순서로 리렌더링을 처리하는지 보자.

  1. 모든 DOM트리 최상단에서부터 재귀 형식으로 컴포넌트를 만날떄마다 ".render()" 호출하기
  2. Tree 변경사항 확인하고, 업데이트 필요한 컴포넌트 확인하고 해당 컴포넌트 자식들도 업데이트 해야하는지 확인하기
  3. 컴포넌트들 하나씩 업데이트하기
function Example(){
    return (
        <Zero>
           <One/>
           <Two/>
        </Zero>
    )
}

재귀를 돌며 컴포넌트를 만날 때마다 .render()를 stack에 담아두었다가 한번에 호출하는 방식의 FILO(First In Last Out)방식이기 때문에 Stack Reconcilation 인 것이고 이 방식은 변경사항이 발생할 때마다 업데이트를 반영하기 때문에 프레임 드랍이 일어날 수 있다.

프레임 드랍이 어떻게 발생하는것일까??

현재 일반적인 모니터는 보통 초당 60회 화면을 갱신한다.
즉 한번 화면을 갱신하는데 대략 16ms가 소요된다는 이갸이다.

우리가 작성한 코드가 변경사항을 화면에 반영하는데 16ms 이상의 시간을 소비하면서 연속적으로 실행되고 있다면 UI 업데이트 횟수는 모니터의 주사율을 따라갈 수 없고 화면이 끊기는 현상이 발생되는 것이다.

이러한 문제 때문에 React Fiber Reconcilation 알고리즘이 나오게 되었다.

끊기는 현상을 극복하기 위해서는 개발자가 직접 프론트엔드에서 작성하는 코드의 동시성을 조절하는 것이 필요했으나 React Fiber 구조에서는 UI 갱신 작업을 작은 단위로 나누어 내부적으로 스케줄링함으로써 React 사용자가 신경쓰지 않더라도 대규모 UI 갱신에도 16ms를 초과하지 않도록 작업되었다.

Fiber Reconcilation

Fiber Reconciler가 기존의 Stack Reconciler와 근본적으로 다른점은 동시성이다.
DOM 업데이트, 렌더링 로직을 작업 단위로 구분하고 이를 비동기로 실행하여 최대 실행 시간이 16ms가 넘지 않도록 제어한다.

Fiber Reconcilation 알고리즘의 기본적인 4가지 요구사항은 아래와 같다.

  1. 작업 일시 중지 후 나중에 다시 시작 가능
  2. 작업별 우선순위 지정 가능
  3. 이전에 완료한 작업 재사용
  4. 더 이상 필요하지 않은 경우 작업 폐기 가능

위에서 말하는 작업단위가 Fiber이다.

아래의 코드는 실제 리액트 파이버노드의 javascript code 이다.

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;

  if (enableProfilerTimer) {
    // Note: The following is done to avoid a v8 performance cliff.
    //
    // Initializing the fields below to smis and later updating them with
    // double values will cause Fibers to end up having separate shapes.
    // This behavior/bug has something to do with Object.preventExtension().
    // Fortunately this only impacts DEV builds.
    // Unfortunately it makes React unusably slow for some applications.
    // To work around this, initialize the fields below with doubles.
    //
    // Learn more about this here:
    // https://github.com/facebook/react/issues/14365
    // https://bugs.chromium.org/p/v8/issues/detail?id=8538
    this.actualDuration = Number.NaN;
    this.actualStartTime = Number.NaN;
    this.selfBaseDuration = Number.NaN;
    this.treeBaseDuration = Number.NaN;

    // It's okay to replace the initial doubles with smis after initialization.
    // This won't trigger the performance cliff mentioned above,
    // and it simplifies other profiler code (including DevTools).
    this.actualDuration = 0;
    this.actualStartTime = -1;
    this.selfBaseDuration = 0;
    this.treeBaseDuration = 0;
  }

  if (__DEV__) {
    // This isn't directly used but is handy for debugging internals:
    this._debugInfo = null;
    this._debugOwner = null;
    this._debugNeedsRemount = false;
    this._debugHookTypes = null;
    if (!hasBadMapPolyfill && typeof Object.preventExtensions === 'function') {
      Object.preventExtensions(this);
    }
  }
}

보다시피 파이버가 단순한 javascript 객체로 구성되어 있는 것을 볼 수 있다.

파이버는 React Element와 다르다.
React Element는 렌더링이 발생할 때마다 새롭게 생상되지만 파이버는 가급적이면 재사용된다.

파이버는 컴포넌트가 최초로 마운트되는 시점에 생성되어 이후에는 가급적이면 재사용된다.

이렇게 생성된 파이버는 state가 변경되거나 생명주기 메소드가 실행되거나 DOM의 변경이 필요한 시점등에 실행된다.

그리고 중요한것은 리액트가 파이버를 처리할 때마다 이러한 작업을 직접 바로 처리하기도 하지만 스케줄링하기도 한다는 것이다.

즉, 이러한 작업들은 작은 단위로 나눠서 처리할 수도, 애니메이션과 같이 우선순위가 높은 작업은 가능한 빠르게 처리하거나, 낮은 작업을 연기시키는 등 좀 더 유연하게 처리된다.

리액트 개발팀은 사실 리액트는 Virtual DOM이 아닌 Value UI, 즉 값을 가지고 있는 UI를 관리하는 라이브러리라는 내용을 피력한 바 있다.

파이버의 객체 값에서도 알 수 있듯 리액트의 핵심 원칙은 UI를 문자열, 숫자, 배열과 같은 값으로 관리한다는 것이다.

변수에 이러한 UI 관련 값을 보관하고, 리액트의 자바스크립트 코드 흐름에 따라 이를 관리하고, 표현하는 것이 바로 리액트다.

파이버 트리

파이버트리는 사실 리액트 내부에서 두개가 존재한다.
하나는 현재 모습을 담은 Current Tree이고,
다른 하나는 작업중인 상태를 나타내는 workInProgress Tree다.

리액트 파이버의 작업이 끝나면 리액트는 단순히 포인터만 변경해 workInProgress Tree를 Current Tree로 바꾼다.

파이버의 작업 순서

  1. 리액트는 beginwork() 함수를 실행하여 파이버 작업을 수행하는데, 더 이상 자식이 없는 파이버를 만날 때까지 트리 형식으로 시작된다.
  2. 1번에서 작업이 끝나면 completeWork()함수를 실행해 작업을 완료한다.
  3. 형제가 있다면 형제로 넘어간다.
  4. 2번, 3번이 모두 끝났다면 return으로 돌아가 자신의 작업이 완료됐음을 알린다.
<A1>
  <B1>안녕하세요</B1>
  <B2>
  	<C1>
  	  <D1 />
  	  <D2 />
    </C1>
  </B2>
  <B3 />
</A1>
  1. A1의 beginWork() 가 수행된다.
  2. A1은 자식이 있으므로 B1으로 이동해 beginWork()를 수행한다.
  3. B1은 자식이 없으므로 completeWork()가 수행됐다. 자식은 없으므로 형제인 B2로 이동한다.
  4. B2의 beginWork()가 수행됐다. 자식이 있으므로 C1으로 이동한다.
  5. C1의 beginWork()가 수행됐다. 자식이 있으므로 D1으로 이동한다.
  6. D1의 beginWork()가 수행됐다.
  7. D1은 자식이 없으므로 completeWork()가 수행됐다. 자식이 없으므로 형제인 D2로 넘어간다.
  8. D2는 자식이 없으므로 completeWork()가 수행됐다.
  9. D2는 자식도 더 이상 형제도 없으므로 위로 이동해 D1, C1, B2 순으로 completeWork()를 호출한다.
  10. B2는 형제인 B3로 이동해 beginWork()를 수행한다.
  11. B3의 completeWork()가 수행되면 반환해 상위로 타고 올라간다.
  12. A1의 completeWork()가 수행된다.
  13. 루트 노드가 완성되는 순간, 최종적으로 commitWork()가 수행되고 이 중에 변경 사항을 비교해 업데이트가 필요한 변경 사항이 DOM에 반영된다.

이렇게 트리가 생성되었다.
여기서 setState등으로 업데이트가 발생하면 어떻게 될까?

이미 리액트는 앞서 만든 current Tree가 존재하고 setState로 인한 업데이트 요청을 받아 workInProgress 트리를 다시 빌드 하기 시작한다.

이 빌드 과정은 앞서 트리를 만드는 과정과 동일하다.
최초 렌더링 시에는 모든 파이버를 새롭게 만들어야 헀지만 이제는 파이버가 이미 존재하므로 되도록 새로 생성하지 않고 기존 파이버에서 업데이트된 props를 받아 파이버 내부에서 처리한다.

앞서 언급한 가급적 새로운 파이버를 생성하지 않는다. 가 바로 이것이다.
일반적인 리액트 어플리케이션에서 이렇게 트리를 비교해서 업데이트한느 작업은 수없이 많이 일어난다.
이러한 반복적인 재조정 작업 때마다 새롭게 파이버 자바스크립트 객체를 만드는것은 리소스 낭비다.
따라서 가급적 객체를 새로 만들기 보다는 기존에 있는 객체를 재활용하기 위해 내부 속성값만 초기화하거나 바꾸는 형태로 트리를 업데이트한다.

마무리

지금까지 Fiber와 Fiber Reconcilation에 대해 알아보았따.
파이버는 단순히 브라우저에 DOM을 변경하는 작업보다 빠르다는 이유로만 만들어진 것이 아니다.
만약 이러한 도움 없이 개발자가 직접 DOM을 수동으로 하나하나 변경해야 한다면 일일히 값들을 관리하기 매우 어려울 것이다.

이러한 어려움을 리액트 내부의 Fiber와 Fiber Reconciler가 내부적인 알고리즘을 통해서 관리해줌으로서 대규모 어플리케이션을 효율적으로 유지보수/관리 할 수 있게 된 것이다.

Naver D2, React 파이버 아키텍처 분석
[React] Fiber 아키텍처의 개념과 Fiber Reconcilation 이해하기

profile
Beyond the wall

0개의 댓글