프론트엔드/React

(React) Virtual DOM과 Reconciliation: React의 렌더링 최적화 원리

그린티_ 2025. 12. 8. 00:06
반응형

서론

React 코드를 작성하면서 항상 궁금했음 "React가 뭐가 그렇게 빠르지? 매번 다시 렌더링하는데?" 찾아보니 Virtual DOM이라는 게 있고, Reconciliation이라는 과정이 있더라 "실제 DOM을 직접 건드리지 말고, 가상의 DOM에서 비교한 다음에 필요한 것만 업데이트한다"는 거 같았음 그래서 Virtual DOM이 뭐고, Reconciliation이 뭐고, 어떻게 빠르게 하는지 정리해봄

본론

DOM이 뭐냐?

먼저 DOM(Document Object Model)을 이해해야 함

<html>
  <body>
    <div id="root">
      <h1>안녕하세요</h1>
      <p>첫 번째 문단</p>
    </div>
  </body>
</html>

이 HTML을 브라우저가 해석하면 이런 구조의 DOM 트리가 생김:

Document
  └── html
      └── body
          └── div#root
              ├── h1 (텍스트: "안녕하세요")
              └── p (텍스트: "첫 번째 문단")

DOM 조작의 비용:

// JavaScript로 DOM 조작
const h1 = document.querySelector('h1');
h1.textContent = '새로운 제목'; // ← 비용 큼!

DOM 조작할 때마다:

  1. 브라우저가 레이아웃 다시 계산 (Layout)
  2. 화면에 다시 그리기 (Paint)
  3. GPU에 업로드 (Composite)

이 과정들이 매번 일어나면 성능이 떨어짐

Virtual DOM이란?

Virtual DOM은 실제 DOM의 "복사본" JavaScript 객체로 메모리에 존재하는 가벼운 DOM

// 실제 DOM (무겁고 비쌈)
<div id="root">
  <h1>안녕</h1>
</div>

// Virtual DOM (가볍고 빠름 - 메모리의 객체)
{
  type: 'div',
  props: { id: 'root' },
  children: [
    {
      type: 'h1',
      props: {},
      children: ['안녕']
    }
  ]
}

React의 렌더링 흐름:

┌─────────────────────────────────────────┐
│ 1. 상태 변화 (setState 호출)                │
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│ 2. 새로운 Virtual DOM 생성                 │
│    (JavaScript 객체 - 빠름!)               │
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│ 3. Reconciliation                       │
│    (이전 VDOM과 새로운 VDOM 비교)               │
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│ 4. Diff 계산                             │
│    (실제로 바뀐 부분만 찾음)                  │
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│ 5. 실제 DOM만 업데이트                      │
│    (필요한 부분만 - 빠름!)                   │
└─────────────────────────────────────────┘

Reconciliation (재조정) 상세히 보기

Reconciliation은 "이전 VDOM과 새로운 VDOM을 비교해서 무엇이 바뀌었는지 찾는 과정"

예시:

// 첫 번째 렌더링
function App() {
  return (
    <ul>
      <li>항목 1</li>
      <li>항목 2</li>
    </ul>
  );
}

// Virtual DOM (이전)
{
  type: 'ul',
  children: [
    { type: 'li', children: ['항목 1'] },
    { type: 'li', children: ['항목 2'] }
  ]
}
// 상태 변화 후 (새 항목 추가)
function App() {
  return (
    <ul>
      <li>항목 1</li>
      <li>항목 2</li>
      <li>항목 3</li>
    </ul>
  );
}

// Virtual DOM (새로운)
{
  type: 'ul',
  children: [
    { type: 'li', children: ['항목 1'] },
    { type: 'li', children: ['항목 2'] },
    { type: 'li', children: ['항목 3'] } // ← 새로 추가됨
  ]
}

Reconciliation 과정:

  1. 루트부터 비교 시작 (ul은 같음)
  2. 첫 번째 li: "항목 1" → "항목 1" (같음)
  3. 두 번째 li: "항목 2" → "항목 2" (같음)
  4. 세 번째 li: 없음 → "항목 3" (추가됨)
  5. 결론: 세 번째 li만 실제 DOM에 추가

만약 Reconciliation이 없었다면:

// ❌ 모든 요소를 다시 그리는 비효율
ul.innerHTML = `
  <li>항목 1</li>
  <li>항목 2</li>
  <li>항목 3</li>
`;
// 메모리, CPU 낭비!

React의 Diffing 알고리즘

React는 두 가지 가정으로 효율적인 비교 알고리즘을 만듦

가정 1: 같은 타입의 요소는 같은 DOM을 생성

// ✅ 이 두 컴포넌트는 같은 구조
<div className="greeting">안녕</div>
<div className="greeting">안녕하세요</div>

// React: div 타입은 같으니까 내용만 바꾸면 됨

가정 2: 다른 타입의 요소는 다른 트리를 생성

// ❌ 이 두 컴포넌트는 완전히 다름
<div>내용</div>
<span>내용</span>

// React: div와 span은 다르니까 전체 트리를 다시 생성해야 함

Diffing 알고리즘의 핵심: Key

// ❌ Key가 없는 경우 (비효율)
function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>{todo.text}</li> // 인덱스를 key로 쓰는 건 위험!
      ))}
    </ul>
  );
}

// 상황: todos = ['청소', '공부'] → ['공부', '청소']
// 문제: 순서가 바뀌면 React가 모든 li를 다시 렌더링함
// li[0]: "청소" → "공부" (변경)
// li[1]: "공부" → "청소" (변경)
// → 비효율!
// ✅ Key를 제대로 쓰는 경우 (효율)
function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li> // id를 key로 씀
      ))}
    </ul>
  );
}

// 상황: todos = [
//   { id: 1, text: '청소' },
//   { id: 2, text: '공부' }
// ] → [
//   { id: 2, text: '공부' },
//   { id: 1, text: '청소' }
// ]

// React의 생각:
// key=2인 li는 이전에 두 번째였는데 이제 첫 번째 → 순서만 바꿈
// key=1인 li는 이전에 첫 번째였는데 이제 두 번째 → 순서만 바꿈
// → 효율적!

Reconciliation 과정을 단계별로 보면

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

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        증가
      </button>
    </div>
  );
}

1단계: 버튼 클릭

onClick 이벤트 발생
  ↓
setCount(count + 1) 호출
  ↓
count 상태 변경 (0 → 1)

2단계: Re-render (새 VDOM 생성)

React가 Counter() 함수를 다시 호출
새로운 VDOM 생성:

{
  type: 'div',
  children: [
    { type: 'p', children: ['카운트: ', 1] },  // ← count가 1로 변함
    { type: 'button', ... }
  ]
}

3단계: Reconciliation (비교)

이전 VDOM                   새로운 VDOM
{                          {
  type: 'div',               type: 'div',
  children: [                children: [
    {                          {
      type: 'p',               type: 'p',
      children:                children:
      ['카운트: ', 0]          ['카운트: ', 1]  ← 다름!
    },                       },
    { type: 'button' }       { type: 'button' } ← 같음
  ]                        ]
}                          }

Diff 결과:
- div: 같음 (변경 없음)
- p: 텍스트 내용이 다름 (변경 필요)
- button: 같음 (변경 없음)

4단계: 실제 DOM 업데이트

// React가 수행:
const pElement = document.querySelector('p');
pElement.textContent = '카운트: 1'; // ← 이 부분만 업데이트

// button, div는 건드리지 않음!

Virtual DOM의 성능 우위

시나리오: 리스트의 1000개 항목 중 1개 변경

방법 1: DOM 직접 조작 (비효율)

// ❌ 전체 리스트 다시 그리기
const ul = document.querySelector('ul');
ul.innerHTML = newListHTML; // 1000개 모두 재생성!

성능:
- DOM 조작: 1000번
- 레이아웃 계산: 1000번
- Paint: 1000번

방법 2: React Virtual DOM (효율)

// ✅ VDOM 비교로 1개만 업데이트
setState({ items: newItems });

성능:
- VDOM 생성: O(n) - 메모리만 사용 (빠름)
- Diff 비교: O(n) - 메모리에서만 (빠름)
- DOM 조작: 1번만! (1개 li만 업데이트)
- 레이아웃 계산: 1번
- Paint: 1번

시간 비교:

DOM 직접 조작:    ████████ (느림)
React VDOM:       ██ (빠름)
                  ↑
                  매우 큰 차이!

Fiber Architecture (React 16+)

React 16에서부터 렌더링 엔진이 바뀜 더 효율적인 "Fiber" 아키텍처 도입

Fiber의 장점:

// 이전: 렌더링하면 완전히 끝날 때까지 대기 (동기)
// → 오래 걸리는 작업이 UI를 블로킹
render(App); // 1000ms 걸리면 UI가 프리징됨

// 이후: 렌더링을 작은 단위로 분할 (비동기)
// → 16ms마다 약간씩 작업해서 60fps 유지
commit work → pause → check events → commit work → ...

실제 코드에서의 영향:

function HeavyComponent() {
  const [items, setItems] = useState([]);

  // 부하 큰 연산
  const processedItems = useMemo(() => {
    return items.map(item => expensiveCalculation(item));
  }, [items]);

  return <List items={processedItems} />;
}

// React Fiber 덕분에 이 과정이 UI를 블로킹하지 않음

Virtual DOM 비용

Virtual DOM도 비용이 있음!

// ❌ Virtual DOM의 비용
const vdom = {
  type: 'div',
  props: { className: 'container' },
  children: [
    { type: 'p', children: ['Text'] }
  ]
};
// → 메모리 사용, Garbage Collection 비용

// ✅ Virtual DOM의 이점
// → 실제 DOM 조작보다 훨씬 저렴

언제 Virtual DOM이 도움이 되는가?

// ✅ Virtual DOM이 도움되는 경우
// - 자주 변하는 요소들
// - 복잡한 업데이트 로직
// - 많은 양의 데이터 렌더링

function UserList({ users }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

// ❌ Virtual DOM이 오버헤드인 경우
// - 거의 변하지 않는 정적 콘텐츠
// - 단순한 HTML

const StaticPage = () => <div>이 내용은 절대 안 바뀜</div>;

Virtual DOM 최적화 팁

1. Key를 제대로 사용

// ❌ 나쁜 예
{items.map((item, index) => (
  <div key={index}>{item}</div>
))}

// ✅ 좋은 예
{items.map((item) => (
  <div key={item.id}>{item}</div>
))}

2. 컴포넌트 구조 최적화

// ❌ 전체가 한 번에 리렌더링
function BadApp() {
  const [count, setCount] = useState(0);
  const [list, setList] = useState([]);

  return (
    <div>
      <Counter count={count} setCount={setCount} />
      <List list={list} setList={setList} />
    </div>
  );
}
// count 변경 → 전체 BadApp 리렌더링
// → List도 불필요하게 리렌더링!

// ✅ 분리된 컴포넌트
function GoodApp() {
  return (
    <div>
      <CounterSection />
      <ListSection />
    </div>
  );
}
// count 변경 → CounterSection만 리렌더링
// → ListSection은 영향 없음

3. React.memo 활용

// ❌ props가 같아도 항상 리렌더링
function UserCard({ user }) {
  return <div>{user.name}</div>;
}

// ✅ props가 같으면 리렌더링 안 함
const UserCard = React.memo(function UserCard({ user }) {
  return <div>{user.name}</div>;
});

결론

Virtual DOM과 Reconciliation은 React의 핵심 최적화 기법

실제 DOM을 직접 조작하는 대신 JavaScript 객체인 Virtual DOM으로 비교해서 필요한 부분만 실제 DOM을 업데이트함

이 덕분에 React는:

  • 복잡한 상태 변화를 효율적으로 처리
  • 많은 데이터를 빠르게 렌더링
  • 개발자가 성능을 크게 신경 쓰지 않아도 됨

Reconciliation의 Diffing 알고리즘과 Key 사용이 성능을 크게 좌우함 따라서 Key를 제대로 사용하고, 컴포넌트 구조를 잘 설계하는 게 중요!
Virtual DOM의 원리를 이해하면 React 애플리케이션을 더 효율적으로 최적화할 수 있음!

반응형