프론트 기술 질문 6. Virtual DOM과 Reconciliation

기운찬곰·2023년 9월 8일
0

프론트 기술 질문

목록 보기
6/10
post-thumbnail

Overview

이번 시간에는 React에 대해 이야기해볼까 합니다. 프론트엔드 기술 면접이고 내가 React를 사용했다고 하면 당연히 빠질 수 없는 질문이 React 겠죠? 그 중 React 질문으로 가장 대표적인 것이 'Virtual DOM'입니다.

아, 그 전에 먼저 리액트가 컴포넌트를 화면에 표시하는 과정부터 알아볼까요?


Render and Commit

참고 : https://react.dev/learn/render-and-commit

한번 읽어보면 좋을 거 같아서 공식문서를 통해 컴포넌트가 화면에 표시되는 과정에 대해 알아보도록 하겠습니다.

공식문서에서 비유하기를 컴포넌트가 주방에서 요리사들이고, 재료들로부터 맛있는 요리를 조립합니다. 리액트는 고객의 요청을 받아 주문을 하는 웨이터입니다. UI를 요청하고 제공하는 프로세스라고 볼 수 있습니다. 이 단계는 세 가지 단계로 구성됩니다.

  1. Triggering a render: 손님의 주문을 주방에 전달
  2. Rendering the component : 주방에서 주문 받아서 요리하기
  3. Committing to the DOM: 테이블에 요리 가져다 놓기

Step 1: Trigger a render

컴포넌트가 렌더링을 하게 되는 트리거는 2가지입니다.

  • 컴포넌트의 initial render (초기 렌더)
  • 컴포넌트(또는 그 부모 컴포넌트 중 하나)의 상태가 업데이트 되는 경우

Initial render

앱이 시작되면 초기 렌더를 실행해야 합니다. 대상 DOM 노드를 createRoot로 호출한 다음 컴포넌트로 render 메서드를 호출하는 방식으로 수행됩니다:

import { createRoot } from 'react-dom/client';
import App from './App.js';

const root = createRoot(document.getElementById('root'))
root.render(<App />);

Re-renders when state updates

set 함수로 상태를 업데이트하여 추가 렌더링을 트리거할 수 있습니다. 컴포넌트의 상태를 업데이트하면 자동으로 렌더가 대기합니다. (비유하자면 식당 손님이 메인 음식을 시켰는데, 갈증이나 배고픔의 상태에 따라 이후에 음료나 디저트 등을 주문하는 것입니다.)

Step 2: React renders your components

렌더를 트리거한 후 React가 컴포넌트를 호출(call)하여 화면에 표시할 내용을 확인합니다.

  • 초기 렌더링 시 React가 루트 컴포넌트를 호출합니다.
  • 후속 렌더링의 경우 React는 상태 업데이트가 렌더링을 트리거한 함수 컴포넌트를 호출합니다.

이 프로세스는 재귀적입니다. 업데이트된 컴포넌트가 다른 컴포넌트를 반환하면 해당 컴포넌트를 다음으로 렌더링하는 구조입니다. 이 프로세스는 더 이상 중첩된 컴포넌트가 없을 때까지 계속 진행되며 리액트는 화면에 무엇이 표시되어야 하는지 정확히 알 수 있습니다. (흔히 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 리렌더링 된다고 하죠? 이런 이유에서 입니다)

re-render 하는 동안, 리액트는 이전 렌더 이후 변경된 속성을 계산합니다. 다음 단계인 커밋 단계까지는 그 정보에 대해 아무것도 하지 않을 것입니다.

Step 3: React commits changes to the DOM

컴포넌트를 렌더링(호출)한 후 React가 DOM을 수정합니다.

  • 초기 렌더링의 경우 React는 appendChild() DOM API를 사용하여 작성한 모든 DOM 노드를 화면에 표시합니다.
  • re-renders 의 경우, 리액트는 DOM이 최신 렌더링 출력과 일치하도록 필요한 최소한의 작업(렌더링 중에 계산)을 적용합니다. 렌더 간에 차이가 있는 경우에만 리액트가 DOM 노드를 변경합니다.

예를 들어, 매초마다 부모로부터 전달되는 props 으로 재렌더링하는 컴포넌트가 있습니다. 텍스트를 input에 추가하여 값을 업데이트할 수 있지만 컴포넌트가 다시 렌더링될 때 텍스트가 사라지지 않는 것에 주목하십시오.

export default function Clock({ time }) {
  return (
    <>
      <h1>{time}</h1>
      <input />
    </>
  );
}

이것은 React가 <h1>의 내용만 업데이트하기 때문입니다. JSX에 지난번과 같은 장소에 input이 있으므로, 리액트는 input이나 그 값에 손을 대지 않습니다.

Epilogue: Browser paint

렌더링이 완료되고 리액트가 DOM을 업데이트하면 브라우저가 화면을 다시 그립니다. 이 과정을 "브라우저 렌더링"이라고 하지만 리액트 문서 전체에서 혼란을 피하기 위해 "painting"이라고 부를 것입니다.


State as a Snapshot

참고 : https://react.dev/learn/state-as-a-snapshot

상태 변수는 읽고 쓸 수 있는 일반 자바스크립트 변수처럼 보일 수 있습니다. 그러나 상태는 스냅샷과 더 유사하게 동작합니다. 상태 변수를 바꾸면 이미 가지고 있는 상태 변수가 변경되지 않고 대신 re-render가 트리거됩니다. (개인적으로 저는 리액트의 본질은 스냅샷이 아닐까 생각합니다)

Setting state triggers renders

일반적으로 사용자 인터페이스가 클릭과 같이 사용자 이벤트에 의해 직접적으로 반응하여 변경된다고 생각할 수 있습니다. 리액트에서는 이런 모델과는 다르게 작동합니다. 인터페이스가 이벤트에 반응하려면 상태를 업데이트해야 합니다.

이 예에서는 "send"를 누르면 setIsSent(true)가 React에게 UI를 다시 렌더링하도록 지시합니다:

import { useState } from 'react';

export default function Form() {
  const [isSent, setIsSent] = useState(false);
  const [message, setMessage] = useState('Hi!');
  
  if (isSent) {
    return <h1>Your message is on its way!</h1>
  }
  
  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      setIsSent(true);
      sendMessage(message);
    }}>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit">Send</button>
    </form>
  );
}

function sendMessage(message) {
  // ...
}

버튼을 클릭하면 다음과 같이 동작합니다:

  1. onSubmit 이벤트 핸들러가 실행됩니다.
  2. setIsSent(true)는 isSent를 true로 설정하며 새 렌더를 대기열(queues)에 넣습니다.
  3. React는 새 isSent 값에 따라 컴포넌트를 다시 렌더링합니다.

상태와 렌더링의 관계에 대해 자세히 알아보겠습니다.

Rendering takes a snapshot in time

"렌더링"은 React가 함수인 컴포넌트를 호출하는 것을 의미합니다. 해당 함수에서 반환하는 JSX는 UI의 스냅샷과 같습니다. props, event handlers, local variables는 모두 렌더 당시의 상태를 이용하여 계산됩니다.

사진이나 영화 프레임과는 달리, 여기서 반환하는 UI 스냅샷은 대화형입니다. 리액트는 이 스냅샷과 일치하도록 화면을 업데이트하고 이벤트 핸들러를 연결합니다. 따라서 버튼을 누르면 JSX에서 클릭 핸들러가 트리거됩니다.

리액트가 컴포넌트를 re-renders 할 때:

  1. 리액트가 함수를 다시 호출합니다.
  2. 함수가 새 JSX 스냅샷을 반환합니다.
  3. 그런 다음 반환한 스냅샷과 일치하도록 화면을 업데이트합니다.

상태는 함수가 반환된 후 사라지는 일반 변수와는 다릅니다. 선반 위에 있는 것처럼 실제로는 살아있다고 말할 수 있습니다. 컴포넌트는 JSX에 있는 새로운 props 및 event handlers 세트와 함께 UI의 스냅샷을 반환하며, 모두 해당 렌더의 상태 값을 사용하여 계산됩니다!

이것이 어떻게 작동하는지 보여줄 작은 실험이 있습니다. 이 예제에서 "+3" 버튼을 클릭하면 setNumber(숫자 + 1)가 세 번 호출되므로 카운터가 세 번 증가할 것으로 예상할 수 있습니다. 정말 그럴까요?

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}>+3</button>
    </>
  )
}

클릭 한 번에 한 번씩만 숫자가 증가한다는 것에 주목하세요!

setNumber(number + 1)를 세 번 호출했지만 이 렌더의 이벤트 핸들러 number는 항상 0이므로 상태를 1로 세 번 설정합니다. 이 때문에 이벤트 핸들러가 끝나면 React가 컴포넌트를 3이 아닌 1로 다시 렌더링합니다.

코드에 있는 상태 변수 값을 대입하여 이를 시각화할 수도 있습니다.

<button onClick={() => {
  setNumber(0 + 1);
  setNumber(0 + 1);
  setNumber(0 + 1);
}}>+3</button>

Virtual DOM

위에서는 Virtual DOM에 대해 직접적인 언급은 하지 않았지만 Virtual DOM의 핵심은 이미 다 나왔다고 볼 수 있겠네요. 이전 공식 문서에서는 Virtual DOM을 다음과 같이 설명하고 있습니다.

Virtual DOM (VDOM)은 UI의 이상적인 또는 “가상”적인 표현을 메모리에 저장하고 ReactDOM과 같은 라이브러리에 의해 “실제” DOM과 동기화하는 프로그래밍 개념입니다. 이 과정을 재조정이라고 합니다.

흔히 이런 그림 많이 보셨을거 같습니다.

출처 : https://www.oreilly.com/library/view/learning-react-native/9781491929049/ch02.html

Real DOM

애플리케이션은 UI의 상태가 변경될 때마다 해당 변경 사항을 나타내기 위해 Real DOM (Browser DOM)가 업데이트 됩니다. 하지만 DOM을 직접 조작하는것은 성능에 영향을 미쳐 속도가 느려지는 현상이 발생한다고 하죠.

무엇이 DOM 조작을 느리게 만드는 걸까요? DOM은 트리구조로 표현됩니다. 이 때문에 DOM의 변경 및 업데이트가 빠릅니다. 그러나 변경 후 애플리케이션 UI를 업데이트하려면 업데이트된 요소와 해당 요소의 자식을 다시 렌더링해야 합니다. UI를 다시 렌더링하거나 다시 페인트 하는 것이 속도를 느리게 만드는 이유입니다. 따라서 UI 구성 요소가 많을수록 DOM 업데이트 비용이 더 많이 들 수 있는데, 이는 모든 DOM 업데이트를 다시 렌더링해야 하기 때문입니다.

Virtual DOM이 생긴 이유

바로 여기서 Virtual DOM의 개념이 들어와 Real DOM보다 훨씬 더 나은 성능을 발휘합니다. Virtual DOM은 DOM의 가상 표현일 뿐입니다. 애플리케이션의 상태가 바뀔 때마다 Real DOM 대신 Virtual DOM이 업데이트됩니다.

이것이 Real DOM을 업데이트하는 것보다 어떻게 더 빠를 수 있을까요? 🤔

UI에 새로운 요소를 추가하면 트리로 표현되는 Virtual DOM이 메모리에 생성됩니다. 각 요소는 이 트리의 노드입니다. 이러한 요소 중 하나의 상태가 변경되면 새 Virtual DOM 트리가 생성됩니다. 그러면 이 트리는 이전의 Virtual DOM 트리와 비교(Diff) 됩니다. 이전 스냅샷과 바뀐 스냅샷을 비교한다고 보면 되겠네요.

이 작업이 완료되면 가상 DOM은 실제 DOM에 이러한 변경을 수행하기 위해 가능한 최선의 방법을 계산합니다. 이를 통해 Real DOM에서 최소한의 작업이 수행되도록 보장합니다. 따라서 Real DOM 업데이트에 따른 성능 비용을 절감할 수 있습니다.


재조정 (Reconciliation)

참고 : https://ko.legacy.reactjs.org/docs/reconciliation.html

React는 선언적 API를 제공하기 때문에 갱신이 될 때마다 매번 무엇이 바뀌었는지를 걱정할 필요가 없습니다. 이는 애플리케이션 작성을 무척 쉽게 만들어주지만, React 내부에서 어떤 일이 일어나고 있는지는 명확히 눈에 보이지 않습니다. (흔히 이런걸 블랙박스라고 하죠... 😂)

이 글에서는 React가 “비교 (diffing)” 알고리즘을 만들 때 어떤 선택을 했는지를 소개합니다. 이 비교 알고리즘 덕분에 컴포넌트의 갱신이 예측 가능해지면서도 고성능 앱이라고 불러도 손색없을 만큼 충분히 빠른 앱을 만들 수 있습니다.

동기

하나의 트리를 가지고 다른 트리로 변환하기 위한 최소한의 연산 수를 구하는 알고리즘 문제를 풀기 위한 일반적인 해결책들이 있습니다. 하지만 최첨단의 알고리즘도 n개의 엘리먼트가 있는 트리에 대해 O(n^3)의 복잡도를 가집니다. React에 이 알고리즘을 적용한다면, 1000개의 엘리먼트를 그리기 위해 10억 번의 비교 연산을 수행해야 합니다. 너무나도 비싼 연산이죠

React는 대신, 두 가지 가정을 기반하여 O(n) 복잡도의 휴리스틱 알고리즘을 구현했습니다.

  1. 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
  2. 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.

실제로 거의 모든 사용 사례에서 이 가정들은 들어맞습니다.

비교 알고리즘 (Diffing Algorithm)

두 개의 트리를 비교할 때, React는 두 엘리먼트의 루트(root) 엘리먼트부터 비교합니다. 이후의 동작은 루트 엘리먼트의 타입(type)에 따라 달라집니다.

참고로, JSX가 바벨로 변환되면 React.createElement(type, props, children)이 되는데, 이를 실행하면 React Element 객체를 반환하게 됩니다. 이 객체는 React에게 무엇을 렌더할지를 알려줍니다. 여기서 타입을 비교한다는 말은 type: 'nav', type: 'article'와 같이 React Element 객체에 있는 type을 비교한다는 말과 같습니다. Virtual DOM이 메모리에 가지고 있는 녀석이 사실 자바스크립트 객체인 것입니다.

{
  $$typeof: Symbol.for("react.element"), // Tells React it's a JSX element (e.g. <html>)
  type: 'html',
  props: {
    children: [
      {
        $$typeof: Symbol.for("react.element"),
        type: 'head',
        props: {
          children: {
            $$typeof: Symbol.for("react.element"),
            type: 'title',
            props: { children: 'My blog' }
          }
        }
      },
      {
        $$typeof: Symbol.for("react.element"),
        type: 'body',
        props: {
          children: [
            {
              $$typeof: Symbol.for("react.element"),
              type: 'nav',
              props: {
                children: [{
                  $$typeof: Symbol.for("react.element"),
                  type: 'a',
                  props: { href: '/', children: 'Home' }
                }, {
                  $$typeof: Symbol.for("react.element"),
                  type: 'hr',
                  props: null
                }]
              }
            },
            {
              $$typeof: Symbol.for("react.element"),
              type: 'article',
              props: {
                children: postContent
              }
            },
            {
              $$typeof: Symbol.for("react.element"),
              type: 'footer',
              props: {
                /* ...And so on... */
              }              
            }
          ]
        }
      }
    ]
  }
}

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

두 루트 엘리먼트의 타입이 다르면, React는 이전 트리를 버리고 완전히 새로운 트리를 구축합니다. 예를 들어, 아래와 같은 비교가 일어난다고 가정해봅시다.

<div>
  <Counter />
</div>

<span>
  <Counter />
</span>

루트 앨리먼트가 div에서 span으로 변경되었기 때문에 타입이 변경된 것입니다. 결국, 이전 Counter는 사라지고, 새로 다시 마운트가 될 것입니다.

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

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

예를 들어, 이 두 엘리먼트를 비교하면, React는 현재 DOM 노드 상에 className만 수정합니다.

<div className="before" title="stuff" />

<div className="after" title="stuff" />

마찬가지로 style이 갱신될 때, React는 또한 변경된 속성만을 갱신합니다. 아래 예시에서 React는 fontWeight는 수정하지 않고 color 속성 만을 수정합니다.

<div style={{color: 'red', fontWeight: 'bold'}} />

<div style={{color: 'green', fontWeight: 'bold'}} />

DOM 노드의 처리가 끝나면, React는 이어서 해당 노드의 자식들을 재귀적으로 처리합니다.

자식에 대한 재귀적 처리

DOM 노드의 자식들을 재귀적으로 처리할 때, React는 기본적으로 동시에 두 리스트를 순회하고 차이점이 있으면 변경을 생성합니다. 예를 들어, 자식의 끝에 엘리먼트를 추가하면, 두 트리 사이의 변경은 잘 작동할 것입니다.

<ul>
  <li>first</li>
  <li>second</li>
</ul>

<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

React는 두 트리에서 <li>first</li>가 일치하는 것을 확인하고, <li>second</li>가 일치하는 것을 확인합니다. 그리고 마지막으로 <li>third</li>를 트리에 추가합니다.

하지만 위와 같이 단순하게 구현하면, 리스트의 맨 앞에 엘리먼트를 추가하는 경우 성능이 좋지 않습니다. 예를 들어, 아래의 두 트리 변환은 형편없이 작동합니다.

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

React는 <li>Duke</li><li>Villanova</li> 종속 트리를 그대로 유지하는 대신 모든 자식을 변경합니다. 이러한 비효율은 문제가 될 수 있습니다. 이러한 문제를 해결하기 위해, React는 key 속성을 지원합니다. 자식들이 key를 가지고 있다면, React는 key를 통해 기존 트리와 이후 트리의 자식들이 일치하는지 확인합니다.

예를 들어, 위 비효율적인 예시에 key를 추가하여 트리의 변환 작업이 효율적으로 수행되도록 수정할 수 있습니다.

<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

이제 React는 '2014' key를 가진 엘리먼트가 새로 추가되었고, '2015'와 '2016' key를 가진 엘리먼트는 그저 이동만 하면 되는 것을 알 수 있습니다. 실제로, key로 사용할 값을 정하는 것은 어렵지 않습니다. 그리려고 하는 엘리먼트는 일반적으로 식별자를 가지고 있을 것이고, 그대로 해당 데이터를 key로 사용할 수 있습니다.

<li key={item.id}>{item.name}</li>

이러한 상황에 해당하지 않는다면, 여러분의 데이터 구조에 ID라는 속성을 추가해주거나 데이터 일부에 해시를 적용해서 key를 생성할 수 있습니다. 최후의 수단으로 배열의 인덱스를 key로 사용할 수 있습니다. 항목들이 재배열되지 않는다면 이 방법도 잘 동작할 것이지만, 재배열되는 경우 비효율적으로 동작할 것입니다. 💡


마치면서

이번 시간을 통해 React 핵심 개념에 대해 많은 것을 다시 생각해볼 수 있게 하는 계기가 되었네요. 사실 React는 이보다 더 복잡하고 분석해야할 내용도 많지만... 그 부분에 대해서는 아직 미흡하니 좀 더 공부해서 가능하면 정리해보도록 하겠습니다.

참고 자료

profile
velog ckstn0777 부계정 블로그 입니다. 프론트 개발 이외의 공부 내용을 기록합니다. 취업준비 공부 내용 정리도 합니다.

0개의 댓글

관련 채용 정보