[리액트] Virtual DOM과 diffing

Date:     Updated:

카테고리:

태그:

주제

그렇다. 오늘은 가상돔에 대해서 딥다이브 해보도록 하겠다. Virtual-DOM, 줄여서 VDOM 이라고 부르도록 하겠다. 실제로 VDOM이라고들 부른다. VDOM의 작동방식에 대한 글들은 쉽게 찾을 수 있었지만, 이를 왜 사용하는지에 대한 제대로 된 글이 인터넷에 별로없었다. 이에 대한 부분부터 풀어나가도록 하겠다.

필자도 공부를 해가며 작성했으므로, 그 의식의 흐름대로 작성을 하고자 한다. 실제 머리속에서의 꼬리질문들을 각색해서 적어보도록 하겠다.


⭐️질문1. 리액트 가상돔 왜써요?

변화들을 묶어서 DOM에 한번에 던져주려고 사용한다.

💁🏻‍♂️꼬리질문1. VDOM없으면 그렇게 안되나요?

아니다. DOM fragment를 사용하면, 필요한 작업들을 한번에 묶어서 던져줄 수 있다.


💁🏻‍♂️꼬리질문2. 그럼 VDOM 왜써요? DOM fragment 사용하면 되는데?

DOM fragment를 관리하는 과정을 하나씩 작업 할 필요 없이, 추상화 시키는 것이다. 그렇다, 어떤 작업들을 하면 되는지 묶어서 DOM에 던져주는 작업을 자동화&추상화 시킴으로써 렌더링을 적게 발생시키기 위해서 이다.

dan_abramov리덕스창시자

리액트개발 팀원이자 리덕스 창시자인 dan_abramov의 트윗이다.

“리액트 = 빠르다”는 잘못된 미신이다. 유지보수가능한 어플리케이션을 만드는 것을 도와주고, 대부분의 경우에 “충분히 빠르다”

라고 한다. VDOM을 계산하고, 다시 DOM에게 던져주어야 하므로 추가적인 계산이 들어간다. 따라서 렌더링 엔진과 브라우저 성능에 따라 어떤경우에는 더 느리기도 한 것이다. 하지만 VDOM을 사용하는 것이 “일반적으로 더 빠르다”라고 볼 수 있는것이다.


💁🏻‍♂️꼬리질문3. 그래서 왜쓰는거냐구요?

VDOM에 관해서는 위에서 말을 했다. VDOM을 사용하는 리액트 관점에서 말을 다시 정리하자면,

(렌더링을 적게 일으키기 위해)작업들을 묶어서 DOM에 한번에 던져주는 작업을 쉽게 할 수 있도록 도와주고, 이는 일반적으로 더 빠르고 유지보수 가능한 어플리케이션을 만드는 것을 도와줄 수 있기에 사용한다.


⭐️질문2. 리액트 가상돔 어떻게 작동해요?

리액트에서 State나 Props가 변경되면, 이전 DOM과 새로운 DOM을 비교해서 차이점을 찾는다.

그리고 그 차이점을 실제 DOM에 반영한다.


💁🏻‍♂️꼬리질문1. 조금더 자세히 설명 가능한가요?

다음은 저번글에도 나왔던 렌더링 도식도 이다

렌더링 도식도

결국 commit phase로 넘어가기 전인,rendering phase에서 VDOM이 어떻게 바뀌었나~ 에 대한 계산작업을 한다.

“어떻게 새로바꿔주면 되지? 차이점을 알아볼까?”, 차이점을 찾아내는 diffing과정을 거친 후 실제 DOM에 반영이 된다. 이 때, 기존의 current VDOM은 workIn Progress가 되고, 작업을 마친 work InProgress는 current가 된다.

이해가 잘 안된다면, 아래 그림을 보자

current2WIP

Root Node가 current를 바라보고(화살표) 있다. diffing을 마치고 나면, Root Node는 workInProgress를 바라보게 되고, 이 WIP(work in progress)가 이제는 current가 된다는 것이다. 렌더링이 일어날 때 마다, root노드는 여기봤다 저기봤다 도리도리를 하는것이다.

VDOM에 드라마틱한 변화가 있지 않는 이상, 그냥 기존 VDOM을 사용하는게 더 효율적이기 때문에 이렇게, 무한 재활용(?)을 하는 것이다.


💁🏻‍♂️꼬리질문2. 디핑? 디핑소스 같은건가요?

우리가 찍어먹는 디핑소스는 dipping 즉 dip하게 찍어먹는 소스라는 뜻이다.
deep => 깊은
dip => 얕게 적시다
diff => 차이점
그리고 우리는, diffing에 대해 이야기하고 있다. 이전 트리와의 difference를 구하는 작업이다. 컴포넌트가 어떻게 바뀌었는지를 계산하는 것이다.


💁🏻‍♂️꼬리질문3. VDOM에서의 diffing 알고리즘을 설명해 주시겠어요?

크게 다음과 같다

1번 이전 Virtual DOM 트리와 새 Virtual DOM 트리를 비교
2번 루트 노드에서 시작하여 이전과 새로운 노드를 비교
3번 두 노드가 다른 유형이면 새 노드를 만들어 기존 노드를 대체
4번 두 노드가 같은 유형이면 속성을 비교하여 변경된 것이 있는지 확인
. 4-1번 변경된 속성이 없으면 그냥사용
. 4-2번 변경된 속성이 있으면 해당 속성을 업데이트
6번 자식 노드를 재귀적으로 비교


💁🏻‍♂️꼬리질문4. 말씀해주신 부분중 ‘비교’ 들은 어떻게 이루어지나요?

위에 설명했듯, 노드를 타고타고타고타고타고 내려가면서 비교를 한다. childReconciler를 어떻게 만드는지를 보면 알 수 있다.

(아래 코드는 v18.2)

function createChildReconciler(shouldTrackSideEffects: boolean,): ChildReconciler {

  function deleteChild(){
      if (!shouldTrackSideEffects) {
        return null;
      }
  }

  function deleteRemainingChildren(){}
  
  function placeChild(){
      if (!shouldTrackSideEffects) {
        newFiber.flags |= Forked;
        return lastPlacedIndex;
      }
  }
  function updateSlot(...){...}
  function reconcileChildrenIterator(...){...}
  function reconcileSingleElement(...){...}
  function reconcileSingleTextNode(...){...}
  function reconcileChildrenArray(){
      //...
      if (shouldTrackSideEffects) {
        existingChildren.forEach(child => deleteChild(returnFiber, child));
      }
  }
}

약 1200줄의 코드를 20줄로 간추려 보았다. 대~충 훑어보면 deleteChild()deleteRemainingChildren()에서 호출하고, deleteRemainingChildren()reconcile어쩌고저쩌고 함수들에서 호출한다.

reconcile어쩌고저쩌고함수들을 확인하면 어떻게 diff를 해 가면서 자식노드들을 삭제하는지, 그냥 지나가는지, 다른 속성으로 업데이트하는지를 알 수 있는 것이다. 그렇다면 diff알고리즘을 설명하기 위해 응당 해당 코드를 까봐야 할 것이다. 한번 reconcileChildrenArray()함수를 보며 해당 과정을 자세히 살펴보도록 하겠다.

function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<any>,
  lanes: Lanes,
): Fiber | null {


// ⭐️1. 새로운 자식 요소들(newChildren)을 순회하며 이전 자식 요소들(oldFiber)과 비교하고 업데이트
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    // 이전 자식 요소 인덱스와 새로운 자식 요소 인덱스를 비교
    nextOldFiber = oldFiber.index > newIdx ? oldFiber : oldFiber.sibling;
    oldFiber = oldFiber.index > newIdx ? null : nextOldFiber;
    
    // 이전 자식 요소와 새로운 자식 요소를 비교하고 업데이트
    const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
    if (newFiber === null) break;

    // 필요한 경우 이전 자식 요소를 삭제하고 새로운 자식 요소를 위치시킴
    if (shouldTrackSideEffects && oldFiber && newFiber.alternate === null) {
      deleteChild(returnFiber, oldFiber);
    }
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

    // 결과적인 첫 번째 자식 노드를 설정하고 형제 관계를 구축
    if (previousNewFiber === null) resultingFirstChild = newFiber;
    else previousNewFiber.sibling = newFiber;
    
    previousNewFiber = newFiber;
    oldFiber = nextOldFiber;
  } 


// ⭐️2. 모든 new Child 요소들이 처리되었을 경우, 남아있는 이전 자식 요소들을 삭제
  if (newIdx === newChildren.length) {  
    deleteRemainingChildren(returnFiber, oldFiber);
  }

// ⭐️3. new Child 요소들을 삽입
  if (oldFiber === null){
    for (; newIdx < newChildren.length; newIdx++) {
      const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
      if (newFiber === null) continue;
      if (previousNewFiber === null) resultingFirstChild = newFiber;
      else previousNewFiber.sibling = newFiber;

      previousNewFiber = newFiber;
    }
  return resultingFirstChild;
}

//⭐️4. oldChildren이 더 이상 없을 경우 나머지 새 자식에 대한 새 Fiber 노드를 만들어 트리에 추가
//⭐️5. oldChildren이 남아 있으면 map에 children을 추가하여 빠르게 탐색. 일치하는 쌍이 발견되면 oldChildren을 newChild로 업데이트
//⭐️6. 모든 new Children을 반복한 후 "should Track Side Effects"가 참일 경우 소비되지 않은(new Child와 일치하는) 모든 old Children을 삭제
}


💁🏻‍♂️꼬리질문4. 코드만 띡 던져주니까 이해가 안되는데요? 요약해 주실 수 있나요?

결국 위의 어려워보이는 코드들은 "비교"를 하는 과정이다. 누구와 누구를 비교하는지 다시 생각해 보아야 한다.

기존 VDOM과 새로운 VDOM을 비교하는 것이다.

diff

비교(diffing)를 왜할까? 차이점을 찾는 것이다. 일단 순회를 하면서 차이점을 찾아야 한다.

⭐️1번 을 보면,

if (shouldTrackSideEffects && oldFiber && newFiber.alternate === null) {
  deleteChild(returnFiber, oldFiber);
}

라는 코드가 있다.

shouldTrackSideEffects가 True이면, 현재 작업은 sideEffect가 추적되어야’만’ 하는 상황이라는 것이다.
oldFiber이 True이면, 기존 트리에 동일한 위치에 무엇인가 존재한다는 것이다.
newFiber.alternate가 null 이면, 호출된 컴포넌트가 이전과는 type또는 key가 달라 fiber를 새로 생성했다는 뜻이다.

결론적으로, 일치하는 슬롯은 찾았지만 이전과 type또는 key가 다른 상황에서는 deleteChild를 이용하여 싹둑 잘라버린다는 뜻이다.

아니? 이제 막 비교를 하려는데, 어떻게 기존과는 type또는 key가 다르다는 것을 알 수 있지? 라는 생각이 들 수 있다. 이것은 내가 역순으로 설명을 하고 있기 때문이다. 상태를 변경하고, 변경한 상태를 토대로 어떻게 트리를 구성할지 짜보고, 기존 트리와 비교를 해서 어디를 업데이트해야하는지 찾는다. 우리는 이미 어떤 work들, 어떤 작업들을 해야하는지 정한 시점이기 때문에 Fiber.alternate에 대한 값은 이미 할당되어 있는 것이다. 따라서, 우리는 해야하는 작업 목록을 보며, 기존 트리와 diff를 하고있는 중이기 때문에 해당 컴포넌트가 이전과는 type또는 key가 다르다는 사실을 이미 알고있는 것이다.

요약하자면, 우리는 스케줄링된 작업을 work를 통해 VDOM 재조정(reconcile)작업을 진행하는 것이다. 따라서 스케줄링 과정에서 확정된 정보를 토대로 VDOM을 비교(diffing))하고 있는 것이다. 왜 비교하는지는 여러번 이야기가 나온 것 같다. diffing을 함으로써 발생한 변경점을 찾고, 이를 적용시키기 위해서 이다.

⭐️2번을 보면,
모든 newChild를 처리하고 나면, 남은건 다 지워버린다. 그렇다. 오늘 할일을 다 하면 집에 가는것이다.

Work를 보고, ‘어? 시키는거 다 했넹?’ => ‘남은건 안쓰는건가보다’ => ‘안쓰는건 버려야징’ 하는 것이다. 코드에서의 용어로 다시 재정리 해보겠다.

Work를 보고, ‘newIdx === newChildren.length 구나?’ => ‘newChild를 다 수행했으니, oldChild는 버려야겠다’ => ‘deleteRemainingChildren()실행’

⭐️3번을 보면,
일치하는 슬롯이 없으면, “어 새로만들어야겠네?” 하고 createChild()를 해주는 것이다.


💁🏻‍♂️꼬리질문5. diff는 그럼 이전 key값이랑 같은지, 값이 어떻게 변했는지 비교하는 알고리즘이 아닌가요?

뭐 그렇게 볼수도 있을 것 같기도 하고 아닐 것 같기도 하다. Work를 적용시키는 부분만 보자면 아닌 것 같다. 큰 흐름 파악이 필요하다.

리액트실행흐름, 큰흐름

우리는 해당 과정에서 Reconcile의 일부분을 보고 있던 것이다.

리액트실행흐름, 중간흐름

리액트의 실행흐름중 작은 부분인 Reconcile에서의 흐름이다. 트리를 만들고, 비교를 시작하는 부분이다. 우리는 이 diffing이라는 아주 작은 부분을 설명했던 것이다.

리액트실행흐름, 작은

그리고 노드를 순회하며 VDOM을 비교하는 이 과정들이 바로, 우리가 현재 글에서 읽어왔던 내용인 것이다. 그렇다면 리액트의 큰 흐름을 한번에 정리해보도록 하겠다.

리액트실행흐름, 총괄

질문에 대한 답을 하자면, 이미 만들어진 VDOM을 통해 기존과는 어떤 변경점이 생겼는지를 알아가는 과정이 diffing인 것이다. Reconcile과정에서 VDOM을 생성하고, diffing과정에서 변경점을 찾고 해당 내용들을 commitPhase로 딱! 넘겨주면, 변경점만 DOM에 적용함으로써 필요한 부분만 DOM에서 한번에 렌더링 해 줄 수 있는 것이다.

어떤 작업을 하면 되는지 batch단위로 모아서, 어떤일을 하면 되는지 적절한 우선순위로 Scheduling 해주면, Work를 보며 VDOM을 만들고… 그리고 바로바로 diffing을 해가며 변경점을 찾는 것이다. 그리고 마침내 rendering Phase를 마치고 commit Phase로 넘어가는 것이다.

항상 보게되는 뻔한 그림을 다시 한번 더 보며 마무리 하겠다.

렌더링 도식도


맨 위로 이동하기

react 카테고리 내 다른 글 보러가기

댓글 남기기