ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 11/14 TIL | React Virtual DOM의 재조정(Reconsiliation)
    📝 기록/매일의 기록 2022. 11. 14. 21:07

    React는 선언적 API를 제공하기 때문에 갱신이 될 때마다 매번 무엇이 바뀌었는지를 걱정할 필요가 없다. 이게 뭔 소릴까? 이를 이해하기 위해선 Virtual DOM이 무엇인지 먼저 알아야 한다.

    💡 Virtual DOM

    말 그대로 가상의 DOM이다. 브라우저에 실제로 보여지는 DOM이 아닌 메모리에 가상으로 존재하는 DOM으로 ReactDOM과 같은 라이브러리에 의해 “실제” DOM과 동기화하는 프로그래밍 개념이다.

    리액트는 상태가 업데이트되면, 업데이트가 필요한 곳의 UI를 Virtual DOM을 통해서 렌더링하고, 그러고 나서 실제 브라우저에 보여지고 있는 DOM과 비교를 한 후, 차이가 있는 곳을 감지하여 이를 실제 DOM에 패치시켜준다. ⇒ 이 과정을 재조정(Reconciliation)이라고 한다. 이러한 접근방식이 React의 선언적 API를 가능하게 하는데, 다시 말해 React에게 원하는 UI의 상태를 알려주면 DOM이 그 상태와 일치하도록 하는 것을 말한다.

    🧐 그렇다면 기존 웹이 사용해온 DOM과 Virtual DOM은 무슨 차이가 있는 걸까?

    기존 Browser Work Flow의 문제, 특히 MPA에서 많이 발생하는 문제는 각각의 DOM 조작이 각각의 레이아웃 변화, 트리 변화, 렌더링을 발생시킨다는 것이다. 즉, 30개의 엘리먼트를 하나하나 수정하면, 30번의 레이아웃 재계산과 30번의 리렌더링이 발생한다는 것이다.

    하지만 Virtual DOM은 DOM의 변화를 먼저 가상 DOM 트리에 먼저 적용시킨다. 가상 DOM은 렌더링이 되지 않아 연산 비용이 적다. 연산이 끝나면 최종적인 변화만 실제 DOM에 던져주는 것이다. 딱 한 번만, 모든 변화를 하나로 묶는 것이다.

    30개의 사소한 DOM조작으로 30개의 리플로우, 리페인팅을 만드는 것이 아닌 Virtual DOM에 먼저 DOM 변화를 적용하고, 최종적인 형태만을 실제로 DOM에 전달해 리플로우와 리렌더링은 한 번으로 줄이는 것이다.

    🛠 재조정(Reconsiliation)

    재조정(Reconsiliation) 과정은 다음과 같다.

    1. 업데이트한 전체 UI를 Virtual DOM에 리렌더링한다.
    2. 실제 DOM과 생성된 Virtual DOM을 비교한다. → 이때 Diffing Algorithm이라는 비교 알고리즘을 따른다.
    3. 바뀐 부분만 실제 DOM에 적용한다.

    🔍 비교 알고리즘 (Diffing Algorithm)

    1. 엘리먼트의 타입이 다른 경우

    아래와 같이 루트 엘리먼트의 타입이 다르면(1은 div, 2는 span), React는 이전 트리를 버리고 완전히 새로운 트리를 구축한다.

    <!-- Before -->
    <div>
      <Counter />
    </div>
    
    <!-- After -->
    <span>
      <Counter />
    </span>

    2. 엘리먼트의 타입이 같은 경우

    아래와 같이 같은 타입의 엘리먼트를 비교할 때, React는 두 엘리먼트의 속성을 확인하여 동일한 내역은 유지하고 변경된 속성들만 갱신한다. 아래의 경우 className만 갱신된다.

    <!-- Before -->
    <div className="before" title="stuff" />
    
    <!-- After -->
    <div className="after" title="stuff" />

    👩‍👧‍👦 자식에 대한 재귀적 처리(Recursing on children)

    DOM 노드의 자식들을 재귀적으로 처리할 때, React는 기본적으로 동시에 두 리스트를 순회하고 차이점이 있으면 변경을 생성한다.

    <!-- Before -->
    <ul>
      <li>first</li>
      <li>second</li>
    </ul>
    
    <!-- After -->
    <ul>
      <li>first</li>
      <li>second</li>
      <li>third</li>
    </ul>

    위 코드와 같이 이전 코드와 비교해서 새로운 엘리먼트는 마지막에 <li>third</li>로 추가되어 있기 때문에 마지막으로 트리에 무리 없이 추가된다.

    <!-- Before -->
    <ul>
      <li key="2015">Duke</li>
      <li key="2016">Villanova</li>
    </ul>
    
    <!-- After -->
    <ul>
      <li key="2014">Connecticut</li>
      <li key="2015">Duke</li>
      <li key="2016">Villanova</li>
    </ul>

    하지만, 순서를 무작위로 엘리먼트를 추가하게 되면 성능 상의 이슈가 있기 때문에 key 속성을 사용해야 한다. 자식이 key를 가지고 있다면 기존 트리와 이후 트리의 자식들이 일치하는지를 확인해서 해당 내용을 트리에 추가하고 순서가 변동된 엘리먼트는 이동만 시켜준다.

    실제로 key 속성을 입력해주지 않은 경우 브라우저 콘솔창에서 이와 같은 에러메시지를 만날 수 있다!

    🔑 Key

    앞서 말했듯 key 속성은 React가 어떤 항목을 변경, 추가 또는 삭제할지 식별하는 것을 돕는다. key는 엘리먼트에 안정적인 고유성을 부여하기 위해 배열 내부의 엘리먼트에 지정해야 한다. Key를 선택하는 가장 좋은 방법은 리스트의 다른 항목들 사이에서 해당 항목을 고유하게 식별할 수 있는 문자열을 사용하는 것이다. 대부분의 경우 데이터의 ID를 key로 사용한다.

    const todoItems = todos.map((todo) =>
      <li key={todo.id}>
        {todo.text}
      </li>
    );

    렌더링 한 항목에 대한 안정적인 ID가 없다면 최후의 수단으로 항목의 인덱스를 key로 사용할 수 있다. 다만 항목의 순서가 바뀔 수 있는 경우 key에 인덱스를 사용하는 것은 권장하지 않는다. 이로 인해 성능이 저하되거나 컴포넌트의 state와 관련된 문제가 발생할 수 있기 때문이다.

    const todoItems = todos.map((todo, index) =>
      // 안정적인 아이디가 없는 경우에 항목의 index를 key로 사용할 수 있음(권장하진 않음)
      <li key={index}>
        {todo.text}
      </li>
    );

    📝 정리

    "React는 선언적 API를 제공하기 때문에 갱신이 될 때마다 매번 무엇이 바뀌었는지를 걱정할 필요가 없다."

    이 글을 시작하면서 썼던 문장을 다시 한번 곱씹어보자. 개발자가 애플리케이션의 각 상태에 대한 뷰를 설계하면 React가 데이터가 변경됨에 따라 적절한 컴포넌트만 효율적으로 갱신하고 렌더링한다. 즉, 앞서 설명한 알고리즘들로 DOM이 그 상태와 일치하도록 React가 알아서 하기 때문에 개발자는 사용할 때 비교 알고리즘 등의 내부 동작을 알 필요가 없다는 것이다. 

    이렇게 React의 내부 동작을 제대로 뜯어봄으로써 React를 보다 더 깊이 이해할 수 있었다👍🏻