트러블이슈

react와 vue 비교하기

Ahyeon, Jung 2024. 5. 24. 12:33

처음에 배울 때 가장 많이 사용하는 리액트로 배우게 되었고, 이후에는 자연스럽게 혹은 당연하게 리액트의 상태관리 라이브러리나 Next.js에 관심을 갖고 배워나갔다. 그러다가 react-hook-form을 사용하면서 상태관리가 완전한 답이 아니라는 것을 알고 나니, virtual DOM도 완전한 답이 아니겠다는 생각이 들었다. 게다가 사실상 페이지 라우팅만 있고, 데이터의 변화로 리렌더링 필요가 없는 이 프로젝트는 굳이 리액트를 쓸 필요가 없었다. 그러면, 리액트 외에 뭘 선택해야하는가?


React와 Vue

리액트와 뷰는 컴포넌트 기반이며, Virtual DOM 방식을 사용하는 점에서 같다. 모두 웹 UI를 작은 컴포넌트 단위로 구성하여 재사용과 확장성을 이용하여 개발이 가능하다. 또한 Virtual DOM을 사용하여 실제 DOM 변화를 최소화 시켜준다. 

 

Vue

Vue는 단일 파일 컴포넌트 기반의 컴포넌트로, HTML, CSS, JavaScript 코드를 .vue 확장자 파일 하나에 모두 정의한다. 이러한 관리 방식은 적당한 규모의 프로젝트에서 관리의 생산성을 높일 수 있습니다. 또한 브라우저 화면에 렌더링할 때 템플릿을 사용하는데, 이때 HTML 기반으로 이루어져 있어 배우기 쉽다.

  1. template이 JavaScript 렌더링 함수로 변환되고, 렌더링 함수는 데이터 상태를 기반으로 가상 DOM을 생성한다.
  2. Vue의 반응형 시스템이 데이터 객체를 프록시로 감싸서 종속성을 추적한다. 각 데이터 속성에 대한 getter와 setter를 정의하여 데이터 접근과 변화를 감지한다.
  3. 컴포넌트의 데이터가 변경되면 해당 데이터의 setter가 호출되어 Vue에게 변경 사항을 알린다. Vue는 이 변경을 감지하고 해당 데이터가 사용된 모든 렌더링 함수를 다시 호출하여 새로운 가상 DOM을 생성한다.
  4. Vue는 새로운 가상 DOM과 이전의 가상 DOM을 비교하여 변경된 부분을 찾아내는 diffing 과정을 거친다. 
  5. diffing 결과를 바탕으로 변경된 부분만 실제 DOM에 반영되어 효율적인 업데이트가 이루어진다.
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue를 만들어봅시다</title>
  <style>
    h1 {
      color: pink;
    }
  </style>
</head>
<body>
  <div id="app"></div>
  <script src="main.js"></script>
</body>
</html>
class Reactive {
  constructor(target) {
    this.target = target;
    this.dep = new Map();   // 각 속성에 대한 종속성을 추적하기 위한 Map
    return this.createProxy(target);  // Proxy를 생성하여 반환
  }

  createProxy(target) {
    const dep = this.dep;
    const handler = {
      // 객체의 속성에 접근할 때 호출
      get(obj, prop) {  // JS Proxy의 핸들러 메서드에서 자동 제공 매개변수
        if (prop in obj) {
          // 해당 속성에 대한 종속성 집합이 없으면 새로 만듦
          if (!dep.has(prop)) dep.set(prop, new Set());
          // activeEffect가 있으면 종속성 집합에 추가함
          if (Reactive.activeEffect) {
            dep.get(prop).add(Reactive.activeEffect);
          }
          return obj[prop];  // 속성값 반환
        }
      },
      // 객체의 속성을 설정할 때 호출
      set(obj, prop, value) {
        if (prop in obj) {
          obj[prop] = value;
          // 속성이 변경되면 해당 종속성 집합의 모든 효과를 실행함
          if (dep.has(prop)) {
            dep.get(prop).forEach(effect => effect());
          }
        }
        return true;  // 설정 완료
      }
    };
    
    // 프록시 객체를 반환하여 객체의 속성 접근과 설정을 처리
    return new Proxy(target, handler);
  }

  // activeEffect를 설정함
  static effect(effect) {
    Reactive.activeEffect = effect;
    effect();
    Reactive.activeEffect = null;
  }
}

// Vue 인스턴스를 생성
function createApp(rootComponent) {
  return {
    // 컴포넌트를 DOM에 마운트
    mount(selector) {
      const container = document.querySelector(selector);
      let isMounted = false;
      let oldVNode = null;

      Reactive.effect(() => {
        if (!isMounted) {
          // 처음 마운트될 때는 루트 컴포넌트를 렌더링하고 마운트
          oldVNode = rootComponent.render();
          mount(oldVNode, container);
          isMounted = true;
        } else {
          // 업데이트 시에는 패칭 과정을 통해 변경된 부분만 업데이트
          const newVNode = rootComponent.render();  새로운 가상 노드 렌더링
          patch(oldVNode, newVNode);
          oldVNode = newVNode;
        }
      });
    }
  };
}

// 가상 노드를 생성
function h(tag, props, ...children) {
  return { tag, props, children };
}

// 가상 노드를 실제 DOM에 마운트
function mount(vnode, container) {
  // 생성
  const el = document.createElement(vnode.tag);
  vnode.el = el;
  
  // 속성 추가
  if (vnode.props) {
    for (const key in vnode.props) {
      el.setAttribute(key, vnode.props[key]);
    }
  }

  // text일 경우 추가하고, child일 경우 재귀적으로 마운트
  vnode.children.forEach(child => {
    if (typeof child === 'string') {
      el.appendChild(document.createTextNode(child));
    } else {
      mount(child, el);
    }
  });

  container.appendChild(el);
}

// 노드를 비교하여 변경된 부분만 실제 DOM에 반영(diffing)
function patch(oldVNode, newVNode) {
  const el = newVNode.el = oldVNode.el;

  // 다를 경우, 변경하여 마운트
  if (oldVNode.tag !== newVNode.tag) {
    const newEl = document.createElement(newVNode.tag);
    el.replaceWith(newEl);  // 기존 엘리먼트를 새로운 엘리먼트로 교체
    mount(newVNode, newEl.parentElement);  // 새로운 가상노드 마운트
  } else {
    // 같을 경우, 속성 업데이트
    if (newVNode.props) {
      for (const key in newVNode.props) {
        el.setAttribute(key, newVNode.props[key]);
      }
    }

    const oldChildren = oldVNode.children;
    const newChildren = newVNode.children;

    const commonLength = Math.min(oldChildren.length, newChildren.length);
    for (let i = 0; i < commonLength; i++) {
      patch(oldChildren[i], newChildren[i]);
    }

    // 새로운 자식 노드가 더 길 경우 추가
    if (newChildren.length > oldChildren.length) {
      newChildren.slice(oldChildren.length).forEach(child => {
        mount(child, el);  // 차이나는 노드 마운트
      });
    // 이전 자식 노드가 더 길 경우 제거
    } else if (newChildren.length < oldChildren.length) {
      oldChildren.slice(newChildren.length).forEach(child => {
        el.removeChild(child.el);  // 차이나는 노드 제거 
      });
    }
  }
}

// 루트 컴포넌트
const App = {
  data: new Reactive({
    message: 'Hello, Vue!'
  }),
  render() {  // Vue의 Template 역할
    return h('div', {}, 
      h('h1', {}, this.data.message),
      h('button', { onclick: () => this.updateMessage() }, 'Update Message')
    );
  },
  updateMessage() {
    this.data.message = 'Hello, Updated Vue!';
  }
};

createApp(App).mount('#app');

React

페이스북의 지원을 받아 지속적인 버전 관리가 이루어지고, 사용자가 많아 다양한 레퍼런스와 라이브러리 등 커뮤니티가 잘 형성되어 있다. 또한 JSX(JavaScript XML) 코드로 컴포넌트를 작성하여 컴포넌트의 상태를 변화시키지 않고 관리한다. 즉, 변화가 일어나면 실제 브라우저의 DOM에 새로운 것을 적용하는 것이 아니라 자바스크립트로 이루어진 Virtual DOM에 렌더링을 하고, 기존의 DOM과 비교하여 변화가 일어난 곳만 업데이트 한다.

  1. JSX가 JavaScript 객체로 변환되어 가상 DOM을 구성한다. ReactDOM.render()가 호출되면 가상 DOM이 실제 DOM으로 렌더링된다.
  2. 컴포넌트의 상태나 속성이 변경되면, React는 해당 컴포넌트의 렌더링 함수를 다시 호출하여 새로운 가상 DOM을 생성한다.
  3. 새로운 가상 DOM과 이전 가상 DOM을 비교하여(diffing) 변경된 부분을 찾아낸다. 이 과정에서 같은 타입의 요소는 비교하며, 다른 타입의 요소는 완전히 교체한다.
  4. diffing 결과를 바탕으로 변경된 부분만 실제 DOM에 업데이트하는 reconcilation 과정을 거친다.
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>React를 만들어봅시다</title>
  <style>
    h1 {
      color: pink;
    }
  </style>
</head>
<body>
  <div id="root"></div>
  <script src="main.js"></script>
</body>
</html>
class Component {
  // 이러한 state 설정을 hooks이 대신해주면서 함수형 컴포넌트를 사용하게 됨
  constructor(props) {
    this.props = props || {};  // props를 초기화
    this.state = {};  // 초기 state 빈 객체
  }

  // state를 부분적으로 업데이트하고 컴포넌트를 리렌더링
  setState(partialState) {
    this.state = { ...this.state, ...partialState };  // 현재 state를 새로운 state로 병합
    updateInstance(this.__internalInstance);  // 인스턴스를 업데이트하여 리렌더링
  }
}

// 가상 DOM 요소를 생성
function createElement(tag, props, ...children) {
  return { tag, props: props || {}, children };
}

// 가상 DOM 요소를 실제 DOM에 렌더링
function render(element, container) {
  const prevInstance = container.__internalInstance;
  const nextInstance = reconcile(container, prevInstance, element);  // 새 인스턴스와 비교하여 업데이트
  container.__internalInstance = nextInstance;
}

// 두 가상 DOM을 비교하고 실제 DOM을 업데이트
function reconcile(parentDom, instance, element) {
  // 이전 인스턴스가 없으면 새 인스턴스를 생성하여 마운트
  if (instance == null) {
    const newInstance = instantiate(element);
    parentDom.appendChild(newInstance.dom);
    return newInstance;
  } else if (element == null) {
    // 새 요소가 없으면 이전 인스턴스를 언마운트
    parentDom.removeChild(instance.dom);
    return null;
  } else if (instance.element.tag === element.tag) {
    // 태그가 동일하면 속성과 자식 요소를 업데이트
    updateDomProperties(instance.dom, instance.element.props, element.props);
    instance.childInstances = reconcileChildren(instance, element);
    instance.element = element;
    return instance;
  } else {
    // 태그가 다르면 새 인스턴스를 생성하여 교체
    const newInstance = instantiate(element);
    parentDom.replaceChild(newInstance.dom, instance.dom);
    return newInstance;
  }
}

// 자식 요소를 비교하고 업데이트하는 함수
function reconcileChildren(instance, element) {
  const dom = instance.dom;
  const childInstances = instance.childInstances;
  const nextChildElements = element.children || [];
  const newChildInstances = [];
  const count = Math.max(childInstances.length, nextChildElements.length);
  for (let i = 0; i < count; i++) {
    const childInstance = childInstances[i];
    const childElement = nextChildElements[i];
    const newChildInstance = reconcile(dom, childInstance, childElement);
    newChildInstances.push(newChildInstance);
  }
  return newChildInstances.filter(instance => instance != null);
}

// 가상 DOM 요소를 실제 DOM 인스턴스로 변환하는 함수
function instantiate(element) {
  const { tag, props, children } = element;

  const isTextElement = typeof element === 'string' || typeof element === 'number';
  if (isTextElement) {
    const dom = document.createTextNode(element);
    return { dom, element, childInstances: [] };
  }

  const isFunctionComponent = typeof tag === 'function';
  // 함수 컴포넌트일 경우 컴포넌트 인스턴스를 생성하여 렌더링
  if (isFunctionComponent) {
    const instance = new tag(props);
    const childElement = instance.render();
    const childInstance = instantiate(childElement);
    const dom = childInstance.dom;
    instance.__internalInstance = { dom, element, childInstance, publicInstance: instance };
    return instance.__internalInstance;
  }

  // 일반 DOM 요소일 경우 엘리먼트를 생성
  const dom = document.createElement(tag);
  updateDomProperties(dom, [], props);

  const childElements = children || [];
  const childInstances = childElements.map(instantiate);
  const childDoms = childInstances.map(childInstance => childInstance.dom);
  childDoms.forEach(childDom => dom.appendChild(childDom));

  return { dom, element, childInstances };
}

// DOM 속성을 업데이트
function updateDomProperties(dom, prevProps, nextProps) {
  const isEvent = name => name.startsWith('on');
  const isAttribute = name => !isEvent(name) && name != 'children';

  // 이전 속성에서 이벤트 리스너를 제거
  Object.keys(prevProps).filter(isEvent).forEach(name => {
    const eventType = name.toLowerCase().substring(2);
    dom.removeEventListener(eventType, prevProps[name]);
  });

  // 이전 속성에서 속성을 제거
  Object.keys(prevProps).filter(isAttribute).forEach(name => {
    dom[name] = null;
  });

  // 새 속성에서 속성을 추가
  Object.keys(nextProps).filter(isAttribute).forEach(name => {
    dom[name] = nextProps[name];
  });

  // 새 속성에서 이벤트 리스너를 추가
  Object.keys(nextProps).filter(isEvent).forEach(name => {
    const eventType = name.toLowerCase().substring(2);
    dom.addEventListener(eventType, nextProps[name]);
  });
}

// 인스턴스를 업데이트하는 함수
function updateInstance(internalInstance) {
  const parentDom = internalInstance.dom.parentNode;
  const element = internalInstance.element;
  reconcile(parentDom, internalInstance, element);
}

class App extends Component {
  constructor(props) {
    super(props);
    this.state = { message: 'Hello, React!' };
  }

  updateMessage() {
    this.setState({ message: 'Hello, Updated React!' });
  }

  render() {
    return createElement('div', {},
      createElement('h1', {}, this.state.message),
      createElement('button', { onClick: () => this.updateMessage() }, 'Update Message')
    );
  }
}

const appElement = createElement(App, {});
render(appElement, document.getElementById('root'));

완전히 잘못 안 상태에서 Vue를 이해하고 있었다. 리액트의 Virtual DOM까지는 안써도 될것같아서 Vue를 선택한 것이었는데, 애초에 내가 Vue의 동작 개념을 잘못 알고 있었다. Virtual DOM까지 필요하지 않을 것이라고 판단했다면 순수 JavaScript 프로젝트를 시작했어야 했다. 게다가 애초에 데이터의 변경이 일어나지 않기 때문에 아예 Virtual DOM이 작동할 일이 없기도 했다. 그런점에서 애초에 Virtual DOM의 오버헤드가 거의 미미하기도 하고 프로젝트 규모나 파일 통합, 추후 확장성에 대한 가능 등의 장점으로 Vue를 선택한 게도 적합한 선택이었던 듯 하다.

 

만들어진 코드를 읽은 정도라서 사실 정확히 이해는 못하고 그냥 내부 처리 과정이 다르다는 사실만 인식한 정도지만, 추후에 직접 작성해보면서 이해해야할듯하다.