front/vue

[vue] Rendering Mechanism

juniKang 2022. 8. 11. 14:17

어떻게 뷰는 템플릿을 받아서 실제 돔 노드로 바꿀까? 어떻게 뷰는 돔 노드들을 효율적으로 업데이트할까? 뷰의 내부 렌더링 메카니즘에서 이 문제들을 밝혀보자.

 

가상 돔

'가상 돔' 에 대해 들어본 적이 있을 것이다. 가상 돔은 뷰의 렌더링 시스템의 기반이 되는 개념 이다.

 

가상 돔(VDOM)은 "실제" 돔에 동기화하게 될, 메모리에 존재하는 UI를 의미하는 "가상의" 프로그래밍 개념이다. 리엑트에 의해 시작된 이 개념은, 다른 구현체로 뷰를 포함한 많은 다른 프레임 워크에서 구현되었다.

 

가상 돔은 특정 기술이라기 보다는 개념에 가깝다. 그래서 정규화된 구현방법은 없다. 간단한 예를 통해 이 개념을 그려보자:

const vnode = {
  type: 'div',
  props: {
    id: 'hello'
  },
  children: [
    /* more vnodes */
  ]
}

여기 vnode는 <div> 엘리먼트를 나타내는 순수한 자바스크립트 객체 (=가상노드) 이다. 이 객체는 실제 엘리먼트를 만드는데 필요한 모든 정보를 담고 있다. 또한 자식 vnode를 담을 수 있어서, 가상 돔 트리의 루트가 될 수 있다.

 

런타임 렌더러는 가상 돔 트리를 분석해서 실제 돔 트리를 만들 수 있다. 이 과정을 마운트라고 부른다.

 

만약 가상 돔 트리 복제품이 두 개가 있다면, 렌더러는 두 개의 트리를 분석하고 비교한다. 차이점을 찾아서, 실제 돔에 변경사항을 적용한다. 이 과정을 패치 라고 부른다. 패치는 "diffing" 이나 "reconciliation" 이라고도 부른다.

 

가상 돔 트리의 가장 큰 이점은 개발자가 프로그램적으로 생성할 수 있도록 해주고, 만들고자하는 UI 구조체를 선언적인 방법으로 검사하고 비교할 수 있도록 해준다. 이 과정을 진행하면서, 렌더러가 실제 돔을 직접적으로 조작하지 않게 해준다.

 


렌더 파이프 라인 (렌더링 과정)

전반적으로, 뷰 컴포넌트가 마운트될 때 일어나는 일은 :

  1. 컴파일 : 뷰 템플릿들이 렌더 함수로 컴파일 된다. 함수들은 가상 돔 트리들을 리턴한다. 이 과정은 빌드 스텝을 통해 사전에 진행될 수도 있고, 런타임 컴파일러를 통해 그때 그때 진행될 수도 있다.
  2. 마운트 : 런타임 렌더러가 렌더 함수를 호출하고, 리턴되는 가상 돔 트리들을 분석하고, 분석을 기반으로 실제 돔 노드들을 만든다. 이 과정은 반응형으로 수행된다. 그래서 사용된 모든 반응형 의존성들을 추적한다.
  3. 패치 : 마운트 중에 의존성이 사용되면, 이펙트는 다시 실행된다. 이 때, 새로운, 업데이트된 가상 돔 트리가 생성된다. 런타임 렌더러는 새로운 트리를 읽고, 이전 트리와 비교한다. 그리고 필요한 업데이트를 실제 돔에 적용한다.


템플릿 vs 렌더 함수

뷰 템플릿은 가상 돔 렌더 함수로 컴파일 된다. 뷰는 템플릿 편집을 건너뛰고 직접적으로 렌더 함수를 작성할 수 있도록 API를 제공한다. 렌더 함수들은 매우 동적인 로직을 다룰 때 템플릿보다 더 유연하다. 왜냐하면 자바스크립트의 풀파워를 사용해서 vnode와 작업할 수 있기 떄문이다.

 

그런데도 왜 뷰는 템플릿 사용을 추천할까? 몇가지 이유가 있다:

  1. 템플릿은 실제 HTML 과 유사하다. 그래서 존재하는 HTML 스니펫을 재사용하기 쉽게 해주고, 베스트 프랙티스를 적용하기 쉽게 해주고, CSS로 스타일하거나 디자이너가 이해하고 변경하기 쉽게 해준다.
  2. 템플릿은 이미 결정된 문법으로 인해 정적으로 분석하기 더 쉽다. 그래서 뷰의 템플릿 컴파일러가 가상 돔의 퍼포먼스를 향상시키기 위해 많은 컴파일 타임 최적화를 적용하도록 해준다 (아래에 좀 더 다룬다).

실전에서, 애플리케이션의 대부분의 유즈케이스에서 템플릿으로 충분하다. 렌더 함수는 매우 유동적으로 렌더링 로직을 사용하는 재사용 컴포넌트를 사용하는 경우에만 거의 필요하다. 렌더 함수 사용에 대해서는 Reder Functions & JSX 에서 보다 자세히 다룬다.

 


정보포함-컴파일러 가상 돔 (Compiler-Informed Virtual DOM)

리엑트의 가상 돔 구현체와 대부분의 다른 가상 돔 구현체들은 런타임에서만 동작한다. 가상돔 분석 알고리즘은 들어오는 가상 돔 트리에 대해서 어떤한 가정도 만들 수 없어서, 정확성을 위해 트리 전체를 분석하고 모든 vnode의 프로퍼티를 확인해서 차이를 찾아야 한다. 심지어, 트리를 하나도 변경하지 않았더라도, 각각의 리-렌더링마다 새로운 vnode들이 만들어진다. 그 결과 불필요하게 메모리를 소모한다. 이 부분이 가상 돔의 가장 큰 약점이다: 다소 폭력적인 분석 과정은 정확성을 위해 효율성을 희생시킨다.

 

하지만 뷰에서는 이런 희생을 할 필요가 없다. 뷰에서는, 프레임워크가 컴파일러와 런타임 둘다 컨트롤한다. 이런 구현은 단단히 결합한 렌더러의 이점을 챙기면서 많은 컴파일 타임 최적화를 이루었다. 컴파일러는 템플릿을 정적으로 분석할 수 있고, 새롭게 생성될 코드에 힌트를 남길수 있다. 그래서 런타임시에는 가능한한 간소화할 수 있다. 동시에, 특별한 상황에서 직접적으로 컨트롤할 수 있는 렌더 함수 레이어를 유저에게 제공해준다. 뷰의 이러한 하이브리드한 접근을 "정보포함-컴파일러 가상 돔(Compiler-Informed Virtual DOM)" 이라고 한다.

 

아래에서, 가상 돔의 런타임 퍼포먼스를 위해 뷰 템플릿 컴파일러에 적용된 몇가지 주요 최적화사항들을 다룰 것이다. 

 

정적인 호이스팅

꽤 흔히 어떠한 바인딩도 담고있지 않은 템플릿의 부분들이 있다.

<div>
  <div>foo</div> <!-- hoisted -->
  <div>bar</div> <!-- hoisted -->
  <div>{{ dynamic }}</div>
</div>

foo 와 bar <div>들은 정적이다 - 각각의 재렌더링마다 vnode를 새로 만들거나 차이를 분석할 필요가 없다. 뷰 컴파일러는 자동적으로 렌더 함수 바깥으로 vnode 생성 호출을 호이스트 한다. 그리고 모든 렌더링마다 같은 vnode를 재사용한다. 렌더러는 이전 vode와 새로운 vnode가 같다는 것을 알아서, 완전히 분석을 스킵할 수있다. 

 

게다가, 정적인 엘리먼트가 연속으로 있을 때, 이 노드들은 순수한 HTML 문자열을 담고 있는 하나의 "정적인 vnode"로 압축된다. 정적인 vnode들은 innerHTML을 셋팅한 채로 마운트 된다. 그것들은 또한 초기 마운트에 상응하는 돔 노드들이 캐싱된다. - 만약 컨텐츠의 같은 조각이 앱의 어디서든 재사용 되면, 새로운 돔 노드는 네이티브 cloneNode() 함수를 사용해서 생성된다. 이 cloneNode() 네이티브 함수는 극도로 효율적이다.

 

패치(Patch) 플래그

동적인 바인딩을 한 엘리먼트에서도, 컴파일 할 때 많은 정보를 추론할 수 있다:

<!-- class binding only -->
<div :class="{ active }"></div>

<!-- id and value bindings only -->
<input :id="id" :value="value">

<!-- text children only -->
<div>{{ dynamic }}</div>

 엘리먼트를 위한 렌더 함수를 생성할 때, 뷰는 vnode 생성 호출에 직접적으로 필요한 업데이트의 타입을 각각 부호화 한다:

createElementVNode("div", {
  class: _normalizeClass({ active: _ctx.active })
}, null, 2 /* CLASS */)

마지막 아규먼트인 2는 패치 플래그이다. 엘리먼트는 여러개의 패치 플래그를 가질 수 있다. 패치 플래그들은 하나의 숫자로 합쳐진다. 런타임 렌더러는 특정한 작업이 필요한지 판단하기 위해 비트와이즈 오퍼레이션을 사용해서 플래그를 분석할 수 있다:

if (vnode.patchFlag & PatchFlags.CLASS /* 2 */) {
  // update the element's class
}

비트와이즈 확인은 극도로 빠르다.  패치플래그로, 뷰는 동적인 바인딩을 한 엘리먼트를 업데이트할 때 필요한 최소한의 작업만 수행하는 것이 가능해졌다.

 

뷰는 또한 vnode가 가지고 있는 자식의 타입도 부호화 한다. 예를 들어, 루트 노드가 여러개인 템플릿은 조각으로 표시된다. 대부분, 이 루트 노드의 순서는 절대 변하지 않는다는 걸 알고 있다. 이 정보는 패치플래그로 런타임에 제공된다:

export function render() {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    /* children */
  ], 64 /* STABLE_FRAGMENT */))
}

패치플래그 덕분에, 런타임시에 루트 조각을 위한 자식의 순서 조정을 완전히 스킵할 것이다.

 

트리 평평화

이전 예제의 생성코드에서 살펴볼 다른 점은, 리턴되는 가상 돔 트리의 루트가 특별한 createElementBlock() 호출하여 생성되었다는 것이다 :

export function render() {
  return (_openBlock(), _createElementBlock(_Fragment, null, [
    /* children */
  ], 64 /* STABLE_FRAGMENT */))
}

개념적으로, "블록"은 안정적인 내부 구조를 가진 템플릿의 부분이다. 이 경우, 전체 템플릿은 하나의 블록을 갖는다. 왜냐하면, v-if나 v-ofr 를 담고있지 않기 때문이다.

 

각각의 블록은 패치 플래그를 가진 모든 하위 노드를 추적한다.  예를 들어:

<div> <!-- root block -->
  <div>...</div>         <!-- not tracked -->
  <div :id="id"></div>   <!-- tracked -->
  <div>                  <!-- not tracked -->
    <div>{{ bar }}</div> <!-- tracked -->
  </div>
</div>

결과는 오직 동적인 하위 노드들로만 이루어진 평평화된 배열이 된다:

div (block root)
- div with :id binding
- div with {{ bar }} binding

이 컴포넌트가 재렌더링 해야할 때, 전체 트리 대신에 평평화된 트리만 분석된다. 이 작업을 트리 평평화 라고 한다. 이 작업이 가상 돔을 조정하는 동안 분석해야하는 노드의 수를 확 줄여준다. 템플릿의 모든 정적인 부분은 효율적으로 스킵된다.

 

v-if와 v-for 는 새로운 블럭 노드를 만든다:

<div> <!-- root block -->
  <div>
    <div v-if> <!-- if block -->
      ...
    <div>
  </div>
</div>

자식 블록은 부모 블록의 동적인 자식의 배열안에서 추적된다. 그래서 부모 블록의 안정적인 구조체를 유지한다.

 

SSR Hydration 에 끼친 영향

패치 플래그와 트리 평평화 둘다 뷰의 SSR Hydration 퍼포먼스를 크게 향상시켰다:

  • 싱글 엘리먼트 hydration은 상응하는 vnode의 패치 플래그를 기반으로 빠른 경로를 사용할 수 있다.
  • 오직 블록 노드와 동적인 후손들만 hydration동안 분석되야 한다. 템플릿 레벨에서 부분 hydration을 효과적으로 달성할 수 있다.