[React 알아보기] Rendering Process (+ Fiber Reconcilation)

YunShin·2024년 8월 6일
2
post-thumbnail

Web Browser 의 Rendering

React 의 Rednering 과정을 살펴보기에 앞서, 브라우저는 랜더링 엔진은 어떻게 화면을 그리는지부터 간략히 알아보려한다. (React 역시 그저 Javascript 로 작성된 라이브러리일 뿐, React 코드가 없는 환경에서도 브라우저는 화면을 그리고 지우기를 반복한다.)

React 로 작성된 페이지가 Rendering 될 시, React 로 제어되는 과정과 제어되지 않는 과정을 구분해보고 싶다.

Critical Rendering Path

Chrome, FireFox, Edge 등 웹 브라우저 의 종류는 다양하지만, 모든 랜더링 엔진은 Critical Rendering Path 이라는 과정을 거쳐 화면을 그려준다.
(그 구분은 해석하는 사람마다 조금씩 다른 것 같지만, 순서와 과정은 동일하다.)

Critical Rendering Path

① DOM & CSSOM Tree 생성

HTML 문서를 DOM(Document Object Modal) 로, CSS 문서를 CSSOM(CSS Object Modal) 로 변환하는 과정이다. DOM 과 CSSOM 은 인간이 작성한 문서(텍스트)를 브라우저 해석할 수 있도록 변환된 형태이다.

② Render Tree 생성

DOM 과 CSSOM 을 합쳐 실제로 화면에 보여질 요소 로 Render Tree 가 완성된다. 각 노드는 의미를 가진 요소로써 style 정보까지 알 수 있기 때문에 Render Tree 는 "UI 의 청사진"이라고 할 수 있다.

화면에 보여지지 않을 요소는 Render Tree 에도 반영되지 못한다. 가령 display: none 과 같은 style 속성이 있는 경우가 그러하다.

③ Layout

각 요소가 화면 상에 어떤 위치에 놓일지 결정된다. Viewport 의 크기나 외부 요소와의 위치관계 등도 고려해야하는 연산이기 때문에, Critical Rendering Path 중 많은 시간이 소요된다. (UI 업데이트 시, Layout 연산을 다시해야 하는 것을 Reflow 라고 한다.)

④ Paint

실제로 페이지가 화면에 그려진다. Render Tree 를 바탕으로 pixel이 채워지고, UI 가 사용자에게 노출된다. Layout 과정과 마찬가지 시간이 많이 소요된다. (UI 업데이트 시, Paint 를 다시해야하는 것을 Repaint 라고 한다.)

DOM 수정을 최소화 하는 이유

Critical Rendering Path 가 발생하는 경우는 다음과 같다.

  • 페이지가 처음으로 로드될 때
  • Javascript 에 의해 DOM 이 수정이 될 때이다.
    (Button 을 눌러 Dialog 를 띄우는 경우를 생각해볼 수 있다.)

이는 화면을 그리기 위해 반드시 필요한 동작이지만, 자주 발생할 경우 UX적으로 아주 좋지 못하다.
특히, Reflow 와 Repaint 는 위에서 언급했듯이 시간이 많이 소요되는 비싼 연산이다. DOM 이 지나치게 많이 변경된다면 페이지의 기능이 일시적으로 정지할 수 있으며, 심할 경우 다음과 같이 브라우저에서 해당 페이지를 강제로 종료할 수 있다.

page shut-down

다음의 영상을 보면, 위와 같은 오류상황을 피하기 위해 코드를 어떻게 작성해야하는지 알 수 있다.


👆 위 이미지를 클릭하면, Youtube 영상링크로 이동한다.

(영상에서 나온 예시가 좋다고 느껴, 나 또한 같은 코드로 실험해보았다. 위의 영상을 보았다면, 아래 예시는 굳이 보지 않아도 좋다.)

Browser Rendering 최적화 과정

두 개의 코드를 비교할텐데, 그 내용은 동일하다.
"버튼 클릭" 이라는 한 번의 동작으로, 총 1000 개의 li 태그를 생성하는 것이다.

Bad Case

아래 코드를 실행 시, for 문 안에 DOM을 수정하는 코드가 직접 포함되어 있기 때문에, 루프가 돌 때마다 새로운 li 요소가 화면에 추가된다.

script

  <script>
		function onClick() {
			const $ul = document.getElementById('ul')
			const count = 2000
 
			console.time(`li ${count} 개 추가, 소요시간`)
			for (let i = 0; i < count; i++) {
				$ul.innerHTML += '<li>${i}</li>' // 👈 DOM 수정
			}
			console.timeEnd(`li ${count} 개 추가, 소요시간`)
		}
	</script>

	<body>
		<button onclick="onClick()">추가</button>
		<ul id="ul"></ul>
	</body>
 

실행결과

  • 소요시간: 약 4248.746 ms (대략 4초)


Good Case

이번엔 2000 개의 변경사항을 취합 후, 한번에 DOM 에 업데이트하는 코드이다.

script

	<script>
		function onClick() {
			const $ul = document.getElementById('ul')
			const count = 2000
			let list = ''
			console.time(`li ${count} 개 추가, 소요시간`)
			for (let i = 0; i < count; i++) {
				list += '<li>${i}</li>'
			}
			$ul.innerHTML = list // 👈 DOM 수정
			console.timeEnd(`li ${count} 개 추가, 소요시간`)
		}
	</script>

	<body>
		<button onclick="onClick()">추가</button>
		<ul id="ul"></ul>
	</body>

실행결과

  • 소요시간: 약 4.5702 ms (대략 0.04초)


React 가 해결하고자 했던 문제점

DOM 수정을 최소화할수록 성능이 얼마나 크게 개선될 수 있는지 위의 예시에서 알 수 있었다.

하지만 개발자가 항상 이러한 최적화된 코드를 작성할 수 있을까? 프로젝트 규모가 크고 여러 사람이 함께 참여하는 경우라면, 일정 수준 이상의 렌더링 성능을 보장하기가 더욱 어려워질 것이다.

이것이 바로 React 라이브러리가 해결하고자 했던 문제이자 도입 배경이다. React는 자체적인 Rendering 과정을 추가한 후, 이를 추상화하였다. React 를 사용하는 개발자는 Reflow, Repaint 연산이 얼마나 자주 일어나는지 깊이 고민하지 않아도 비교적 자연스럽게 페이지를 갱신할 수 있다.

결론

React 를 사용할 경우 개발자가 DOM 수정 횟수를 고려하며 코드를 작성하지 않아도, 일정 수준 이상의 랜더링 최적화가 가능하다. 위의 Good Case 에서 확인했다시피 동시에 발생된 업데이트 사항을 모아 한번에 DOM 을 수정하는 과정이 추상화되어 있기 때문이다.

프로젝트 규모가 크거나 참여인원(개발자)이 많더라도, 어느 정도의 랜더링 성능이 보장된다는 점에서 React, Vue 등의 Frontend Libaray 가 고안된 이유를 유추해 볼 수 있다.

React 는 언제 사용할까?

내 답변은 다음과 같다.

⓵ 서비스 특성 상, 화면 업데이트가 빈번한 경우
⓶ 한 프로젝트에서 여러 개발자와 협업할 경우

React 랜더링

이제 본격적으로 React 의 동작 과정을 살펴보겠다. React 의 랜더링은 Commit Phase 와 Render Phase, 두 단계에 걸쳐 일어난다.

Commit Phase

Commit Phase 는 호출된 컴포넌트를 순회하며, 어떤 업데이트가 필요한지를 파악하는 단계이다. 업데이트 내역을 파악하는 것은 Virtual DOM 이라는 객체 비교를 통해 이뤄지고, 이 Virtual DOM 을 얻기 위해 컴포넌트를 순회한다. 조금 더 자세히 알아보겠다.

React 의 모든 컴포넌트는 JSX 문법으로 작성되어 있다. 이는 babel 을 통해, createElement() 라는 함수로 변환된다. 이는 말 그대로 ReactElement 라는 객체를 만드는 함수이다.

ReactElement 란, 해당 컴포넌트로 그려질 UI 에 대한 모든 정보를 가진 객체이다.

다음과 같이, 컴포넌트를 선언 후, 콘솔에 출력해보면 직접 ReactElement 를 확인할 수 있다.

  const App = () => {
	return (
		<div>
			<ChildOne />
			<ChildTwo />
			ChildThree
		</div>
	)
  }
 
  console.log(App()) // 👈 console 로 App() 의 반환을 출력

출력 결과

console.log(App())

컴포넌트의 반환으로 얻게 된 ReactElement 를 토대로, Vitual DOM 이라는 트리형태의 객체가 완성된다. Vitual DOM 은 실제 DOM에 반영되기 전에 변경 사항을 메모리상에서 효율적으로 계산하기 위한 객체이다.

주의할 점은, Vitual DOM 의 각 노드는 ReactElement 가 아니다. ReactElement 는 위에서 보았듯 type, key, props 등 매우 단순한 정보만을 가지고 있기 때문에 그 자체로 Tree 를 구성하지 못한다. Vitual DOM 의 각 노드는 (ReactElement 를 기반해 만든) Fiber 라는 객체이다. 이에 대한 자세한 내용은 아래의 [Fiber Reconcilation] 파트에서 마저 정리하겠다.

만약 페이지를 최초로 랜더링할 경우 Commit Phase 는 이 수준에서 완료되지만, UI 상태가 변경된 경우 기존의 Virtual DOM 과 새롭게 작성된 Virtual DOM 간의 비교하는 과정이 추가된다.

참고 ) 기존에 작성되어 있는 Virtual DOM 을 current tree , 새롭게 작성한 V-DOM 을 WorkinProgress tree 라고 흔히 지칭한다. WorkinProgresscurrent 를 복제하여 만들어지며, 노드를 탐색하며 재작성된다.

Render Phase

Render Phase 는 Virtual DOM 비교를 통해 찾아낸 업데이트 내용을 실제 DOM 에 반영하는 단계이다. 만약 발견된 업데이트 사항이 없다면, Render Phase 는 생략된다. 이 단계는 동기적으로 진행되기 때문에 도중에 멈출 수가 없다. Render Phase 를 거치며 실제 DOM 이 수정되었으니, 브라우저는 Critical Rendering Path 를 실행한다.

Reconcilation

이제 Reconcilation 과정에 대해 조금 더 자세히 알아보겠다. 위에서 간략하게 언급했듯, 이것은 두 개의 Virtual DOM (current, WorkinProgress) 간 차이을 찾아내고, 그 차이(업데이트 사항)을 종합하여 Render Phase 로 돌입하기 까지의 과정이다.
Reconcilation 의 핵심은 차이를 얼마나 빠르고 정확하고 찾아내는지에 달려있다.

React 16 버전 이전까지, current tree와 WorkinProgress tree를 비교하기 위해서 사용했던 Stack Reconciler 부터 살펴보자.

Stack Reconcilation (~ React 15)

요약: Stack Reconciler는 Virtual DOM 을 비교하고 화면에 변경 사항을 push하는 작업을 동기적으로 실행한다.

Stack Reconcilation 은 그 이름처럼, Virtual DOM 상단의 노드부터 다음 노드를 재귀적으로 호출하며 비교한다. 만약, 기존 Virtual DOM 과 달라진 사항이 발견되면, 곧바로 Commit Phase 로 돌입하여, DOM 업데이트가 곧바로 일어난다. 다음 애니메이션을 참고해보자. (핑크색 화살표가 등장할 때, 변경 사항이 실제 화면에 적용된다.)

stack reconciler

stack reconciler 가 재귀적으로 virtual DOM을 탐색하는 순서를 시각화한 애니메이션이다.
출처: React Deep Dive — Fiber

이 모든 과정은 "동기적" 으로 일어나는 연속 과정이다. 즉, 모든 업데이트가 실제 화면에 반영될 때까지 기다려야 한다. 이게 심각하게 문제일까?

요즘의 일반적인 모니터들은 초당 60프레임(60 FPS)정도로로 화면을 재생한다. 이 말은 변경사항이 발생할 경우, 1/60(16.67) ms 시간 안에 모든 노드를 탐색 후, 새로운 화면을 그려내야 하는 것을 의미한다. 그렇지 못하면, frame-drop 이 발생한다. 애니메이션 효과가 있는 컴포넌트라면 이 제한이 치명적이다.

Fiber Reconcilation (React 16 ~ 현재)

Fiber Reconcilation 는 Stack Reconcilation 의 문제를 보안하고 대체하기위해 2년에 걸쳐 재작성한 새로운 Reconcilation 알고리즘 이다.

Stack Reconcilation 의 문제를 다시 한번 짚어보기

"Virtual DOM 을 비교하고, 그 차이를 화면에 적용하는 작업을 동기적으로 실행 하는 것" 이다. 동기적인 작업에는 "스케줄링", "일시 중지", "재실행" 등이 원천적으로 불가능하다.

결론부터 말해서 Fiber Reconcilation 을 사용할 경우, 다음과 같이 업데이트를 관리할 수 있다.

  1. 업데이트을 멈춘 후 나중에 다시 돌아온다.
  2. 각 업데이트마다 우선순위를 부여한다.
  3. 이전에 완료된 업데이트를 다시 사용한다.
  4. 더이상 필요로 하지 않는 업데이트의 경우, 중단한다.

이와 같은 관리가 가능한 이유는 Rendering 과정을 잘게 쪼개어, 여러 프레임 걸쳐 실행하기 때문이다. (Rendering 을 하나의 거대한 task로 처리했던 Stack Reconcilation 과 대조되는 작업방식이다.) 여기서 잘게 쪼개어진 작업 단위가 이 알고리즘의 핵심인 Fiber 이다.

Fiber 란?

Fiber 를 컴포넌트의 정보를 담고 있는 자바스크립트 객체이자, 업데이트 작업의 기본 단위이다.

참고) 다음은 createFibberFromElement() 의 구현이다.
위에서 잠깐 언급했듯, 기본적으로 ReactElement 를 통해 만들어지는 것을 볼 수 있다.
(github > react-reconciler 에 방문해보면 실제 코드를 확인할 수 있다.)

createFibberFromElement()

Fiber 의 구조를 확인해보자.
(많은 속성을 가진 객체이기 때문에, 모든 속성을 아래에 다 명시하지 못했다.)
(github > react-reconciler 에 방문해보면 실제 타입를 확인할 수 있다.
)

export type Fiber = {
  /* 인스턴스 관련 항목*/
  tag: WorkTag, // 해당 Fiber 가 대변하는 컴포넌트의 유형 (0~25, 컴포넌트 인스턴스 유형을 이를 통해 알 수 있다. )
  key: null | string,
  elementType: any, // 원본 요소의 타입 (JSX에서 지정된 컴포넌트).
  type: any, // 실제로 렌더링 시 사용되는 해석된 타입.
  
  //...
  
  /* 가상 스택 관련 항목 */
  return: Fiber | null, // 부모 Fiber
  child: Fiber | null, // 첫 자식 Fiber
  sibling: Fiber | null, // 형제 Fiber
  
  //...
  
  /* Effect(바뀐 점, 바뀌어야 할 작업) 관련 항목 */
  flags: Flags, // Effect 유형
  subtreeFlags: Flags, // 자식 노드에 발생할 Effect 유형
  
  firstEffect: Fiber | null, // 해당 Fiber 의 서브 트리에서 Effect 가 있는 첫번째 Fiber
  lastEffect: Fiber | null, // 해당 Fiber 의 서브 트리에서 Effect 가 있는 마지막 Fiber
  nextEffect: Fiber | null, // 해당 Fiber 가 Effect 를 가졌다면, 탐색 순서상 그 다음으로 Effect 가 발생한 Fiber

  //...
};

각 Fiber 들은 단방향 연결리스트 (Singly Linked List) 형태로 연결되어 있다. 위의 구조에서 return, child, sibling 속성을 볼 수 있는데, 이를 통해 다음으로 탐색할 노드를 빠르게 찾아갈 수 있다. (순서는 child, sibing, return 순이다.)

이 역시, 각 노드를 재귀적으로 방문했던 Stack Reconciler 와 대조적이다. 재귀 호출이 발생할 경우, 각 호출에 대한 비용(=시간)이 발생하여 느릴뿐만 아니라 특정 작업(들?)에 대한 처리 순서를 지정하기 어렵다. (알고리즘 문제 중, 깊이 우선 탐색(DFS) 관련 풀이를 생각해보면 이해가 쉽다. 탐색 도중, 동적인 분기가 어렵지 않은가?) 그러니 모든 작업이 동기적일 수밖에 없는 것이다.

반면 Fiber Reconciler 의 경우, 연결리스트 구조이기 때문에 노드 순회가 빠르다. 또한 각 Fiber 는 작업 정보를 포함하고 있으며, 우선순위에 따라 작업을 나눌 수도 있다. 즉, 우선순위가 낮은 작업은 현재 순회에서 처리하지 않고, 다음 순회에서 처리할 수 있는 구조이다.

다음 애니메이션을 참고해자.

stack reconciler

fiber reconciler 가 각 fiber의 return child sibling 을 참조하여 트리를 탐색하는 순서를 시각화한 애니메이션 이다.
출처: React Deep Dive — Fiber

Effect 란?

컴포넌트의 변경내용을 의미한다. UI 상태가 변경되어 WorkInProgress 를 작성할 때, React 는 각 노드의 Effect를 수집하고 저장한다. 이후에, Commit 페이지에서 이러한 변경내용들만 추려 실제 DOM 에 반영하는 것이다. 변경해야할 요소에 빠르게 접근하기 위해 firstEffect, lastEffect, nextEffect 속성에 Effect 가 확인된 Fiber 를 가리키고 있다.

Root Fiber 의 firstEffect 는 첫번째 변경사항이 있는 노드를 가리키고 있을 테고, 해당 노드의 nextEffect 는 그 다음 변경사항이 있는 노드를 가리키고 있다. 이렇게 lastEffect 까지 도달하면 모든 변경내용이 정리되고, 이를 통해 DOM 을 한번만 수정할 수 있다.

아래 그림은 Commit Phase 에서의 상태를 모식화한 것이다. EffectList 를 확인해보면, 수정해야할 사항만 정리되어 있는 걸 확인할 수 잇다.

EffectList

(위의 이미지는 다른 분의 블로그에서 발췌해왔다. 같은 주제로 더 깊이있는 내용을 작성한 유익한 글이니 방문해보는 걸 꼭 추천한다.)
네이버 블로그-[React] Fiber 아키텍처의 개념과 Fiber Reconcilation 이해하기

마치며..

React 의 Rendering 관련 주제로 여러 내용을 취합하고 필자가 이해한 부분에 대해 작성했다. 내부 동작에 대한 더욱 자세한 자료는 많으나, 제대로 서술하지 못한 내용이 많다.(특히 Reconcilation 부분이 그러하다.)

스스로 이해하지 못한 것을 공개하기가 민망하다는 이유도 있고, 해당 주제는 React 가 어떤 문제를 어떻게 해결했는지를 집중하는 것에 더 큰 의미가 있다고 생각해서 이다. 잘못된 서술이나 개념적으로 미흡한 사항을 댓글로 남겨준다면 언제든 다시 수정하겠다.!

Reference

Github

Youtube

Blog

profile
😎👍

0개의 댓글