React 렌더링 동작 원리 with Fiber

Doeunnkimm·2023년 7월 7일
5

React

목록 보기
1/5
post-thumbnail

DOM ?

  • DOM(Document Object Model)은 문서객체모델로, HTML, XML 문서의 프로그래밍 interface
  • 웹 페이지는 일종의 문서(document)라고도 할 수 있는데
  • 이 문서는 웹 브라우저를 통해 그 내용이 해석 → 웹 브라우저 화면에 표시
  • 이때 DOM을 통해 동일한 문서를 표현, 저장, 조작할 수 있게 되는 것

→ DOM은 문서로 만들어져있는 웹 사이트를 브라우저가 이해할 수 있는 구조를 제공

렌더링(Rendering) ?

  • Render는 애플리케이션에서 요소의 속성(props)이 새로운 속성으로 바뀌거나 컴포넌트의 상태가 변경될 때 사용자 화면의 해당 정보만을 갱신하는 것을 의미
  • 렌더링 방법 덕분에 웹 페이지를 전부 다 다시 로드하지 않아도 된다
  • 리렌더링은 diffing이라는 프로세스를 사용하여 React 요소의 이전 트리와 새 트리(가상 DOM) 사이의 변경 사항을 식별

Virtual DOM (가상돔) ? (What)

  • 실제 DOM의 복사본, 실제 DOM의 모든 요소와 속성을 공유
  • 차이점은, 브라우저에 있는 문서에 직접 접근 X
  • Virtual DOM은 UI의 “가상” 표현이 메모리에 유지
  • React DOM과 같은 라이브러리에 의해 “실제” DOM과 동기화되는 프로그래밍 개념

→ 컴포넌트에서 state가 변경될 때, 렌더링된 요소와 새로 반환된 요소를 비교하여 실제 DOM을 업데이트할지 말지 결정해야 하는데, 이때 달라진 부분만 업데이트하게 된다.

→ 이러한 프로세스를 reconciliation !

React가 Virtual DOM을 사용하는 이유 ? (Why)

⭐ 실제 DOM을 조작하는데 걸리는 시간을 획기적으로 단축

  • 원래라면, …
    • 폰트의 color만 바꾸고 싶어도 다음과 같이 DOM 조작이 필요
      document.querySelector('#title').style.color = 'pink'
    • 브라우저는 HTML을 탐색 → 요소 찾기 → 요소와 자식 요소들을 DOM에서 제거 → 수정된 요소로 교체 → CSS는 이후에 다시 계산되어 레이아웃에 맞게 수정 → 브라우저에 그려지기
  • 이를 반복적으로 수행한다면 충분히 무거워질 수 있는 작업이 되는 것

→ 이렇게 등장한 개념이 Virtual DOM(가상돔) !

React가 Virtual DOM을 효율적으로 조작하는 방식 (How) - Reconciliation

  • React의 2개의 가상돔

    1. 렌더링 이전 화면 구조를 나타내는 가상돔
    2. 렌더링 이후에 보이게 될 화면 구조를 나타내는 가상돔
  • 렌더링 이전 가상돔과 렌더링 이후 가상돔을 비교

    → 어떤 요소(element)가 변했는지를 비교 (Diffing)

    → 차이가 발생한 부분만을 실제 DOM에 적용

    → 이 과정을 Reconciliation(재조정)

React의 Reconciliation 과정이 효율적인 이유 & 한계점

React의 diff 알고리즘

✔️ 레벨 별 트리 조정(휴리스틱) - 변경된 요소를 빨리 찾는 방식

  • React는 렌더링 이전 가상돔 & 렌더링 이후 가상돔을 비교
    • 이를 위해서는 이 2개의 DOM 트리를 순회하면서 비교
  • 이때 React는 순회 과정에서 같은 레벨의 값들 비교를 우선시

✔️ 같은 레벨에 존재하는 노드들의 수 일치/불일치에 따라 다른 비교

  • 노드들의 수가 일치한 경우
    • 수가 일치하다는 것은, 추가/삭제된 노드가 없다거나, 특정 노드가 완전히 새로운 노드로 변경

    • state가 변경된 노드는 체크가 되어 있을 것이고, 하위 컴포넌트의 속성 값을 모두 업데이트

      → 유사할 것 같지 않은 두 구성 요소를 비교하지 않고 새로 교체

      → 미리 컴포넌트를 만들어 둔다면 재조정 시간이 줄어든다

  • 노드들의 수가 불일치한 경우
    • 예를 들어, TodoList에서 todo를 추가했거나 삭제한 경우

    • 누가 추가, 삭제 되었는지 혹은 완전히 새롭게 추가된 경우를 체크하는 것이 중요

      → 이러한 경우를 대비해 React에서는 리스트 형태로 컴포넌트를 렌더할 때 key값을 입력하도록 강제

      → 이 키 값을 기반으로 Map 자료 구조를 이용해 추가 삭제를 확인

      → 같은 컴포넌트가 다른 위치에 있다면 새로운 렌더링 없이 위치만 바꿔주는 효율적인 연산도 가능

✔️ 재귀적인 접근 방식

  • diff 알고리즘은 재귀적으로 자식 노드들까지 계속해서 비교
  • 현재 노드를 처리한 후 하위 노드를 처리하기 위해 스택에 저장하고 하위 노드까지 처리가 완료된 후 다음 상위 노드로 이동하는 방식
  • 스택(Stack) 구조와 유사한 동작 방식

→ 이 말은 곧, 하나의 비교가 끝날 때까지 다른 무언가의 행위 X (동기식)

✔️ 비교할 양이 커질수록 성능에 영향

  • 아무리 휴리스틱이 적용되더라도 규모가 커지면 그만큼 비교해야 할 양이 증가
  • 특히, 컴포넌트의 구조가 복잡하고 변경사항이 전체 컴포넌트 트리에 영향을 주는 경우에는 더욱 많은 비교 작업이 필요

동작원리를 개선하자, React16부터는 Fiber

React Fiber란?

  • Fiber란 React의 렌더링 동작을 개선하는 방식
  • 렌더링 작업을 여러 단계로 분할하여 처리
    • 작업을 일시중지하고 나중에 다시 시작하는 것도 가능
    • 이전에 완료된 작업을 재사용하거나 필요하지 않은 경우 중단도 가능
  • Fiber는 애니메이션과 반응성에 중점
    • 작업을 chunks로 분할하고 작업의 우선 순위를 지정하는 기능

⭐ 이전 Reconciliation 방식의 reconciler와 달리 비동기식

핵심 아이디어

  • Fiber를 하나의 작업 단위(unit of work)
  • React는 이러한 Fibers, 작업 단위를 처리하고 “finished work”라는 항목으로 종료
  • 그런 다음, 작업들을 커밋(commit)하여 DOM에서 변경사항을 체크

Fiber는 작업 단위(unit of work), 2단계로 처리된다

  1. 렌더링 단계 → processing
    • 비동기로 처리
    • 새로운 Virtual DOM을 생성하고, 변경 사항을 찾는 과정
    • 새로운 DOM과 이전 DOM 사이에서 변화를 찾아낸다.
    • 이 과정은 중단이 가능
    • 사용자 입력과 같은 우선 순위가 높은 작업이 발생하면 현재 진행 중인 작업을 중단하고 우선 순위가 높은 작업을 처리할 수 있다.
  2. 커밋 단계 → committing
    • 동기식 처리
    • 렌더링 단계에서 계산한 변경 사항들을 실제 DOM에 적용하는 과정
    • 한번 시작되면 중단X
    • 컴포넌트 생명 주기 메서드인 componentDidMountcomponentDidUpdate 등도 이 단계에서 호출

⭐️ Fiber의 아키텍처의 동작 방식 덕분에 React는 우선 순위가 다른 작업들을 동시에 관리할 수 있으며, 필요한 경우 원활한 사용자 경험을 위해 일부 작업의 실행 시점을 조절할 수 있다.

🤔 계산을 다하고 커밋하는 건데, 우선 순위라는 게 의미가 있는건가 ?

렌더링 단계에서 변경 사항을 계산한다고 했는데, 이는 중단 가능한 작업!!!

따라서 높은 우선 순위를 가진 업데이트가 들어오면, 현재 진행 중인 낮은 우선 순위의 작업을 중단하고 높은 우선 순위의 작업을 먼저 처리 가능

사용자 입력 같은 상호 작용이 원활하게 이루어질 수 있도록 해당 작업들에 높은 우선 순위를 부여하고, 그 외 배경에서 진행되는 업데이트와 같이 비교적 지연이 허용되는 작업들에 낮은 우선 순위를 부여함으로써 사용자 경험을 향상시킬 수 있다.

Fiber의 속성

  • Fiber는 항상 “무언가”와 1:1 관계
    • 여기서 “무언가” → 컴포넌트, DOM 노드 등
  • “무언가”의 type은 해당 태그 속성 안에 저장
  • stateNode는 실제 DOM 요소 자체를 참조(Reference)하고 있어 실제 DOM에 접근/조작 가능한 것 → 이를 통해 React는 가상 DOM과 실제 DOM 사이의 Fiber와 관련된 상태를 유지/업데이트
  • Fiber 트리에는 single child → 이는 first child에 대한 참조
    • 나머지 child는 sibling으로 연결
    • child들은 부모에 대한 참조로 반환
    • 즉, 부모와 자식들은 하나의 Fiber

❓ 무슨 이유로 first child만 child로 두고 나머지는 silbling으로 처리할까?

  • 우선순위를 고려하여 작업을 조율할 수 있도록 하기 위해 변경된 부분이 첫 번째 자식에 있을 경우, 해당 자식은 더 높은 우선순위로 처리되도록

Fiber가 작업의 단위라는데, 여기서 작업(work)이라는 건 ?

  • 상태를 변경할 때마다 → 작업!
  • 생명주기 함수(useState, useEffect, …)가 있을 때마다 → 작업!

⭐ 중요한 건, React가 Fiber를 처리할 때마다 작업을 직접 처리 or 예약

  • time slicing이라는 기능을 사용하면, 작업을 chunks로 분할 가능
  • 어떤 작업이 애니메이션과 같이 매우 높은 우선순위를 가지고 있다면 ⇒ React는 가능한 한 빨리 처리되도록 스케줄을 잡을 수 있다

Fiber Tree

  • React는 UI 렌더링이 필요한 state가 반영한 Fiber 트리를 갖게 된다. → current 트리 (한번 그려진 트리)
  • React가 current를 update하기 시작하게 그것은 workInProgress 트리 → 화면에 보여지게 될 미래의 state를 반영
  • 모든 작업은 workInProgress 트리의 Fiber에서 수행
  • React가 current 트리를 살펴보면서 기존의 각 Fiber 노드에 대해 workInProgress 트리를 구성하는 alternative 노드를 생성
  • 업데이트 처리가 되고 모든 작업이 완료되면, 화면에 보여줄 alternative 트리를 가지고 있을 것 → 이 alternative 트리이자 실제 작업이 이루어진 workInProgress 트리가 렌더되고 나면 그것은 다시 current 트리가 된다 → 트리 교환

⭐ React는 항상 DOM을 한번에 update
→ 부분적인 결과는 표시X
→ 그래서 React는 먼저 모든 컴포넌트들을 처리한 뒤에
→ 그것들의 변화를 화면에 반영할 수 있다.

❓ Fiber가 없었을 때는 그럼 한번에 update하는 것이 불가능했나?
그럼 눈에 연속적으로 상태가 변하는 게 보였나?

  • Fiber 아키텍처가 도입되기 전에는 최적화된 업데이트 수행하기 보다는 모든 변경 사항을 한번에 처리하는 방식 사용
  • Fiber 도입으로 인해 변경 사항이 작은 단위로 분할/조정
    → 더 효율적이고 최적화된 업데이트 가능
    → 사용자에게 더 부드러운 UI 업데이트와 반응성 제공하는 데 도움 👍🏻

그래서, Fiber는 어떻게 동작하는가? - 트리 순회 방식

  • Fiber 트리 순회는 다음과 같이 발생

    • 시작: 최상위 React 요소에서 순회를 시작, 이에 대한 Fiber 노드 생성

    • 자식: 그런 다음 자식 요소로 이동하여 요소에 대한 Fiber 노드 생성. 이는 리프 요소에 도달할 때까지 계속

    • 형제: 형제 요소가 있는지 확인. 있는 경우 형제의 리프 요소까지 형제 하위 트리를 순회

    • Return : 형제가 없으면 부모에게 돌아감

  • 아래와 같은 코드가 있다고 하자

    function App() {    // App
        return (
          <div className="wrapper">    // W
            <div className="list">    // L
              <div className="list_item">List item A</div>    // LA
              <div className="list_item">List item B</div>    // LB
            </div>
            <div className="section">   // S
              <button>Add</button>   // SB
              <span>No. of items: 2</span>   // SS
            </div>
          </div>
        );
    }
     
    ReactDOM.render(<App />, document.getElementById('root'));  // HostRoot
    1. 초기렌더링
      • createFiberFromTypeAndProps를 사용하여 React 요소를 Fiber로 변환
      • 위 코드에 대한 최종 Fiber 트리는 아래와 같은 모습
    2. 업데이트 단계
      • 모든 업데이트에 대해 workInProgress 트리 빌드
      • 루트 Fiber에서 시작하여 리프 노드까지 트리를 순회
      • 초기 렌더링 단계와 달리 모든 React 요소에 대해 새로운 Fiber를 생성 X
      • 기존 Fiber를 사용하고 업데이트된 요소의 새 컴포넌트를 병합
      • Fiber 등장 이전처럼 재귀적으로 트리를 순회하긴 하지만! ⭐ Fiber는 다른 작업과 우선순위를 조율할 수 있어 중단과 재개가 가능
    3. 커밋 단계
      • 완성된 작업을 UI에 렌더링하는 데 사용되는 단계
      • 동기식으로 처리
      • 이 단계가 시작될 때 Fiber에는 현재 트리, 업데이트된 workInProgress 트리 존재
      • completeRoot 함수 호출
      • 여기서 workInProgress 트리는 업데이트된 UI를 렌더링하는 데 사용되므로 현재 트리로 교체

그래서 내가 할 수 있는 렌더링 최적화 방법은?

⭐ 결론적으로, 불필요한 Fiber 작업(상태 업데이트) 줄이기

방법1. 불필요한 렌더링 줄이기 - React.memo

  • 일부 props로 인해서 자주 렌더링될 거 같을 때 사용해보자
  • 부모 컴포넌트에 의해 하위 컴포넌트가 같은 일부만 다른 props 값 때문에 렌더링되는 경우 존재
  • 아래 코드로 살펴보자
    function MovieViewsRealtime({ title, releaseDate, views }) {
      return (
        <div>
          <Movie title={title} releaseDate={releaseDate} />
          Movie views: {views}
        </div>
      );
    }
    export default MovieViewsRealtime;
    • view가 새로운 숫자가 업데이트될 때마다 MovieViewRealtiem 컴포넌트 리렌더링

    • 📌 title이나 releaseData가 같음에도 불구하고 리렌더링 진행

    • 이럴 때 메모이징된 컴포넌트로 성능을 향상시켜볼 수 있다.

      function MovieViewsRealtime({ title, releaseData, views }) {
        return (
          // ...
      	)
      }
      export default React.memo(MovieViewsRealtime);

방법 2. map 사용 시 고유하면서도 불변하는 값으로 key 설정 - index 말고

  • React는 트리를 순회하면서 변경사항을 체크한다고 했는데
  • 이때 같은 계층의 노드의 수가 일치하거나 불일치할 경우
    • 어떤 노드가 추가/삭제/순서변경이 있었는데 파악하기 위해
    • key 입력을 강제하고 있다고 했다 (여기에서)
  • 따라서 불필요한 렌더링을 방지하고 컴포넌트의 정합성을 위해
    • [todo.id](http://todo.id) 처럼 고유하고 불변한 값을 넣어서 React가 똑바로 인식할 수 있도록 입력

방법3. useCallback, useMemo

  • 컴포넌트 내에 정의된 어떤 함수나 값은 특별한 이유를 제외하고는 매번 렌더링될 필요 X
  • 그래서 순수 함수나 constant한 값의 경우 컴포넌트 밖으로 빼거나 Utils로 빼는 경우도 있다

❓ 컴포넌트 밖에 빼는 것과, useCallabck을 사용하는 경우 어떤 차이 ?

  • 성능 차이는 크지 않을 것이라는 의견들이 많고..
  • 오히려 useCallback이나 useMemo는 캐싱을 하기 때문에 성능에 영향이 미칠 것
  • 외부로 함수를 빼면 캐싱 대신 해당 모듈에 함수가 할당된 형태로 존재

⭐ 성향 차이인 것 같다..! (나는 주로 컴포넌트 밖으로 뺀다)

방법4. useTransition, useDeferredValue - 디바운싱(Debouncing)

  • useTransition과 useDeferredValue는 React18 버전에 등장
  • 디바운싱을 통해 렌더링 batch를 뒤로 미루어주는 역할 수행
  • 짧은 시간 안에 반복적인 렌더링이 많이 일어날 경우 렌더링 지연이 발생할 수 있다
  • 이럴 때는 batch를 뒤로 밀어 반복적으로 렌더링 X → batch 간격으로만 렌더링 발생(렌더링 타이밍 지연)

참고문서

profile
개발자와 사용자 모두의 눈👀을 즐겁게 하는 개발자가 되고 싶어요 :) 👩🏻‍💻

0개의 댓글