본문으로 바로가기
<joshuayeyo />
MENU
← ALL NOTES
FE9 MIN READ

[FE] VDOM은 언제나 실제 DOM보다 나을까?

Lorem ipsum dolor sit amet

SERIES / FE ↗

1. DOM은 왜 느릴까?

많은 사람들이 "DOM은 무겁다"라고만 알고 있지만, 진짜 이유는 따로 있다.

브라우저의 실제 DOM 조작이 느린 이유는 단순히 "무거워서"가 아니다.

1) Reflow(Layout) 비용

element.style.width = "100px"; // ← 이 한 줄이
// 브라우저 전체 레이아웃 재계산을 유발

DOM 노드의 구조적 속성(width, height, position 등)이 바뀌면 브라우저는 영향받는 모든 요소의 위치와 크기를 다시 계산한다.

2) Repaint 비용

element.style.backgroundColor = "red"; // ← 화면 다시 그리기

색상, 테두리 같은 시각적 속성이 바뀌면 화면에 그려진 픽셀을 다시 칠하는 과정이 발생한다.

3) 동기적 처리 특성

for (let i = 0; i < 1000; i++) {
  div.appendChild(document.createElement("span")); // 1000번 reflow!
}

JavaScript는 싱글 스레드이고, DOM 조작 중에는 다른 작업을 할 수 없다.

즉, DOM 자체가 느린 게 아니라, "연쇄적으로 발생하는 렌더링 파이프라인"이 비용이 큰 것.

[ DOM 변경 ]
    ↓
[ Reflow: 레이아웃 재계산 ]
    ↓
[ Repaint: 화면 다시 그리기 ]
    ↓
[ Composite: 레이어 합성 ]

2. VDOM이 등장한 이유

VDOM은 "DOM이 느려서 만든 해킹 기법"이 아니다.
진짜 목적은 UI 업데이트의 복잡도를 줄이기 위한 추상화다.

React 팀이 해결하고 싶었던 문제:

// 문제 상황: jQuery 스타일 명령형 코드
function updateUI(data) {
  $("#user-name").text(data.name);
  $("#user-age").text(data.age);
  if (data.isPremium) {
    $("#badge").show();
  } else {
    $("#badge").hide();
  }
  // ... 무한히 복잡해지는 조건문들
}

사실 나는 jQuery를 해본 적이 없어서,, Claude의 도움을 조금 받아 작성하였다.

이런 코드는:

  • 어디를 업데이트해야 하는지 일일이 지정해야 함
  • 상태가 복잡해질수록 버그 발생률 급증
  • UI와 상태의 동기화를 개발자가 직접 관리

라는 문제점이 있다.

React의 해결책: 선언적 UI

// React 방식: 상태만 바꾸면 UI는 자동 갱신
function UserProfile({ data }) {
  return (
    <div>
      <span>{data.name}</span>
      <span>{data.age}</span>
      {data.isPremium && <Badge />}
    </div>
  );
}

React가 원한 것:

  • 상태 변화가 어디서 일어나든 UI를 함수처럼 다시 그리기
  • setState()를 호출하면 변경된 UI만 반영되도록 안전하게
  • 개발자는 DOM 세부사항을 걱정하지 않게

즉, VDOM의 진짜 목표는:

성능 최적화가 아니라 "UI ↔ 상태" 사이의 예측 가능한 업데이트 모델 제공

물론 diffing 과정에서 어느 정도 성능 이점도 생긴다.


3. VDOM이 빠른 경우

VDOM이 실제 DOM보다 빠른 순간은 분명 존재한다.

3-1. 변경 범위가 넓고 구조가 복잡한 대규모 UI

// 실제 DOM 직접 조작 (비효율적)
const container = document.getElementById("list");
items.forEach((item) => {
  const div = document.createElement("div");
  div.textContent = item.name;
  container.appendChild(div); // 매번 reflow 발생!
});
// 1000개 아이템 → 1000번 reflow

// VDOM (React) - 배치 업데이트
function ItemList({ items }) {
  return (
    <div>
      {items.map((item) => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>
  );
}
// React는 diff 후 한 번에 DOM 조작 → 1번 reflow

왜 빠른가?

실제 DOM 직접 조작:

appendChild() × 1000 = reflow × 1000 = 💀

VDOM 방식:

[ 이전 VDOM ]     [ 새 VDOM ]
      ↓              ↓
      └── diff 알고리즘 ──┘
              ↓
    [ 변경사항 계산 ]
              ↓
    [ 한 번에 DOM 패치 ]
              ↓
         reflow × 1

실제 측정 데이터

작업순수 DOMReact (비최적화)React (최적화)
1000개 요소 생성~5ms~15ms~8ms
100개 요소 부분 업데이트~50ms~12ms~6ms
복잡한 중첩 구조 업데이트~200ms~30ms~15ms

출처: React 팀 벤치마크

유리한 상황:

  • 대시보드: 여러 위젯이 동시에 갱신
  • 소셜 피드: 무한 스크롤 + 실시간 업데이트
  • 복잡한 폼: 수십 개 필드의 연쇄 검증
  • 데이터 테이블: 정렬/필터링/페이지네이션

4. VDOM이 오히려 느려지는 경우

핵심 포인트:
VDOM은 DOM보다 무거운 JavaScript 연산을 먼저 수행한다.

VDOM의 2단계 처리 과정:

[ 상태 변경 ]
   ↓
[ 1단계: VDOM diff 계산 ] ← JavaScript 연산 비용
   ↓
[ 2단계: 실제 DOM 패치 ] ← 브라우저 렌더링 비용

만약 변경이 단순하다면, 실제 DOM을 직접 조작하는 게 더 빠르다.

예시 1: 단일 input value 변경

// 직접 조작: ~0.1ms
document.querySelector("#input").value = newValue;

// React: ~2ms
function Input({ value }) {
  return <input value={value} />;
}
// 전체 컴포넌트 트리 비교 → diff → patch

이 경우 VDOM의 1단계(diff 계산)가 순수한 오버헤드가 된다.

예시 2: 고빈도 애니메이션

// React로 애니메이션 (느림)
function AnimatedBox() {
  const [x, setX] = useState(0);

  useEffect(() => {
    const animate = () => {
      setX((prev) => prev + 1); // 매 프레임마다 리렌더!
      requestAnimationFrame(animate);
    };
    animate();
  }, []);

  return <div style={{ transform: `translateX(${x}px)` }} />;
}
// 초당 60번 VDOM diff

// 직접 DOM 조작 (빠름)
useEffect(() => {
  const el = ref.current;
  const animate = () => {
    el.style.transform = `translateX(${x}px)`;
    requestAnimationFrame(animate);
  };
  animate();
}, []);

예시 3: 잘못된 key 사용

// index를 key로 사용
{
  items.map((item, index) => <Item key={index} data={item} />);
}
// 아이템 추가/삭제 시 전체 리스트 리렌더

// 안정적인 고유 ID 사용
{
  items.map((item) => <Item key={item.id} data={item} />);
}

VDOM이 느려지는 근본 원인:

변경이 작은데 매번 전체 트리를 diff 하면 손해다.


5. "그럼 컴포넌트화 잘하면 언제나 빠른걸까?"

React의 기본 동작:

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

  return (
    <div>
      <Child1 /> {/* count 안 쓰는데 리렌더됨 */}
      <Child2 /> {/* count 안 쓰는데 리렌더됨 */}
      <Child3 count={count} />
    </div>
  );
}

부모 컴포넌트가 리렌더되면 자식 컴포넌트도 무조건 리렌더된다.

왜 문제인가?

컴포넌트 트리가 깊어질수록
작은 상태 변화 하나가 수십 개 컴포넌트를 리렌더시킴
Root (count 변경)
 ├─ Header         ← 리렌더 (불필요)
 ├─ Sidebar        ← 리렌더 (불필요)
 │   ├─ Menu       ← 리렌더 (불필요)
 │   └─ UserInfo   ← 리렌더 (불필요)
 └─ Content
     └─ Counter    ← 리렌더 (필요함)

해결책: 메모이제이션

1) React.memo - props가 안 바뀌면 리렌더 스킵

const Child = React.memo(function Child({ name }) {
  console.log("Child 렌더됨");
  return <div>{name}</div>;
});

// 부모가 리렌더되어도 name prop이 같으면 스킵

2) useMemo - 계산 결과를 캐싱

function ExpensiveComponent({ items }) {
  const sortedItems = useMemo(() => {
    console.log("정렬 계산 중...");
    return items.sort((a, b) => a.value - b.value);
  }, [items]); // items 안 바뀌면 재계산 ❌

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

3) useCallback - 함수 참조를 안정화

function Parent() {
  // 매 렌더마다 새 함수 생성
  const handleClick = () => console.log("clicked");

  // 함수 참조 유지
  const handleClick = useCallback(() => {
    console.log("clicked");
  }, []);

  return <Child onClick={handleClick} />;
}

핵심 정리:

컴포넌트 구조를 잘 쪼개는 것은 필수 조건이지만 충분 조건은 아니다.
VDOM의 diff 비용 자체를 줄이는 전략이 필요하다.


6. VDOM diff는 정확히 어떻게 작동하나?

지금까지 "VDOM이 diff를 한다"고만 했는데,

실제로는 어떤 과정을 거칠까?

이 섹션에서는 React의 핵심 알고리즘인 **Reconciliation(재조정)**과 Fiber 아키텍처를 파헤쳐본다.


6-1. Reconciliation 알고리즘의 핵심 원리

React의 diffing 알고리즘은 다음 3가지 휴리스틱을 기반으로 작동한다.

원칙 1: 서로 다른 타입의 엘리먼트는 다른 트리를 생성한다

// 이전 렌더
<div>
  <Counter />
</div>

// 다음 렌더
<span>
  <Counter />
</span>

React의 판단:

  • div → span으로 타입이 바뀜
  • 기존 트리를 완전히 버리고 새로 만듦
  • Counter 컴포넌트도 unmount → remount
  • 내부 state도 모두 초기화

즉, 루트 엘리먼트 타입이 바뀌면 전체를 다시 그린다.


원칙 2: key를 통해 자식 엘리먼트를 식별한다

// 이전 렌더
<ul>
  <li key="a">Apple</li>
  <li key="b">Banana</li>
</ul>

// 다음 렌더 (맨 앞에 추가)
<ul>
  <li key="c">Cherry</li>
  <li key="a">Apple</li>
  <li key="b">Banana</li>
</ul>

key가 없으면:

1. Cherry와 Apple 비교 → 다름 → 업데이트
2. Apple과 Banana 비교 → 다름 → 업데이트
3. Banana가 사라짐 → 삭제
4. 마지막에 Banana 추가

결과: 3번의 업데이트 + 1번의 삭제 + 1번의 추가 = 🤯

key가 있으면:

1. key="c" → 새로운 요소 → 맨 앞에 삽입
2. key="a", key="b" → 그대로 유지

결과: 1번의 삽입만 = ☺️

핵심:

key는 단순히 경고를 없애기 위한 게 아니라
React가 엘리먼트를 식별하고 재사용하는 핵심 메커니즘이다.


원칙 3: 같은 타입의 컴포넌트는 state를 유지한다

// 이전 렌더
<Button color="blue" />

// 다음 렌더
<Button color="red" />

React의 판단:

  • 타입은 같음 (Button)
  • props만 바뀜 (color)
  • 컴포넌트 인스턴스 유지
  • 내부 state는 보존
  • props만 업데이트하고 리렌더

6-2. 실제 diff 과정 시뮬레이션

구체적인 예시로 보자:

// 이전 VDOM
<div className="container">
  <h1>Title</h1>
  <ul>
    <li key="1">Item 1</li>
    <li key="2">Item 2</li>
  </ul>
</div>

// 다음 VDOM
<div className="container">
  <h1>New Title</h1>
  <ul>
    <li key="2">Item 2</li>
    <li key="3">Item 3</li>
  </ul>
</div>

React의 diff 과정:

1. <div> 비교
   - 타입 같음 (div)
   - className 같음
   → 유지, 자식 비교로 진행

2. <h1> 비교
   - 타입 같음 (h1)
   - 텍스트 다름 ("Title" → "New Title")
   → DOM 업데이트: textContent = "New Title"

3. <ul> 비교
   - 타입 같음 (ul)
   → 유지, 자식 비교로 진행

4. <li> 리스트 비교
   - key="1" → 삭제됨 → DOM에서 제거
   - key="2" → 그대로 → 유지
   - key="3" → 새로 추가 → DOM에 삽입

최종 DOM 조작:

// 실제로 실행되는 DOM 작업
h1Element.textContent = "New Title"; // 1번
ulElement.removeChild(li1); // 2번
ulElement.appendChild(li3); // 3번

// 총 3번의 DOM 조작으로 끝

6-3. Fiber 아키텍처: React 16 이후의 진화

React 16 이전에는 Stack Reconciler를 사용했다:

물론 나는 React 18 시절부터 사용한 뉴비이기에, 이 시절은 모른다,,,

// 문제 상황: 동기적 처리
function render() {
  // 이 작업이 끝날 때까지 다른 일을 할 수 없음
  diffTree(oldTree, newTree); // 100ms 소요
  patchDOM();
}
// → 화면 버벅임

문제점:

  • diff 계산이 중단 불가능
  • 큰 컴포넌트 트리는 렌더링이 오래 걸림
  • 그동안 사용자 입력을 처리할 수 없음

Fiber의 해결책: 작업을 쪼개서 처리

// Fiber: 작업을 작은 단위로 쪼갬
function fiberRender() {
  let currentFiber = firstFiber;

  while (currentFiber) {
    // 각 컴포넌트를 작은 단위로 처리
    processComponent(currentFiber);

    // 브라우저에게 제어권을 돌려줌
    if (shouldYield()) {
      // 급한 일(클릭, 입력)이 있으면 양보
      return continueNextTime(currentFiber);
    }

    currentFiber = currentFiber.next;
  }
}

Fiber의 핵심 개념:

[ Fiber Tree 구조 ]

Component
  ├─ child ──→ ChildComponent
  ├─ sibling → SiblingComponent
  └─ return ──→ ParentComponent

각 노드(Fiber)는:
- 처리할 작업의 최소 단위
- 언제든 중단/재개 가능
- 우선순위 부여 가능

Fiber가 가능하게 한 것들:

1) 우선순위 기반 렌더링

// 긴급한 작업
onClick={() => setCount(count + 1)}  // 즉시 처리

// 덜 긴급한 작업
startTransition(() => {
  setSearchResults(bigData);  // 나중에 처리
});

2) 렌더링 중단 및 재개

// 사용자가 입력하는 중
[렌더링 시작]
  → [1/10 완료]
  → [사용자 입력 감지!]
  → [렌더링 중단]
  → [입력 처리]
  → [렌더링 재개]
  → [2/10부터 계속...]

3) Time Slicing

브라우저의 한 프레임(16ms) 동안:
├─ 5ms: React 렌더링
├─ 5ms: 사용자 입력 처리
├─ 3ms: 애니메이션
└─ 3ms: 여유 시간

→ 화면이 부드럽게 유지됨

6-4. 실전에서 중요한 포인트

1) key는 안정적이고 예측 가능해야 한다

// 안티패턴
{
  items.map((item, index) => (
    <Item key={index} /> // index는 순서가 바뀌면 문제
  ));
}

{
  items.map((item) => (
    <Item key={Math.random()} /> // 매번 새로운 key → 항상 remount
  ));
}

// 올바른 패턴
{
  items.map((item) => (
    <Item key={item.id} /> // 안정적인 고유 ID
  ));
}

2) 조건부 렌더링은 타입을 유지하라

// 타입이 바뀌면 state 초기화
{
  isLoggedIn ? <UserDashboard /> : <GuestDashboard />;
}

// 같은 타입 유지
<Dashboard isGuest={!isLoggedIn} />;

3) 리스트는 항상 key를 사용하라

// React가 최적화할 수 있게 도와줌
<ul>
  {items.map((item) => (
    <li key={item.id}>{item.name}</li>
  ))}
</ul>

6-5. Fiber 이후의 성능 개선 수치

시나리오Stack (React 15)Fiber (React 16+)
1000개 컴포넌트 동시 업데이트50ms (blocking)50ms (non-blocking)
긴급 업데이트 응답 시간50ms 대기<16ms 즉시
프레임 드랍많음거의 없음

핵심 차이:

  • Fiber 이전: 총 작업 시간은 같지만 중단 불가
  • Fiber 이후: 총 작업 시간은 같지만 작업 쪼개기 가능

6-6. 정리: diff 알고리즘의 본질

VDOM diff의 목표:
"어떻게 하면 **최소한의 DOM 조작**으로 화면을 갱신할까?"

전략:
1. 타입이 다르면 → 전체 교체
2. 타입이 같으면 → 속성만 업데이트
3. 리스트는 key로 식별 → 재사용 최대화

Fiber의 추가 목표:
"어떻게 하면 렌더링 중에도 화면이 버벅이지 않을까?"

전략:
1. 작업을 쪼개서 처리
2. 우선순위 부여
3. 필요하면 중단/재개

결론:

VDOM diff는 단순한 "객체 비교"가 아니라
휴리스틱 기반의 최적화된 알고리즘이다.

Fiber는 이를 한 단계 더 발전시켜
사용자 경험을 해치지 않으면서 복잡한 UI를 처리한다.


7. React 팀의 입장: "VDOM은 절대 빠르지 않다"

React 공식 팀도 명확하게 말했다.

"Virtual DOM is not what makes React fast.
What makes React fast is avoiding touching the DOM too often."

-- React 팀 공식 블로그

즉:

  • VDOM 자체가 빠른 게 아니라
  • DOM 업데이트를 최소화하는 전략 덕에 빠르게 보이는 것

React 18 이후의 진화

VDOM의 "무거운 diff" 문제를 해결하기 위한 새로운 기능들:

1) Concurrent Rendering

// 긴급/비긴급 업데이트 구분
startTransition(() => {
  setSearchResults(results); // 낮은 우선순위
});

2) useTransition

const [isPending, startTransition] = useTransition();

// 느린 렌더링을 중단 가능하게
startTransition(() => {
  setTab("posts"); // 무거운 컴포넌트 렌더링
});

3) Suspense

// 비동기 렌더링을 선언적으로
<Suspense fallback={<Spinner />}>
  <LazyComponent />
</Suspense>

→ VDOM의 한계를 우선순위 기반으로 해결


8. 결론: VDOM은 언제나 DOM보다 빠르지 않다

VDOM의 진짜 목적은 "성능"이 아니라 "추상화"

상태 기반 UI를 쉽게 만드는 근본적 해결책.

빠른 경우

  • 변경 규모가 크고 복잡한 UI
    • 대시보드, 소셜 피드, 복잡한 폼
  • 선언적 렌더링 패턴이 극도로 유리한 구조
    • 여러 컴포넌트가 동시에 갱신
  • diff 알고리즘이 전체 변경 비용을 감소시킬 때
    • 배치 업데이트로 reflow 최소화

느린 경우

  • 변경이 단순한데 매번 전체 트리를 diff 할 때
    • 단일 input value 변경
  • 렌더 빈도가 높은 애니메이션
    • 초당 60프레임 리렌더
  • 리스트 key가 잘못되었을 때
    • index를 key로 사용
  • 메모이제이션 전략이 없을 때
    • 불필요한 전체 트리 리렌더

9. 실무 사례

VDOM(React)을 쓰는 게 유리한 경우:

// 1. 상태 변화가 복잡하고 예측하기 어려운 앱
function Dashboard() {
  const [user, setUser] = useState();
  const [notifications, setNotifications] = useState();
  const [widgets, setWidgets] = useState();
  // 여러 상태가 복잡하게 얽혀 있을 때
}

// 2. 여러 컴포넌트가 동시에 갱신되는 구조
<Layout>
  <Header user={user} />
  <Sidebar notifications={notifications} />
  <Content widgets={widgets} />
</Layout>;

// 3. 팀 협업이 중요한 중대형 프로젝트
// - 선언적 코드는 유지보수가 쉬움
// - 컴포넌트 재사용성 높음

직접 DOM 조작이 나을 수 있는 경우:

// 1. 애니메이션 (requestAnimationFrame 활용)
useEffect(() => {
  const el = ref.current;
  let x = 0;

  const animate = () => {
    x += 1;
    el.style.transform = `translateX(${x}px)`;
    requestAnimationFrame(animate);
  };

  animate();
}, []);

// 2. Canvas/WebGL 같은 저수준 렌더링
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
ctx.fillRect(0, 0, 100, 100);

// 3. 매우 단순한 정적 페이지
// - 상태 변화가 거의 없음
// - React 오버헤드가 불필요

핵심:

"VDOM을 쓸 것인가?"가 아니라
"React의 선언적 모델이 우리 앱에 맞는가?"를 물어야 한다.


마무리

React를 쓰는 이유는 VDOM이 빠르기 때문이 아니라 복잡한 UI를 확실하게 "제대로" 갱신하기 위함이다.

이걸 이해하면:

  • useMemo/useCallback/React.memo도 단순 성능 트릭이 아니라 VDOM이 가진 비용을 통제하기 위한 도구라는 게 명확해진다.
  • key의 역할이 단순한 경고 제거가 아니라 React의 reconciliation 알고리즘을 돕는 핵심 장치임을 알게 된다.
  • Fiber 아키텍처가 어떻게 사용자 경험을 개선하는지 이해하게 된다.
  • "왜 이렇게 느리지?"라는 의문이 생길 때 어디를 최적화해야 하는지 판단할 수 있다.
FE / 4 OF 5
  1. [FE] 새로고침 해도 이미지를 유지하는 방법
  2. [FE] Context, Store 그래서 둘이 뭔 차이임?
  3. [FE] React Native에서 Amplitude 쿠키 에러 해결하기
  4. [FE] VDOM은 언제나 실제 DOM보다 나을까?
  5. [FE] 혼돈 속에서도 지속되는 프로덕트를 위해 — 도메인 기반 구조 리빌드 기록