[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
실제 측정 데이터
| 작업 | 순수 DOM | React (비최적화) | 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 아키텍처가 어떻게 사용자 경험을 개선하는지 이해하게 된다.
- "왜 이렇게 느리지?"라는 의문이 생길 때 어디를 최적화해야 하는지 판단할 수 있다.