전체 목록
ReactMedium#14

React의 Virtual DOM이란 무엇이며 성능에 어떤 영향을 미치나요?

#React#VirtualDOM#성능#렌더링
힌트

실제 DOM 조작 비용과 Diffing 알고리즘을 생각해보세요.

정답 및 해설

React의 Virtual DOM이란 무엇이며 성능에 어떤 영향을 미치나요?

Virtual DOM(가상 DOM)은 실제 브라우저 DOM의 가벼운 JavaScript 객체 복사본입니다. React는 상태가 변경될 때 실제 DOM을 바로 수정하는 대신, 메모리 상의 Virtual DOM을 먼저 업데이트한 뒤 이전 Virtual DOM과 비교(Diffing)하여 실제로 변경된 부분만 실제 DOM에 반영(Reconciliation)합니다. 이 과정을 통해 비용이 큰 DOM 조작을 최소화하여 렌더링 성능을 향상시킵니다.


실제 DOM의 문제점

브라우저 DOM은 강력하지만 조작 비용이 높습니다. DOM 요소 하나를 변경해도 브라우저는 다음 과정을 거칩니다.

DOM 변경 → Style 계산 → Layout(Reflow) → Paint → Composite
  • Reflow(리플로우): 요소의 크기/위치가 바뀌면 영향받는 모든 요소를 재계산
  • Repaint(리페인트): 색상, 배경 등 시각적 속성 변경 시 다시 그리기
  • 직접 DOM 조작의 비용: 1,000개 목록 항목을 개별 업데이트 시 1,000번의 리플로우 발생
// 비효율적인 직접 DOM 조작
const list = document.getElementById('list');
for (let i = 0; i < 1000; i++) {
  const item = document.createElement('li');
  item.textContent = `항목 ${i}`;
  list.appendChild(item); // 매번 reflow 발생
}

// 개선: DocumentFragment 사용
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const item = document.createElement('li');
  item.textContent = `항목 ${i}`;
  fragment.appendChild(item);
}
list.appendChild(fragment); // 1번의 reflow

Virtual DOM의 동작 원리

1단계: Virtual DOM 트리 생성

React 컴포넌트의 render 또는 함수 반환값은 실제 DOM 대신 JavaScript 객체로 표현됩니다.

// JSX 코드
const element = (
  <div className="container">
    <h1>안녕하세요</h1>
    <p>반갑습니다</p>
  </div>
);
// Babel이 변환한 React.createElement 호출
const element = React.createElement(
  'div',
  { className: 'container' },
  React.createElement('h1', null, '안녕하세요'),
  React.createElement('p', null, '반갑습니다')
);

// 실제 Virtual DOM 객체 구조
{
  type: 'div',
  props: {
    className: 'container',
    children: [
      { type: 'h1', props: { children: '안녕하세요' } },
      { type: 'p', props: { children: '반갑습니다' } }
    ]
  }
}

2단계: Diffing 알고리즘

상태가 변경되면 React는 새 Virtual DOM 트리를 생성하고 이전 트리와 비교합니다.

이전 Virtual DOM        새 Virtual DOM
      div                    div
     /   \                  /   \
   h1     p               h1     p
  "안녕"  "반갑"           "안녕"  "변경됨"  ← 변경 감지

React의 Diffing 알고리즘은 두 가지 가정을 기반으로 O(n) 복잡도를 달성합니다.

  1. 다른 타입의 요소는 다른 트리를 생성한다: 타입이 바뀌면 이전 트리 전체를 버리고 새로 구성
  2. key prop으로 자식 요소를 안정적으로 식별한다: 목록에서 변경된 항목만 업데이트
// 타입이 같을 때: 속성만 업데이트
// 이전: <div className="old" />
// 새로: <div className="new" />
// → className 속성만 업데이트

// 타입이 다를 때: 전체 교체
// 이전: <div>...</div>
// 새로: <span>...</span>
// → 이전 트리 언마운트 후 새 트리 마운트

3단계: Reconciliation (재조정)

Diffing 결과로 얻은 최소한의 변경사항만 실제 DOM에 일괄 적용합니다.

Virtual DOM 비교 결과:
- p 요소의 textContent 변경 필요

실제 DOM 조작 (1회):
document.querySelector('p').textContent = '변경됨';

성능 최적화 효과

일괄 업데이트 (Batching)

React는 여러 상태 변경을 하나의 렌더링으로 묶어 처리합니다.

function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  const handleClick = () => {
    // React 18: 이벤트 핸들러 내의 모든 setState를 1번의 리렌더링으로 처리
    setCount(prev => prev + 1);
    setName('업데이트됨');
    // → 렌더링 1회 발생 (2회 X)
  };
}
// React 18의 자동 배칭 (Automatic Batching)
// 이전: setTimeout, Promise 내부는 각각 리렌더링 발생
// React 18: 어디서나 자동 배칭

setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React 18: 1번의 리렌더링 (이전에는 2번)
}, 1000);

최소 DOM 업데이트

function ProductList({ products }) {
  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>
          <span>{product.name}</span>
          <span>{product.price}원</span>
        </li>
      ))}
    </ul>
  );
}

// 상태 변경: products[2].price만 변경됨
// Virtual DOM 비교 결과: 3번째 li의 price span만 변경
// 실제 DOM 조작: 1개 요소의 textContent만 업데이트

React Fiber 아키텍처 (React 16+)

React 18의 Fiber는 기존 재귀 방식의 한계를 극복한 새로운 렌더링 엔진입니다.

기존 Stack Reconciler의 문제

기존 방식 (동기적, 중단 불가):
렌더링 시작 → ... 긴 작업 ... → 렌더링 완료
           ↑ 이 동안 UI 블로킹, 애니메이션 끊김

Fiber의 해결책: 작업 분할과 우선순위

Fiber 방식 (비동기적, 중단 가능):
렌더링 시작 → 청크 처리 → 중단 → 사용자 입력 처리 → 재개 → 렌더링 완료
                         ↑ 고우선순위 작업이 끼어들 수 있음
// React 18의 Concurrent Features 활용
import { useTransition, startTransition } from 'react';

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (e) => {
    const value = e.target.value;
    setQuery(value); // 고우선순위: 즉시 업데이트

    startTransition(() => {
      // 저우선순위: 무거운 작업, 다른 업데이트에 양보
      setResults(expensiveFilter(value));
    });
  };

  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending ? <span>검색 중...</span> : <ResultList results={results} />}
    </div>
  );
}

Fiber의 주요 특징

// Fiber 노드 구조 (단순화)
const fiberNode = {
  type: 'div',           // 요소 타입
  key: null,             // 식별 키
  stateNode: domNode,    // 실제 DOM 노드 참조
  return: parentFiber,   // 부모 Fiber
  child: childFiber,     // 첫 번째 자식 Fiber
  sibling: siblingFiber, // 형제 Fiber
  pendingProps: {},       // 처리할 새 props
  memoizedProps: {},      // 이전 props
  memoizedState: {},      // 현재 상태
  effectTag: 'UPDATE',   // 수행할 작업 (INSERT, UPDATE, DELETE)
  lanes: 1,              // 작업 우선순위
};

Virtual DOM vs 직접 DOM 조작 성능 비교

// 시나리오: 10,000개 목록 중 500개 업데이트

// 직접 DOM 조작 (최적화 없음)
function updateListDirect(items) {
  const container = document.getElementById('list');
  container.innerHTML = ''; // 전체 삭제 후 재생성
  items.forEach(item => {
    const el = document.createElement('li');
    el.textContent = item.text;
    container.appendChild(el);
  });
  // 10,000번의 createElement + appendChild → 무거운 reflow
}

// React Virtual DOM
function ItemList({ items }) {
  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.text}</li>)}
    </ul>
  );
}
// 변경된 500개 항목의 textContent만 업데이트 → 최소 DOM 조작

Virtual DOM의 한계

// 1. 소규모 앱에서는 오버헤드 발생
// Virtual DOM 비교 자체도 연산 비용이 있음
// 단순한 카운터 같은 경우 직접 DOM 조작이 더 빠를 수 있음

// 2. 메모리 사용량 증가
// Virtual DOM 트리를 메모리에 유지해야 함

// 3. 초기 렌더링 속도
// 서버 사이드 렌더링(SSR) 없이는 초기 로딩이 느릴 수 있음

// Svelte의 접근법: 컴파일 타임에 최적화된 직접 DOM 업데이트 코드 생성
// → Virtual DOM 없이도 유사한 개발자 경험 제공

성능 측정 및 최적화 도구

// React DevTools Profiler로 성능 측정
import { Profiler } from 'react';

function onRenderCallback(id, phase, actualDuration) {
  console.log(`${id} [${phase}]: ${actualDuration.toFixed(2)}ms`);
}

function App() {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <MyComponent />
    </Profiler>
  );
}

// React.memo로 불필요한 리렌더링 방지
const ExpensiveChild = React.memo(({ data }) => {
  return <div>{data}</div>;
});
// props가 변경되지 않으면 리렌더링 스킵

Virtual DOM 핵심 개념 요약

개념설명이점
Virtual DOM실제 DOM의 JS 객체 복사본메모리에서 빠른 비교 가능
Diffing이전/새 Virtual DOM 트리 비교변경 사항만 식별
Reconciliation변경된 부분만 실제 DOM에 반영DOM 조작 최소화
Batching여러 상태 변경을 1회 렌더링으로 처리불필요한 리렌더링 방지
Fiber작업 분할과 우선순위 기반 렌더링UI 응답성 유지, Concurrent Mode
Automatic BatchingReact 18에서 모든 컨텍스트에서 자동 배칭추가 최적화 없이 성능 향상

핵심 정리

  • Virtual DOM은 실제 DOM 조작을 최소화하기 위한 추상화 레이어로, DOM의 느린 조작 비용을 JavaScript 연산으로 대체합니다.
  • Diffing 알고리즘은 O(n³) 문제를 두 가지 휴리스틱 가정으로 O(n)으로 해결합니다.
  • React Fiber(React 16+)는 렌더링 작업을 청크로 분할하고 우선순위를 부여해 UI 응답성을 개선했습니다.
  • Virtual DOM이 항상 직접 DOM보다 빠른 것은 아니며, 복잡한 UI에서 개발 생산성과 성능의 균형을 잡아주는 도구입니다.