Million.js는 어떻게 React보다 최대 70% 빠를까?

Million.js는 어떻게 React보다 최대 70% 빠를까?

May 29, 2023

해당 글은 Million.js v2.3.2을 기준으로 작성되었습니다

Million.js 제작자의 트윗을 우연히 보게 되었는데 React 환경에서 성능 최적화를 이끌어내는 방식이 흥미로워서 파헤쳐 보았다.

Million.js는 무엇인가

Million.js는 단순히 React Component를 HOC(Higher Order Component)로 감싸는 것만으로도 렌더링 속도를 빠르게 만들어 주는 virtual DOM 라이브러리이다. 어떻게 개선하는지가 재밌는데 뒤에서 자세히 기술하겠지만 react reconciliation 대신 자체 제작한 virtual DOM을 사용해 조작하는 식으로 개선하였다.

(Million.js Documentation > Introduction)

Million is an extremely fast and lightweight (<4kb) virtual DOM that makes React components up to 70% faster

Oh man... Another /virtual dom|javascript/gi framework? I'm fine with React already, why do I need this?

Million works with React. Million makes creating web apps just as easy (It's just wrapping a React component!), but with faster rendering and loading speeds. By using a fine-tuned, optimized virtual DOM, Million.js reduces the overhead of React.

실제 렌더링 속도는 krausest/js-framework-benchmark 벤치마크를 참조해보면 확연하게 차이가 나는 걸 확인할 수 있다.

어떻게 성능 최적화를 이끌어냈을까?

리액트는 잘 알려진 것처럼 virtual DOM 데이터와 diff 알고리즘으로 reconciliation 과정을 거쳐 화면을 수정한다. 그리고 이 과정은 n개의 node가 있는 트리에 대해 O(n) 복잡도를 가지게 된다. (React Reconciliation document)

node가 많아질수록 diff 작업이 오래 걸리기 때문에 diff 과정에서 병목 현상이 발생하는 건 리액트 개발자라면 한 번쯤 격어봤을 것이다.

Million.js는 reconciliation을 사용하지 않기 때문에 tree를 렌더마다 재생성하지도 않고 node에 대한 diff 알고리즘도 사용하지 않는다.

대신 보다 fine-grained reactivity framework(SolidJS, Qwik…)에 가깝게 필요한 부분만 update 하는데 이를 위해 정적 분석(Static Analysis)과 더티 체킹(Dirty Checking)을 사용한다.

  1. 컴포넌트를 정적 분석해 JSX tree의 변경될 수 있는 부분(dynamic part), 즉 {expression}의 tree 내 위치와 데이터 및 관련 정보를 수집해 저장한다. (해당 데이터를 "Edit Map"이라 부른다)
  2. 컴포넌트가 update 되면 Edit Map을 통해 데이터를 이전과 비교(Dirty Checking)함으로써 변경된 부분만 DOM 업데이트를 진행한다.

위 처리 과정은 JSX tree에 expression이 m개 존재할 때 O(m)의 복잡도를 가지며 node 개수에 영향을 받지 않고 update 처리를 할 수 있다.

어떻게 구현했을까?

접근법을 보면 어떻게 구현해서 React에 embed 했을지 궁금할 수밖에 없다. 내부 구현은 3가지 영역으로 나뉘어져 있다.

  1. Block Virtual DOM
  2. Compiler
  3. React HOC

Block Virtual DOM

Million.js가 사용하는 virtual DOM 시스템은 Block Virtual DOM이라 부르는데, 기존 virtual DOM들과는 다른 접근 방식을 가지고 있다.

  1. 컴포넌트를 관리하기 위해 block이라는 단위를 사용하며, 컴포넌트의 노드를 static하게 관리한다. 이를 위해 컴포넌트를 static node로 미리 생성하며, 이 과정에서 dynamic part는 Edit Map에 저장된다.
  2. 이후 update가 있을 때 Edit Map의 데이터만 비교하는 방식으로 빠른 렌더링 속도를 보장해 준다.

아래는 약간 변형한 실제 소스이다.

1const PLACEHOLDER_SYMBOL = Symbol('PLACEHOLDER_SYMBOL');
2const PLACEHOLDER_PROXY = new Proxy({}, {
3 get(_, key) {
4 return { [PLACEHOLDER_SYMBOL]: key };
5 }
6});
7
8const createBlock = (component, unwrap) => {
9 const velement = component(PLACEHOLDER_PROXY); // 1. props 대신 PLACEHOLDER Proxy를 넘겨서 element tree를 미리 생성한다.
10 const edits = []; // Edit Map
11
12 const root = stringToDOM(renderToTemplate( // 2. velement를 static DOM node로 치환한다. 이 과정에서 PLACEHOLDER 데이터가 쓰이는 곳은 Edit Map에 저장된다.
13 unwrap ? unwrap(velement) : velement,
14 edits
15 ));
16
17 return (props) => new Block( // 3. 실제 데이터 props를 전달받아 Block instance를 생성한다.
18 root,
19 edits,
20 props,
21 );
22};
23
24const block = createBlock((props) => (
25 <div id={props.id} onClick={props.onClick}>
26 Hello, {props.name} - ({props.cnt})
27 </div>
28));
  1. 실제 데이터 대신 미리 PLACEHOLDER Proxy를 넘겨 velement를 생성한다. 생성 과정에서 props data를 사용하는 dynamic part는 PLACEHOLDER 값이 들어가게 되어 다른 데이터와 구분이 가능해진다. 결괏값은 아래처럼 생성될 것이다.

    1const velement = {
    2 type: 'div',
    3 props: {
    4 id: { [PLACEHOLDER_SYMBOL]: 'id' },
    5 onClick: { [PLACEHOLDER_SYMBOL]: 'onClick' },
    6 children: [
    7 'Hello, ',
    8 { [PLACEHOLDER_SYMBOL]: 'name' },
    9 ' - (',
    10 {[PLACEHOLDER_SYMBOL]: 'cnt' },
    11 ')',
    12 ],
    13 },
    14};
  2. velement를 node로 치환할 때 PLACEHOLDER를 체크해 edits에 저장한다. edits와 root는 아래와 같을 값 형태를 가지고 있다.

    1const edits = [
    2 {
    3 path: [],
    4 edit: [
    5 { // id attr
    6 type: AttributeFlag, // 어떤 type인지를 나타낸다. Child, Attribute, Event, StyleAttribute, SvgAttribute, Block
    7 name: 'id', // attribute, event의 name
    8 hole: 'id', // data key
    9 index: null, // child일 경우 자신의 index 값
    10 patch: null, // event listener patch 전용 필드
    11 },
    12 { // click event
    13 type: EventFlag,
    14 name: 'Click',
    15 hole: 'onClick',
    16 index: null,
    17 patch: null,
    18 },
    19 { // 1번 index child에 name
    20 type: ChildFlag,
    21 name: null,
    22 hole: 'name',
    23 index: 1,
    24 patch: null,
    25 },
    26 { // 3번 index child에 cnt
    27 type: ChildFlag,
    28 name: null,
    29 hole: 'cnt',
    30 index: 3,
    31 patch: null,
    32 },
    33 ],
    34 initial: [],
    35 },
    36];
    37
    38root = <div>Hello, </div>; // Edit Map 데이터들은 바로 반영되지 않는다.

    edits에서 editPLACEHOLDER들에 대한 정보가 저장되며 initialPLACEHOLDER가 아니지만 static template으로 치환이 안 되는 데이터(event callback, 다른 block component)를 initial에 저장해 뒀다가 mount 단계에서 추가해 준다.

  3. 실제 데이터를 전달받아 Block instance를 생성하게 되면 이제 DOM을 렌더링할 준비가 끝난다. 아래는 block을 활용한 실제 예시 코드이다.

    1let cnt = 0;
    2const block = createBlock((props) => (
    3 <div id={props.id} onClick={props.onClick}>
    4 Hello, {props.name} - ({props.cnt})
    5 </div>
    6));
    7
    8const onClick = () => {
    9 ... // update 코드가 들어갈 부분
    10};
    11
    12// Block instance 생성
    13const app = block({
    14 id: 'first',
    15 name: 'pyjun01',
    16 cnt,
    17 onClick,
    18});
    19
    20app.mount(document.querySelector('#app')); // Edit Map을 순회하며 PLACEHOLDER를 props 데이터(= { id: 'first', name: 'pyjun01', cnt: 0, onClick: fn })로 채워준다.
  • mount를 호출하면 생성된 root에 dynamic part가 채워지며 #app에 mount 된다. 아래는 mount method의 일부이다.

    1// root가 <div>Hello, </div>에서 <div id='first'>Hello, pyjun01 - (0)</div>로 변경된다.
    2
    3 mount(parent) { // root를 insert할 대상 node
    4 const root = this.root.cloneNode(true);
    5
    6 ...
    7
    8 for (let i = 0, j = this.edits.length; i < j; ++i) { // (1) Edit Map을 순회한다.
    9 const current = this.edits[i];
    10 const el = getCurrentElement(current.path, root, this.cache, i); // (2) path를 통해 대상 element를 가져온다. path 값이 없으면 root가 대상 element이다.
    11
    12 for (let k = 0, l = current.edit.length; k < l; ++k) { // (3) edit을 순회하며 값들을 추가한다.
    13 const edit = current.edit[k];
    14 const value = this.data[edit.hole]; // PLACEHOLDER의 실제 데이터
    15
    16 if (edit.type & ChildFlag) { // children을 insert한다. name 값이 여기서 추가된다.
    17 ...
    18 insertText(el, String(value), edit.index); // el.insertBefore(...)
    19 } else if (edit.type & AttributeFlag) { // attribute를 설정한다. id attr이 여기서 설정된다.
    20 setAttribute(el, edit.name, value); // el.setAttribute(...);
    21 } else if (edit.type & EventFlag) { // event listener를 설정한다. onClick이 여기서 설정된다.
    22 const patch = createEventListener(el, edit.name, value); // el.addEventListener()
    23 edit.patch = patch; // Edit Map에 patch 필드를 설정한다.
    24 } ...
    25 }
    26 }
    27
    28 ...
    29
    30 if (parent) {
    31 parent.insertBefore(root, null); // append
    32 }
    33 }
    • (1) Edit Map을 순회한다.
    • (2) path(= [0,1,2]일 경우 > :nth-child(1) > :nth-child(2) > :nth-child(3))를 통해 대상 element를 가져온다.
    • (3) edit을 순회하며 child, attribute(style, eventListener, ...etc)를 추가해 준다.

    mount된 DOM을 update 하려면 patch가 필요하다. onClick에 patch 로직을 추가하자.

    1let cnt = 0;
    2const block = createBlock((props) => (
    3 <div id={props.id} onClick={props.onClick}>
    4 Hello, {props.name} - ({props.cnt})
    5 </div>
    6));
    7
    8const onClick = () => {
    9 cnt++;
    10
    11 const newApp = block({ // 새로운 데이터로 block을 새로 생성한다.
    12 id: 'clicked',
    13 name: 'pyjun01',
    14 cnt,
    15 onClick
    16 });
    17
    18 app.patch(newApp); // 생성한 block을 patch 하면 Edit Map을 순회하며 데이터를 비교해 변경을 반영한다.
    19};
    20
    21const app = block({
    22 id: 'first',
    23 name: 'pyjun01',
    24 cnt,
    25 onClick,
    26});
    27
    28app.mount(document.querySelector('#app'));
  • patch는 newValue(= 새로운 block의 data)와 oldValue(= 기존 block의 data)를 비교하며 변경된 부분만 newValue로 치환한다.

    1patch(newBlock) {
    2 const root = this.root;
    3 if (!newBlock.data) return root;
    4
    5 const props = this.data;
    6 this.data = newBlock.data;
    7
    8 for (let i = 0, j = this.edits.length; i < j; ++i) { // 1. mount와 동일하게 Edit Map을 순회한다.
    9 ...
    10 for (let k = 0, l = current.edit.length; k < l; ++k) {
    11 const edit = current.edit[k];
    12 const oldValue = props[edit.hole];
    13 const newValue = newBlock.data[edit.h];
    14
    15 if (newValue === oldValue) continue; // 2. 비교해서 같으면 변경하지 않는다.
    16
    17 ...
    18
    19 // 3.
    20 if (edit.type & EventFlag) {
    21 edit.patch(newValue); // mount에서 설정한 patch
    22 } else if (edit.type & ChildFlag) {
    23 ...
    24 setText(el, String(newValue), edit.index);
    25 } else if (edit.type & AttributeFlag) {
    26 setAttribute(el, edit.name, newValue);
    27 } ...
    28 }
    29 }
    30
    31 return root;
    32}
    1. mount와 동일하게 Edit Map을 순회한다.
    2. value가 같다면 변경하지 않고 넘어간다.
    3. 바뀐 부분이 있다면 newValue로 변경해 준다.

Example

Compiler

Million.js는 이전에 설명한 것처럼 JSX tree의 expression 값 변경을 체크하는데 이는 expression들이 Block DOM에 넘기는 데이터(= props)가 된다는 뜻이다. 그러기 위해서 expression을 props로 넘기도록 구조를 수정해야 하고 이를 위해 compiler의 도움이 필요하다.

compiler는 block으로 감싸져 있는 컴포넌트를 분석해 두 개로 분리한다.

  • (1) return JSX tree 그대로 return 하며 expression 값들만 props로 받는 stateless component + 해당 컴포넌트를 block HOC으로 감싼 Block(= block(stateless component))
  • (2) Block을 return 하고 나머지는 그대로인 원본 컴포넌트
1/*** 원본 컴포넌트 ***/
2function App(props) {
3 const [cnt, setCnt] = useState(0);
4
5 return (
6 <div id={props.id} onClick={() => setCnt(cnt+1)}>
7 Hello, {props.name} - ({cnt})
8 </div>
9 );
10}
11const AppBlock = block(App);
12
13/*** 컴파일된 컴포넌트 ***/
14function BlockUI({
15 _$2, // {variable} 형태가 아니라면 key를 자동으로 생성해 준다.
16 _$3,
17 _$4,
18 cnt
19}) {
20 return (
21 <div id={_$2} onClick={_$3}>
22 Hello, {_$4} - ({cnt})
23 </div>
24 );
25}
26const BlockComponent = block(BlockUI); // (1)
27
28function App(props) { // (2)
29 const [cnt, setCnt] = useState(0);
30
31 return BlockComponent({
32 _$2: props.id,
33 _$3: () => setCnt(cnt+1),
34 _$4: props.name,
35 cnt,
36 });
37}
38const AppBlock = App;
  • props가 변수가 아닐 때는 auto-increment로 key를 생성한다.
  • BlockComponent에 사용된 block 함수는 React HOC 함수이다.

컴포넌트가 분리됐다면 이제 HOC 함수 내에서 Block Virtual DOM과 React component를 적절히 조화시키면 된다.

React HOC

React HOC는 다음과 같은 작업을 진행한다.

  1. React reconciliation 대신 Block Virtual DOM을 사용해야 하므로 컴포넌트 내부에서는 JSX tree를 return 하는 것이 아니라 Block instance로 생성한다. (block의 mount 대상이 될 적절한 temp tag는 react를 통한 렌더링이 필요하다)
  2. useEffect를 통해 컴포넌트 mount시 block을 temp tag에 mount 시킨다.
  3. 이후 update가 발생하면 patch를 호출해 변경된 데이터를 DOM에 반영한다.
1const css = 'million-block, million-fragment { display: contents }'; // 1. temp tag용 style 설정
2const style = document.createElement('style');
3Object.assign(style, {
4 type: 'text/css',
5 innerHTML: css,
6})
7document.head.appendChild(style);
8
9export const block = (
10 fn,
11 options = {},
12) => {
13 const block = createBlock(fn, unwrap);
14
15 return function MillionBlock(props) {
16 const ref = useRef(null); // tmep tag DOM ref
17 const patch = useRef(null); // patch function
18
19 patch.current?.(props); // 3. patch 호출
20
21 useEffect(() => {
22 const currentBlock = block(props, props.key);
23
24 if (ref.current) {
25 currentBlock.mount(ref.current, null); // 2. block을 mount 한다.
26
27 patch.current = (props) => { // patch ref 값을 설정한다.
28 currentBlock.patch(block(props));
29 };
30 }
31
32 return () => {
33 currentBlock.remove(); // 4. remove
34 };
35 }, []);
36
37 return <million-block ref={ref} />; // million-block이라는 tag를 container로 사용한다.
38 }
39};
  1. display: contents; style이 생소할 수 있는데 대상 node를 마치 DOM에 없는 것처럼 처리해 준다. (= contents의 자식이 contents를 무시하고 조부모를 부모로 인식한다)
  2. 컴포넌트 mount 이후 useEffect가 호출되면 block.mount를 호출해 렌더링한다.
  3. 컴포넌트 최초 호출에서는 patch ref가 initial value(= null)이므로 호출이 안 되고 이후 컴포넌트가 update 될 때 useEffect 내부에서 할당한 patch 함수가 호출되어 업데이트된다.
  4. 컴포넌트가 unmount 되어 destructor가 호출되면 block.remove를 호출해 DOM node를 제거한다.

결론

Million.js의 핵심적인 코드 부분을 살펴보았다. 코드를 살펴보면서 느꼈을 수 있지만 당연히 모든 곳에 적용한다고 성능 향상이 보장되는 것은 아니며 제약사항이 여럿 존재해 할 수도 없다.

  • component composition 불가
  • component early return 불가
  • expression에서 JSX(= ReactElement) 사용 불가
  • spread attribute 사용 불가

(글 업로드 날 기준으로 최신 버전에서는 위 제약사항 중 몇몇이 해결됐다)

아직 분명한 한계가 존재하지만, 특정 케이스(static 요소)에서는 높은 활용도를 가지고 있고 lib의 개선도 계속되고 있으니 많은 변경이 필요한 프로젝트에서 채택을 고려해볼만한 가치가 있다.

부록