"DOM 조작은 왜 느릴까?" 라는 의문에서 시작했다
프론트엔드를 처음 배울 때 선배 개발자한테 들었던 말이 있다. "DOM 조작은 비싸니까 줄여야 해." 그때는 무슨 소리인지 몰랐다. 자바스크립트로 document.getElementById().innerHTML = "새 텍스트" 한 줄 쓰는 게 뭐가 비싸다는 건지 이해가 안 됐다. 그냥 문자열 바꾸는 거잖아? 그런데 리액트를 쓰면서 "Virtual DOM이 성능을 좋게 한다"는 말을 자주 들었고, 나는 이게 도대체 뭔지 제대로 이해하고 싶었다.
결국 이거였다. 브라우저가 화면을 다시 그리는 과정 자체가 비싸다는 것. DOM을 조작하면 브라우저는 단순히 메모리의 값만 바꾸는 게 아니라, 전체 렌더링 파이프라인을 다시 돌린다. 이 렌더링 파이프라인을 이해하고 나니, Virtual DOM이 왜 필요한지가 와닿았다.
브라우저 렌더링 파이프라인 - 왜 느린지의 핵심
브라우저는 HTML을 받으면 다음과 같은 단계를 거친다.
- DOM 생성: HTML 파싱해서 DOM 트리를 만든다.
- CSSOM 생성: CSS를 파싱해서 CSSOM 트리를 만든다.
- Render Tree 생성: DOM + CSSOM을 합쳐서 실제로 화면에 보일 요소들만 모은 트리.
- Layout (Reflow): 각 요소의 정확한 위치와 크기를 계산한다. "이 div는 왼쪽에서 100px, 위에서 200px 위치에 너비 300px로 그려야지."
- Paint: 픽셀 단위로 색을 칠한다. 텍스트 색, 배경색, 테두리 등.
- Composite: 여러 레이어를 합성해서 최종 화면을 만든다.
여기서 Layout(Reflow)이 제일 비싸다. 요소 하나의 크기나 위치가 바뀌면, 그 주변 요소들도 다시 계산해야 하기 때문이다. 예를 들어 리스트 상단에 아이템을 추가하면? 그 아래 모든 아이템들의 Y 좌표가 밀려난다. 브라우저는 이걸 전부 재계산해야 한다.
나는 이걸 "지하철 좌석 밀리기"로 이해했다. 중간에 한 명이 끼어들면 뒷사람들이 전부 한 칸씩 밀리는 것처럼, DOM 요소 하나가 바뀌면 주변이 연쇄적으로 영향을 받는다.
Virtual DOM이라는 개념을 받아들이기까지
처음에는 "메모리에 가짜 DOM을 만든다"는 말이 이상했다. "그냥 진짜 DOM을 고치면 되지 않나?" 싶었다. 그런데 리액트의 동작 방식을 보면서 이해했다. Virtual DOM은 "변경 사항을 모아서 한 번에 처리하는 버퍼" 역할을 한다는 것을.
아파트 리모델링 비유 - 내가 이해한 방식
나는 이걸 이렇게 받아들였다.
- Real DOM: 내가 사는 진짜 아파트. 벽지 바꾸려면 도배사 불러야 하고, 벽 허물려면 인테리어 업체 불러야 하고, 매번 돈과 시간이 든다.
- Virtual DOM: 아파트의 3D 설계도 파일. 컴퓨터 프로그램(SketchUp 같은 거)에서 벽을 옮겨보고, 벽지 색을 바꿔보고, 가구 배치를 바꿔본다. 이건 공사비가 안 든다. 그냥 마우스 클릭이니까.
리액트가 하는 일은 이거다:
- 설계도(Virtual DOM)에서 여러 변경 사항을 시뮬레이션한다.
- 이전 설계도와 새 설계도를 비교한다. (Diffing)
- "아, 실제로 바뀐 건 거실 벽지 색깔이랑 침실 가구 위치구나."
- 그 부분만 실제 아파트(Real DOM)에 공사를 한다.
결국 이거였다. 불필요한 공사를 줄이는 게 핵심이라는 것. 만약 상태가 10번 바뀌었는데 최종 결과가 처음 상태랑 같다면? Real DOM은 한 번도 안 건드린다. 설계도(Virtual DOM) 위에서만 시뮬레이션하고 끝.
리액트의 Reconciliation 과정 - 깊이 들어가보니
리액트 공식 문서를 읽으면서 Reconciliation(재조정)이라는 개념을 제대로 이해했다. 이건 단순히 "비교한다"는 게 아니라, 효율적으로 비교하는 알고리즘이 핵심이었다.
1. Render Phase: 새로운 Virtual DOM 생성
상태(state)나 props가 바뀌면 리액트는 컴포넌트를 다시 실행한다. 이때 return 문에 있는 JSX가 Virtual DOM 객체로 변환된다.
// 예시: 카운터 컴포넌트
function Counter() {
const [count, setCount] = React.useState(0);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>Increase</button>
</div>
);
}
// count가 0일 때 Virtual DOM (간단히 표현하면)
{
type: 'div',
props: {},
children: [
{ type: 'h1', props: {}, children: ['Count: 0'] },
{ type: 'button', props: { onClick: [Function] }, children: ['Increase'] }
]
}
// count가 1이 되면? 새로운 Virtual DOM
{
type: 'div',
props: {},
children: [
{ type: 'h1', props: {}, children: ['Count: 1'] }, // 이 부분만 바뀜!
{ type: 'button', props: { onClick: [Function] }, children: ['Increase'] }
]
}
이 과정은 순수 자바스크립트 객체 연산이라서 엄청 빠르다. 브라우저가 개입할 필요가 없다.
2. Diffing: 틀린 그림 찾기 알고리즘
리액트는 이전 Virtual DOM과 새 Virtual DOM을 비교한다. 근데 트리 구조를 완벽하게 비교하려면 O(n³) 시간 복잡도가 필요하다. (엄청 느림) 그래서 리액트는 두 가지 가정을 했다.
가정 1: 다른 타입의 요소는 완전히 다른 트리를 만든다.
// 이전
<div><span>Hello</span></div>
// 새로운 것
<div><p>Hello</p></div>
// span이 p로 바뀌었으니, span을 지우고 p를 새로 만든다.
가정 2: key prop으로 리스트 아이템을 식별할 수 있다.
// key가 없으면
<ul>
<li>A</li>
<li>B</li>
</ul>
// → 새로운 아이템 C를 맨 앞에 추가하면?
<ul>
<li>C</li> // 리액트: "첫 번째가 A에서 C로 바뀌었네? A를 지우고 C를 만들어야지."
<li>A</li> // "두 번째가 B에서 A로 바뀌었네?"
<li>B</li> // "세 번째는 새로 생긴 B네."
</ul>
// → 결과: 비효율적으로 전체를 다시 만듦
// key가 있으면
<ul>
<li key="a">A</li>
<li key="b">B</li>
</ul>
// → C를 추가하면?
<ul>
<li key="c">C</li> // 리액트: "key='c'는 새로운 애네. 이거만 추가하자."
<li key="a">A</li> // "key='a'는 그대로네. 건드리지 말자."
<li key="b">B</li> // "key='b'도 그대로."
</ul>
나는 이걸 "학번으로 학생 찾기"로 이해했다. 이름(내용)은 바뀔 수 있지만 학번(key)은 고유하니까, key로 "이 학생은 그대로고, 저 학생은 새로 들어왔네"를 판단하는 것.
3. Commit Phase: Real DOM에 적용
Diffing으로 찾은 변경 사항만 Real DOM에 적용한다. 위 예시에서는 <h1> 태그의 텍스트만 "Count: 0"에서 "Count: 1"로 바꾼다. <div>나 <button>은 건드리지 않는다.
이 과정에서 리액트는 Fiber Architecture라는 걸 쓴다. (리액트 16부터) Fiber는 작업을 작은 단위로 쪼개서, 브라우저가 급한 일(사용자 입력 처리 등)을 먼저 하고 나서 렌더링을 이어서 할 수 있게 한다. 이건 나중에 따로 정리해봐야겠다고 생각했다.
최적화 기법들 - 불필요한 렌더링 줄이기
리액트가 알아서 최적화해준다지만, 개발자가 힌트를 줄 수도 있다. 나는 이 세 가지를 받아들였다.
React.memo: 컴포넌트 메모이제이션
props가 안 바뀌었으면 컴포넌트를 다시 렌더링하지 않는다.
const ExpensiveComponent = React.memo(({ data }) => {
console.log("렌더링됨!");
return <div>{data}</div>;
});
function Parent() {
const [count, setCount] = React.useState(0);
const data = "고정된 데이터";
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<ExpensiveComponent data={data} />
{/* 부모가 리렌더링돼도 data가 안 바뀌었으면 ExpensiveComponent는 리렌더링 안 됨 */}
</div>
);
}
useMemo: 계산 결과 캐싱
비싼 계산을 매번 하지 않고 결과를 캐싱한다.
function FilteredList({ items, filterText }) {
const filteredItems = React.useMemo(() => {
console.log("필터링 중...");
return items.filter(item => item.includes(filterText));
}, [items, filterText]); // items나 filterText가 바뀔 때만 재계산
return <ul>{filteredItems.map(item => <li key={item}>{item}</li>)}</ul>;
}
useCallback: 함수 재생성 방지
함수를 props로 넘길 때 매번 새 함수를 만들지 않는다.
function Parent() {
const [count, setCount] = React.useState(0);
// useCallback 없으면 매번 새 함수가 만들어져서 Child가 리렌더링됨
const handleClick = React.useCallback(() => {
console.log("클릭!");
}, []); // 의존성 배열이 비어있으면 함수가 절대 안 바뀜
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<Child onClick={handleClick} />
</div>
);
}
const Child = React.memo(({ onClick }) => {
console.log("Child 렌더링");
return <button onClick={onClick}>Click me</button>;
});
다른 프레임워크들의 접근 - Virtual DOM만이 답은 아니었다
리액트 공부하다 보니 다른 프레임워크들은 어떻게 하는지 궁금해졌다.
Vue: Virtual DOM을 쓰지만 더 똑똑하게
Vue도 Virtual DOM을 쓴다. 근데 리액트와 다르게 반응성 시스템(Reactivity System)이 있다. Vue는 어떤 데이터가 어떤 컴포넌트에 영향을 주는지 추적한다. 그래서 데이터 A가 바뀌면 "컴포넌트 X만 리렌더링하면 되겠네"라고 정확히 안다. 리액트는 상태가 바뀌면 일단 하위 컴포넌트를 다 렌더링하고 Diffing으로 걸러내는 방식이라, Vue가 이론상 더 효율적일 수 있다.
Angular (Ivy): Incremental DOM
Angular의 Ivy 렌더러는 Incremental DOM이라는 걸 쓴다. Virtual DOM과 달리, 이전/새로운 두 개의 트리를 메모리에 안 들고 있는다. 대신 Real DOM을 직접 순회하면서 필요한 부분만 업데이트한다. 메모리를 덜 쓴다는 장점이 있다. 나는 이걸 "현장 직공사" 방식이라고 이해했다. 설계도를 두 번 그리는 대신, 현장에 가서 바로 "여기 바꾸고, 저기 바꾸고" 하는 것.
Svelte: 컴파일 타임에 최적화
Svelte는 아예 Virtual DOM을 안 쓴다. 빌드할 때 "변수 A가 바뀌면 DOM 노드 B를 업데이트해"라는 코드를 미리 생성한다. 런타임 오버헤드가 없어서 엄청 빠르다. 나는 이걸 "공장에서 미리 조립해서 배송"하는 방식으로 받아들였다. 현장(브라우저)에서 조립(Diffing)하지 않고, 공장(빌드 타임)에서 이미 완제품을 만들어 보내는 것.
Virtual DOM의 한계 - 언제 오버헤드가 문제가 될까?
Virtual DOM이 만능은 아니다. 내가 와닿았던 케이스는 엄청 큰 리스트다.
예를 들어 테이블에 10,000개 행이 있고, 스크롤할 때마다 업데이트된다면? Virtual DOM Diffing 자체가 부담이 된다. 이럴 때는 가상 스크롤(Virtualization) 기법을 쓴다. 화면에 보이는 100개 행만 DOM에 렌더링하고, 스크롤하면 위아래를 교체하는 방식. (react-window, react-virtualized 같은 라이브러리)
또 하나는 애니메이션이다. 60fps 애니메이션을 Virtual DOM으로 처리하기엔 Diffing 비용이 부담스러울 수 있다. 이럴 땐 CSS 애니메이션이나 Web Animations API를 직접 쓰는 게 낫다.
React 18의 동시성 기능 - Virtual DOM의 진화
최근 리액트 18에서 Concurrent Features가 나왔다. 이건 Virtual DOM과 직접 관련은 없지만, 렌더링 최적화의 연장선이라고 이해했다.
- useTransition: 급하지 않은 업데이트를 뒤로 미룬다. 사용자 입력 같은 급한 일을 먼저 처리.
- useDeferredValue: 값의 업데이트를 지연시켜서 부드러운 UI를 유지.
- Suspense for Data Fetching: 데이터 로딩 중에 폴백 UI를 보여주고, 준비되면 자연스럽게 렌더링.
이게 가능한 이유는 Fiber Architecture가 작업을 쪼개서 우선순위를 매길 수 있기 때문이다. 결국 이거였다. Virtual DOM은 단순히 "빠르게 하려고"만 있는 게 아니라, "사용자 경험을 좋게 하려고" 진화하고 있다는 것.
정리해본다 - Virtual DOM은 트레이드오프의 산물
Virtual DOM을 공부하면서 받아들인 핵심은 이거다:
- DOM 조작이 비싼 이유: 브라우저 렌더링 파이프라인(Layout, Paint, Composite) 때문.
- Virtual DOM의 본질: 변경 사항을 메모리에서 먼저 시뮬레이션하고, 최종 변화만 Real DOM에 적용하는 버퍼.
- Reconciliation의 핵심: Diffing 알고리즘으로 O(n) 시간에 변경 사항을 찾아냄. key prop이 중요한 이유.
- 성능 최적화: React.memo, useMemo, useCallback으로 불필요한 렌더링을 줄임.
- 다른 접근들: Vue의 반응성 시스템, Angular의 Incremental DOM, Svelte의 컴파일 최적화.
- 한계와 해결: 큰 리스트는 가상 스크롤, 애니메이션은 CSS로.
- 미래 방향: React 18의 동시성 기능으로 UX 개선.
결국 Virtual DOM은 "절대적으로 빠른 것"이 아니라, "유지보수 가능한 코드를 짜면서도 충분히 빠른 성능을 내기 위한 트레이드오프"였다. 나는 이렇게 이해했고, 이제 리액트 코드를 짤 때 "왜 key를 써야 하는지", "왜 useMemo를 남발하면 안 되는지"가 명확해졌다.