본문 바로가기
tech epilogue

React vs Vue 렌더링 차이

by rami_ 2026. 6. 11.

1. 반응성 시스템

React

  • 불변성(Immutability) 기반
  • 상태 변경시 setState, useState로 새 객체를 생성해야 리렌더링 트리거
  • 변경 감지를 개발자가 명시적으로 수행
//React : 새 참조를 만들어야 감지
const [user, setUser] = useState({ name: 'Rami' });
setUser({ ...user, name: 'New' });

 

Vue 3

  • 반응형 프록시 기반(reactive, ref)
  • 객체를 직접 변이(mutate)해도 Vue가 자동 감지
  • 내부적으로 의존성 추적을 런타임에 수행
//Vue : 직접 변이 가능
const user = reactive({ name : 'Rami' });
user.name = 'New'; //그냥 대입해도 리렌더링

 

2. 렌더링 트리거 방식

구분 React Vue 3
감지 방식 상태 참조 비교(Object.is) Proxy getter/setter 인터셉트
추적 시점 렌더 시 전체 트리 순회 컴포넌트 렌더 중 자동 수집
기본 동작 부모 리렌더 -> 자식 전부 리렌더 실제로 사용된 의존성만 리렌더

 

3. Virtual DOM 처리 전략

 

React

  • 렌더마다 전체 컴포넌트 함수 재실행
  • useMemo, useCallback, React.memo로 수동 최적화 필요
  • Fiber 아키텍처 : 렌더 작업을 청크로 분리, 우선순위 스케줄링

Vue 3

  • 컴파일 타임 최적화 수행
    • render 함수로 변환하면서 정적/동적 부분을 분석한다. 이 과정에서 3가지 핵심 최적화가 들어간다.
      • hoistStatic - 정적 노드 호이스팅
        • 한 번도 변하지 않는 노드는 렌더 함수 바깥으로 끌어올려서 재사용한다. 매 렌더마다 새로 생성하지 않음.
        • 효과 : diff 알고리즘이 이 노드를 만나면 "참조가 동일하다"는 걸 알고 즉시 건너 띔(skip)
      • patchFlag - 동적 노드에 변경 부위 마킹
        • 컴파일러가 템플릿을 분석해서 이 노드의 어떤 부분이 동적인가를 미리 파악하고, 비트 플래그로 표시한다. diff 시 전체 속성을 비교하지 않고 표시된 부분만 비교.
        • 핵심 효과 : 비교해야할 속성의 개수가 줄어든다. -> React의 전체 diff보다 빠름. 
      • cacheHandlers - 이벤트 핸들러 캐싱
        • 인라인 이벤트 핸들러는 보통 매 렌더마다 새 함수가 생성되는데, Vue 컴파일러는 이를 자동으로 캐싱해서 같은 함수 참조를 재사용한다.
//예시 템플릿
<template>
  <div>
    <h1>고정 제목</h1>
    <p>{{ count }}</p>
  </div>
</template>


// hoistStatic 적용 전 - 매 렌더마다 새 vnode 생성
function render() {
  return h('div', [
    h('h1', '고정 제목'),  // 매번 새로 생성됨
    h('p', count.value)
  ]);
}

// hoistStatic 적용 후 - 함수 바깥에서 1회만 생성
const _hoisted_1 = h('h1', '고정 제목'); // 컴포넌트 생성 시 1회만

function render() {
  return h('div', [
    _hoisted_1,           // 캐시된 vnode 재사용
    h('p', count.value)
  ]);
}

 

//예시 템플릿
<template>
  <div :class="dynamicClass" :id="staticId" @click="handleClick">
    {{ message }}
  </div>
</template>


//컴파일 결과
function render() {
  return h(
    'div',
    {
      class: dynamicClass,    // 동적
      id: staticId,           // 정적 (한 번 설정 후 안 변함)
      onClick: handleClick
    },
    message,
    1 /* TEXT */ | 2 /* CLASS */  // ← patchFlag
  );
}
//예시 템플릿
<template>
  <button @click="count++">증가</button>
</template>


//컴파일 결과
// cacheHandlers 적용 전
function render() {
  return h('button', {
    onClick: () => count.value++  // 매 렌더마다 새 함수 생성
  }, '증가');
}

// cacheHandlers 적용 후
function render(_ctx, _cache) {
  return h('button', {
    onClick: _cache[0] || (_cache[0] = () => count.value++)
    //        ↑ 캐시 확인        ↑ 없으면 1회 생성 후 캐시에 저장
  }, '증가');
}

 

React와 비교

// React: 매 렌더마다 새 함수 → 자식이 React.memo여도 리렌더 발생
<Button onClick={() => setCount(c => c + 1)} />

// React에서 막으려면 직접 useCallback 필요
const handleClick = useCallback(() => setCount(c => c + 1), []);
<Button onClick={handleClick} />


<!-- Vue: 컴파일러가 자동으로 useCallback 같은 효과를 적용 -->
<Button @click="count++" />  <!-- 별도 처리 불필요 -->

 

4. 배칭 처리 

React 18+

  • 자동 배칭(Automatic Batching) 도입
  • 이벤트 핸들러, setTimeout, Promise 내부까지 모두 배치처리
  • 강제 동기 처리가 필요하면 flushSync사용

Vue 3

  • 상태변경을 마이크로태스크 큐에 모아 다음 tick에 한번에 렌더
  • nextTick()으로 DOM 업데이트 완료 후 콜백 실행 가능

 

5. 실무적 시사점

React : 개발자가 최적화를 직접 제어 -> 유연하지만 실수 여지 있음

Vue : 프레임워크가 최적화를 담당 -> 예측 가능하지만 내부 추상화 의존

 

React는 렌더링을 추적하는 습관, Vue는 반응형 데이터 범위를 명확히 하는 습관이 성능 디버깅의 핵심이 된다.

 

 

6. Vue SFC

SFC(Single File Component)

<!-- MyComponent.vue -->
<template>
  <div class="box">{{ message }}</div>
</template>

<script setup>
const message = 'Hello'
</script>

<style scoped>
.box { color: red; }
</style>

HTML + JS + CSS가 한 파일에 합쳐진 .vue 파일을 SFC라고 부른다.

.vue 파일을 vite를 통해 빌드하면

// MyComponent.vue → 빌드 후 (개념적 결과물)

import { createElementVNode as _createElementVNode } from 'vue'

export default {
  setup() {
    const message = 'Hello'
    return { message }
  },
  render(_ctx) {
    return _createElementVNode('div', { class: 'box' }, _ctx.message)
  }
}

 

 

7.  Vue와 React 렌더링 과정(초기 렌더 ~ 업데이트)

전체 흐름을 초기 마운트와 업데이트 두 단계로 나눠서 비교한다.

 

1)  React 렌더링 과정

1-1 초기 마운트

1. JSX 작성
   <div><Counter /></div>
        ↓ Babel 컴파일
   React.createElement(Counter)
        ↓
2. createElement → Element 객체 생성 (description일 뿐, 실제 DOM 아님)
   { type: Counter, props: {}, ... }
        ↓
3. ReactDOM.createRoot().render() 호출
        ↓
4. React가 컴포넌트 함수 실행
   function Counter() {
     const [count, setCount] = useState(0); // Fiber 노드에 hook 상태 저장
     return <button>{count}</button>;
   }
        ↓
5. Fiber 트리 생성 (Reconciliation의 작업 단위)
   각 컴포넌트/엘리먼트 = Fiber 노드
        ↓
6. Commit Phase
   실제 DOM 노드 생성 → document에 삽입

 

1-2. 업데이트

이벤트 발생 (예: setCount(1))
        ↓
1. State 변경 예약 (스케줄링)
   - React 18: 자동 배칭으로 여러 setState 묶음
        ↓
2. Render Phase (재조정/Reconciliation)
   - 컴포넌트 함수 재실행 → 새 Element 트리 생성
   - 이전 Fiber 트리와 새 Element 트리 비교 (diffing)
   - 변경 필요한 Fiber에 표시(flag)
   
   ⚠️ 이 단계는 중단 가능 (Concurrent Mode)
   - 우선순위 높은 작업(예: 사용자 입력)이 끼어들면 일시 중지
        ↓
3. Commit Phase
   - 표시된 변경사항만 실제 DOM에 적용
   - 동기적으로 한 번에 실행 (중단 불가)
   - useEffect 콜백 실행

 

React 핵심 키워드

Element → Fiber → Reconciliation → Commit
         ↑
    이 단계가 "Virtual DOM diffing"

 

2) Vue3 렌더링 과정

2-1. 초기 마운트

1. .vue 파일 작성
   <template><button>{{ count }}</button></template>
        ↓ 컴파일러 (빌드 타임)
2. render 함수로 변환 + 최적화 적용
   function render(_ctx) {
     return h('button', null, _ctx.count, 1 /* TEXT patchFlag */)
   }
        ↓
3. createApp().mount() 호출
        ↓
4. setup() 실행
   const count = ref(0)
   return { count }
        ↓
5. 반응형 객체 생성 (Proxy)
   ref(0) → { value: 0 }을 Proxy로 감쌈
   getter/setter 인터셉트 가능 상태
        ↓
6. 첫 render() 실행
   - render 함수 실행 중 count.value 접근
   - "이 컴포넌트는 count에 의존한다" → 의존성 자동 수집(track)
        ↓
7. VNode 생성 → 실제 DOM 생성 → 마운트

 

2-2. 업데이트

count.value++ 실행
        ↓
1. Proxy의 setter 트리거 (trigger)
   - "count를 구독 중인 effect들"을 찾아서 큐에 등록
        ↓
2. 비동기 큐잉 (스케줄러)
   - 같은 tick에 여러 변경이 있어도 1번만 재실행 예약
   - 마이크로태스크로 처리 → nextTick에서 실행
        ↓
3. 컴포넌트 re-render 함수 실행
   - 컴파일 타임에 박힌 patchFlag 확인
   - patchFlag=1(TEXT)이면 → 텍스트 콘텐츠만 비교
   - hoistStatic된 노드는 비교 자체 스킵
        ↓
4. 최소 범위 patch → 실제 DOM 업데이트

 

Vue 핵심 키워드

Proxy(반응형) → track/trigger → Scheduler(큐잉) → 컴파일 최적화 patch
              ↑
        "필요한 컴포넌트만, 필요한 부분만" 자동 추적

 

┌─────────────────────────────────────────────────────────┐
│                     React                                │
├─────────────────────────────────────────────────────────┤
│ 트리거: setState 호출                                     │
│ 추적 방식: 없음 (호출되면 무조건 재실행)                   │
│ 비교 범위: 컴포넌트 전체 트리 재실행 후 diff               │
│ 비교 대상: 이전/이후 Element 트리 전체                    │
│ 최적화 책임: 개발자 (memo, useMemo, useCallback)          │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                     Vue 3                                │
├─────────────────────────────────────────────────────────┤
│ 트리거: reactive 데이터 변경 (Proxy setter)               │
│ 추적 방식: 렌더 중 자동 의존성 수집(track)                 │
│ 비교 범위: patchFlag로 표시된 동적 부분만                  │
│ 비교 대상: 정적 노드는 애초에 비교 대상 아님 (hoisted)     │
│ 최적화 책임: 컴파일러 (개발자는 거의 신경 안 씀)           │
└─────────────────────────────────────────────────────────┘

 

3) 타임라인

React

[State 변경]
    ↓
[전체 함수 재실행] → "이 컴포넌트가 의존하는 모든 값" 다시 계산
    ↓
[새 Element 트리 vs 이전 Fiber 트리 diff]
    ↓
[변경분만 DOM commit]

 

Vue3

[Proxy 변경 감지]
    ↓
[해당 값을 구독한 effect만 큐에 등록]
    ↓
[다음 tick에 해당 컴포넌트 render 재실행]
    ↓
[patchFlag로 동적 부분만 비교 → DOM patch]

 

 

8. 가장 큰 철학 차이

React: "값이 바뀌면 함수를 다시 실행해서 무엇이 바뀌었는지 비교한다"
       → Pull 방식 (재계산 후 비교)

Vue:   "어떤 값이 바뀌면 그 값을 쓰는 곳만 정확히 알고 있다"
       → Push 방식 (변경 즉시 구독자에게 알림)

이 차이 때문에 :

 - React : 컴포넌트가 커질수록 불필요한 재실행 가능성 ↑ -> 수동 최적화 필요

 - Vue : 의존성 추적이 정교해서 자동으로 최소 범위만 업데이트 -> 단, Proxy 추적의 엣지 케이스(배열 인덱스 직접 할당, 구조 분해 시 반응성 손실 등) 주의 필요