
Virtual DOM: 리액트가 빠른 진짜 이유
진짜 집을 부수고 다시 짓는 건 비쌉니다. 설계도(가상돔)에서 미리 그려보고 바뀐 부분만 공사하는 '똑똑한 리모델링'의 기술.

진짜 집을 부수고 다시 짓는 건 비쌉니다. 설계도(가상돔)에서 미리 그려보고 바뀐 부분만 공사하는 '똑똑한 리모델링'의 기술.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

프론트엔드를 처음 배울 때 선배 개발자한테 들었던 말이 있다. "DOM 조작은 비싸니까 줄여야 해." 그때는 무슨 소리인지 몰랐다. 자바스크립트로 document.getElementById().innerHTML = "새 텍스트" 한 줄 쓰는 게 뭐가 비싸다는 건지 이해가 안 됐다. 그냥 문자열 바꾸는 거잖아? 그런데 리액트를 쓰면서 "Virtual DOM이 성능을 좋게 한다"는 말을 자주 들었고, 나는 이게 도대체 뭔지 제대로 이해하고 싶었다.
결국 이거였다. 브라우저가 화면을 다시 그리는 과정 자체가 비싸다는 것. DOM을 조작하면 브라우저는 단순히 메모리의 값만 바꾸는 게 아니라, 전체 렌더링 파이프라인을 다시 돌린다. 이 렌더링 파이프라인을 이해하고 나니, Virtual DOM이 왜 필요한지가 와닿았다.
브라우저는 HTML을 받으면 다음과 같은 단계를 거친다.
여기서 Layout(Reflow)이 제일 비싸다. 요소 하나의 크기나 위치가 바뀌면, 그 주변 요소들도 다시 계산해야 하기 때문이다. 예를 들어 리스트 상단에 아이템을 추가하면? 그 아래 모든 아이템들의 Y 좌표가 밀려난다. 브라우저는 이걸 전부 재계산해야 한다.
나는 이걸 "지하철 좌석 밀리기"로 이해했다. 중간에 한 명이 끼어들면 뒷사람들이 전부 한 칸씩 밀리는 것처럼, DOM 요소 하나가 바뀌면 주변이 연쇄적으로 영향을 받는다.
처음에는 "메모리에 가짜 DOM을 만든다"는 말이 이상했다. "그냥 진짜 DOM을 고치면 되지 않나?" 싶었다. 그런데 리액트의 동작 방식을 보면서 이해했다. Virtual DOM은 "변경 사항을 모아서 한 번에 처리하는 버퍼" 역할을 한다는 것을.
나는 이걸 이렇게 받아들였다.
리액트가 하는 일은 이거다:
결국 이거였다. 불필요한 공사를 줄이는 게 핵심이라는 것. 만약 상태가 10번 바뀌었는데 최종 결과가 처음 상태랑 같다면? Real DOM은 한 번도 안 건드린다. 설계도(Virtual DOM) 위에서만 시뮬레이션하고 끝.
리액트 공식 문서를 읽으면서 Reconciliation(재조정)이라는 개념을 제대로 이해했다. 이건 단순히 "비교한다"는 게 아니라, 효율적으로 비교하는 알고리즘이 핵심이었다.
상태(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'] }
]
}
이 과정은 순수 자바스크립트 객체 연산이라서 엄청 빠르다. 브라우저가 개입할 필요가 없다.
리액트는 이전 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로 "이 학생은 그대로고, 저 학생은 새로 들어왔네"를 판단하는 것.
Diffing으로 찾은 변경 사항만 Real DOM에 적용한다. 위 예시에서는 <h1> 태그의 텍스트만 "Count: 0"에서 "Count: 1"로 바꾼다. <div>나 <button>은 건드리지 않는다.
이 과정에서 리액트는 Fiber Architecture라는 걸 쓴다. (리액트 16부터) Fiber는 작업을 작은 단위로 쪼개서, 브라우저가 급한 일(사용자 입력 처리 등)을 먼저 하고 나서 렌더링을 이어서 할 수 있게 한다. 이건 나중에 따로 정리해봐야겠다고 생각했다.
리액트가 알아서 최적화해준다지만, 개발자가 힌트를 줄 수도 있다. 나는 이 세 가지를 받아들였다.
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>
);
}
비싼 계산을 매번 하지 않고 결과를 캐싱한다.
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>;
}
함수를 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>;
});
리액트 공부하다 보니 다른 프레임워크들은 어떻게 하는지 궁금해졌다.
Vue도 Virtual DOM을 쓴다. 근데 리액트와 다르게 반응성 시스템(Reactivity System)이 있다. Vue는 어떤 데이터가 어떤 컴포넌트에 영향을 주는지 추적한다. 그래서 데이터 A가 바뀌면 "컴포넌트 X만 리렌더링하면 되겠네"라고 정확히 안다. 리액트는 상태가 바뀌면 일단 하위 컴포넌트를 다 렌더링하고 Diffing으로 걸러내는 방식이라, Vue가 이론상 더 효율적일 수 있다.
Angular의 Ivy 렌더러는 Incremental DOM이라는 걸 쓴다. Virtual DOM과 달리, 이전/새로운 두 개의 트리를 메모리에 안 들고 있는다. 대신 Real DOM을 직접 순회하면서 필요한 부분만 업데이트한다. 메모리를 덜 쓴다는 장점이 있다. 나는 이걸 "현장 직공사" 방식이라고 이해했다. 설계도를 두 번 그리는 대신, 현장에 가서 바로 "여기 바꾸고, 저기 바꾸고" 하는 것.
Svelte는 아예 Virtual DOM을 안 쓴다. 빌드할 때 "변수 A가 바뀌면 DOM 노드 B를 업데이트해"라는 코드를 미리 생성한다. 런타임 오버헤드가 없어서 엄청 빠르다. 나는 이걸 "공장에서 미리 조립해서 배송"하는 방식으로 받아들였다. 현장(브라우저)에서 조립(Diffing)하지 않고, 공장(빌드 타임)에서 이미 완제품을 만들어 보내는 것.
Virtual DOM이 만능은 아니다. 내가 와닿았던 케이스는 엄청 큰 리스트다.
예를 들어 테이블에 10,000개 행이 있고, 스크롤할 때마다 업데이트된다면? Virtual DOM Diffing 자체가 부담이 된다. 이럴 때는 가상 스크롤(Virtualization) 기법을 쓴다. 화면에 보이는 100개 행만 DOM에 렌더링하고, 스크롤하면 위아래를 교체하는 방식. (react-window, react-virtualized 같은 라이브러리)
또 하나는 애니메이션이다. 60fps 애니메이션을 Virtual DOM으로 처리하기엔 Diffing 비용이 부담스러울 수 있다. 이럴 땐 CSS 애니메이션이나 Web Animations API를 직접 쓰는 게 낫다.
최근 리액트 18에서 Concurrent Features가 나왔다. 이건 Virtual DOM과 직접 관련은 없지만, 렌더링 최적화의 연장선이라고 이해했다.
이게 가능한 이유는 Fiber Architecture가 작업을 쪼개서 우선순위를 매길 수 있기 때문이다. 결국 이거였다. Virtual DOM은 단순히 "빠르게 하려고"만 있는 게 아니라, "사용자 경험을 좋게 하려고" 진화하고 있다는 것.
Virtual DOM을 공부하면서 받아들인 핵심은 이거다:
결국 Virtual DOM은 "절대적으로 빠른 것"이 아니라, "유지보수 가능한 코드를 짜면서도 충분히 빠른 성능을 내기 위한 트레이드오프"였다. 나는 이렇게 이해했고, 이제 리액트 코드를 짤 때 "왜 key를 써야 하는지", "왜 useMemo를 남발하면 안 되는지"가 명확해졌다.