
React Virtual DOM: 왜 코드를 짜면 화면이 그려질까?
리액트가 혁신이었던 이유. 진짜 DOM 조작이 느린 이유(Reflow/Repaint)와 Virtual DOM의 '더블 버퍼링' 전략, 그리고 React Fiber가 가져온 혁명.

리액트가 혁신이었던 이유. 진짜 DOM 조작이 느린 이유(Reflow/Repaint)와 Virtual DOM의 '더블 버퍼링' 전략, 그리고 React Fiber가 가져온 혁명.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

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

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

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

2015년, 저는 jQuery로 게시판을 만들고 있었습니다. 데이터 100개가 담긴 리스트에서 아이템 하나를 삭제하려면 이렇게 했습니다.
// DOM을 직접 찾아서 지움
$('#item-42').remove();
직관적이고 빨랐습니다. 그런데 데이터가 1,000개가 되고, 필터링 기능이 추가되고, 좋아요 버튼이 생기면서 지옥이 시작되었습니다. 데이터(Array)는 바뀌었는데 화면(DOM)이 업데이트 안 되거나, 반대로 화면은 지워졌는데 데이터는 남아있는 불일치(Sync) 버그가 속출했습니다.
그때 React가 등장했습니다. "DOM을 직접 건드리지 마세요. 그냥 데이터(State)만 바꾸세요. 화면은 알아서 그립니다."
처음엔 "매번 전체를 새로 그리면 느리지 않나?"라고 의심했습니다. 하지만 리액트는 빨랐습니다. 그 비결이 바로 Virtual DOM입니다.
2017년, 저는 처음으로 리액트를 제품에 도입했습니다. 고객 관리 대시보드였는데, 고객 리스트에 500명 정도가 표시되고, 각 행마다 "수정" 버튼이 있었습니다. 개발할 땐 테스트 데이터 10개로만 테스트했기 때문에 아무 문제가 없었습니다. 그런데 실제 배포 후 문제가 터졌습니다.
검색창에 한 글자 타이핑할 때마다 화면이 1초씩 멈췄습니다. 사용자들이 "이거 뭐 고장난 거 아니냐"고 컴플레인을 쏟아냈습니다. 저는 당황했습니다. 분명 리액트는 "빠르다"고 했는데, 왜 이러는 걸까요?
Chrome DevTools의 Performance 탭을 열어봤습니다. 그리고 충격적인 광경을 목격했습니다. 검색어가 하나 바뀔 때마다 500개의 리스트 아이템 전체가 리렌더링되고 있었습니다. Virtual DOM이 아무리 빨라도, 500개를 매번 새로 그리면 느릴 수밖에 없었습니다.
원인을 찾아보니 제가 key prop에 index를 사용하고 있었습니다.
// 🔴 잘못된 코드
{customers.map((customer, index) => (
<CustomerRow key={index} data={customer} />
))}
index를 key로 쓰면, 데이터가 필터링되어도 리액트는 "0번 아이템, 1번 아이템, 2번 아이템..."으로만 인식합니다. 그래서 실제로는 고유 ID(customer.id)로 key를 주고, React.memo로 감싸야 했습니다.
// ✅ 올바른 코드
const CustomerRow = React.memo(({ data }) => {
return <div>{data.name}</div>;
});
{customers.map(customer => (
<CustomerRow key={customer.id} data={customer} />
))}
이렇게 고치자 타이핑 지연이 사라졌습니다. 이때 저는 처음으로 Virtual DOM이 만능이 아니라는 걸 이해했습니다. Virtual DOM은 도구일 뿐이고, 제대로 쓰는 건 개발자의 몫이었습니다.
리액트를 이해하려면 먼저 브라우저가 어떻게 화면을 그리는지 알아야 합니다. 저도 처음엔 "HTML 파일 읽고 화면에 띄우는 거 아닌가?" 정도로 생각했습니다. 그런데 실제로는 훨씬 복잡했습니다.
display: none인 요소는 제외).여기서 핵심은 Reflow입니다. 이 과정은 엄청나게 비쌉니다. 왜냐하면 CSS의 position, width, height, margin 등을 기반으로 모든 요소의 좌표와 크기를 다시 계산해야 하기 때문입니다.
// 🔴 나쁜 예: DOM을 100번 건드림
for(let i=0; i<100; i++) {
document.body.innerHTML += `<div>${i}</div>`;
}
위 코드는 이론적으로 Reflow를 100번 유발합니다. (실제로는 브라우저가 최적화를 하긴 하지만, 그래도 느립니다). 마치 책상 정리(Layout)를 하는데, 책 한 권 꽂을 때마다 책장 전체를 다시 정렬하는 것과 같습니다.
이 비유가 저한테는 와닿았습니다. "아, DOM 조작 자체가 느린 게 아니라, Reflow를 너무 자주 일으키는 게 문제구나." 그래서 옛날 개발자들은 DocumentFragment를 써서 메모리에서 작업을 다 끝낸 후 한 번에 DOM에 붙이는 테크닉을 썼습니다.
// ✅ 좋은 예: 한 번에 붙이기
const fragment = document.createDocumentFragment();
for(let i=0; i<100; i++) {
const div = document.createElement('div');
div.textContent = i;
fragment.appendChild(div);
}
document.body.appendChild(fragment); // Reflow는 딱 한 번
그런데 이런 식으로 코드를 짜는 건 너무 번거롭습니다. 리액트의 Virtual DOM은 바로 이 "메모리에서 작업하고 한 번에 붙이기"를 자동화한 것입니다.
Virtual DOM은 메모리에 존재하는 가짜 DOM입니다. 그냥 자바스크립트 객체(Object)일 뿐입니다. 저는 처음에 "가상 DOM"이라는 말을 듣고 뭔가 대단한 기술일 줄 알았는데, 실제로는 이런 구조였습니다.
// Virtual DOM의 실체
const vdom = {
type: 'div',
props: {
className: 'container',
children: [
{ type: 'h1', props: { children: 'Hello' } },
{ type: 'p', props: { children: 'World' } }
]
}
};
"이게 전부야?" 싶었지만, 이 단순한 구조가 엄청난 성능 향상을 가져왔습니다.
setState(newState) 호출.이 과정을 게임 개발의 "더블 버퍼링(Double Buffering)"에 비유하는 사람들이 많습니다. 게임에서 화면을 그릴 때, 보이는 화면(Front Buffer)에 직접 그리면 깜빡임이 생깁니다. 그래서 보이지 않는 버퍼(Back Buffer)에 먼저 그린 후, 완성되면 스왑합니다. Virtual DOM도 똑같습니다.
비유: "내용을 수정할 때마다 프린터로 뽑아보는 게 아니라(Real DOM), 워드 프로세서(Virtual DOM)에서 수정을 다 끝내고 인쇄 버튼을 딱 한 번(Batch Update) 누르는 것."
이 비유를 받아들이고 나니까, Virtual DOM이 왜 존재하는지 완전히 정리해본다면 결국 이거였습니다. "비싼 작업(Reflow)을 최소화하자."
두 개의 트리(Tree)를 완벽하게 비교하는 알고리즘은 원래 O(n³)입니다. 노드가 1,000개면 10억 번 연산해야 합니다. 너무 느립니다. 리액트는 과감한 휴리스틱(Heuristic)으로 이를 O(n)으로 줄였습니다.
<div>가 <span>으로 바뀌면? 자식들 비교 안 함. 그냥 싹 다 버리고 새로 만듦.key를 줘라.
처음엔 "이게 완벽한 비교가 아니잖아? 버그 나는 거 아냐?"라고 생각했습니다. 그런데 실제로 <div>가 갑자기 <span>으로 바뀌는 경우는 거의 없습니다. 리액트는 "완벽함"보다 "실용성"을 선택했고, 그게 먹혔습니다.
<!-- Before -->
<ul>
<li>Apple</li>
<li>Banana</li>
</ul>
<!-- After: "Orange"가 맨 앞에 추가됨 -->
<ul>
<li>Orange</li>
<li>Apple</li>
<li>Banana</li>
</ul>
리액트는 key가 없으면 순서대로 비교합니다.
3번의 DOM 변경이 일어납니다.
<ul>
<li key="orange">Orange</li>
<li key="apple">Apple</li>
<li key="banana">Banana</li>
</ul>
리액트: "어? key="apple"이랑 key="banana"는 그대로네? 위치만 이동했구나!"
1번의 생성(Orange 추가)과 이동만 일어납니다. 이 차이가 리스트가 길어질수록 엄청난 성능 차이를 만듭니다.
제가 앞서 언급한 "검색창 타이핑 지연" 문제를 어떻게 찾았을까요? 바로 React DevTools의 Profiler 덕분입니다. 이 도구를 모르는 리액트 개발자들이 의외로 많습니다. 저도 1년 동안 모르고 지냈습니다.
제 경우엔 CustomerRow 컴포넌트가 500번 렌더링되고 있었고, 이유는 "Props changed"였습니다. 그런데 실제로는 props가 안 바뀌었는데도 "changed"로 나왔습니다. 왜일까요?
부모 컴포넌트에서 매번 새로운 객체를 생성하고 있었기 때문입니다.
// 🔴 문제: 매번 새 객체 생성
<CustomerRow data={{ ...customer, timestamp: Date.now() }} />
리액트는 객체의 참조(Reference)를 비교합니다. 내용이 같아도 주소가 다르면 "다른 객체"로 인식합니다. 이걸 고치고 나서야 성능이 정상화되었습니다.
React.memo는 컴포넌트를 메모이제이션(Memoization)합니다. 즉, props가 안 바뀌면 이전 렌더링 결과를 재사용합니다. 저는 처음에 "그럼 모든 컴포넌트에 React.memo를 감싸면 되겠네?"라고 생각했습니다. 큰 실수였습니다.
<div>Hello</div> 같은 건 그냥 새로 그리는 게 더 빠름.이 원칙을 받아들이고 나서, 저는 "일단 다 React.memo로 감싸기"를 멈췄습니다. 대신 Profiler로 병목을 찾고, 진짜 문제가 되는 컴포넌트에만 적용했습니다.
React 15까지는 Stack Reconciler를 사용했습니다. 한 번 렌더링을 시작하면 멈출 수 없었습니다. 거대한 트리를 비교하느라 16ms(60프레임 기준)를 넘기면 화면이 버벅(Jank)거렸습니다.
저는 이걸 "OS의 스케줄러(Scheduler)"에 비유했습니다. OS도 여러 프로세스를 동시에 실행하는 것처럼 보이지만, 실제로는 시분할(Time Slicing)로 조금씩 번갈아 가며 실행합니다. Fiber도 똑같습니다.
이 덕분에 리액트는 더 부드러운 UX를 제공하게 되었습니다. 특히 대규모 리스트나 복잡한 애니메이션에서 체감이 큽니다.
Virtual DOM이 빨라도, 렌더링 자체를 너무 자주 하면 느립니다. 이때 필요한 게 useMemo와 useCallback입니다.
// 부모 컴포넌트
function Parent() {
const [count, setCount] = useState(0);
// 🔴 문제: Parent가 렌더링 될 때마다 이 함수는 "새로운 함수" 취급됨
const handleClick = () => { console.log("Click"); };
return (
<>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
{/* Child는 props가 바뀌었다고 착각해서 불필요한 리렌더링 발생 */}
<Child onClick={handleClick} />
</>
);
}
// 자식 컴포넌트
const Child = React.memo(({ onClick }) => {
console.log("Child Rendered!"); // ㅠㅠ 계속 찍힘
return <button onClick={onClick}>Child</button>;
});
handleClick은 매번 새로운 함수로 생성됩니다. 자바스크립트에서 함수는 참조 타입이므로, () => {} 는 매번 다른 주소를 가집니다. 그래서 React.memo로 감싼 Child도 "props가 바뀌었다"고 판단하고 리렌더링됩니다.
// ✅ 해결: 의존성 배열([])이 안 바뀌면 함수를 재사용
const handleClick = useCallback(() => {
console.log("Click");
}, []);
이제 Parent가 리렌더링 되어도, handleClick은 같은 참조를 유지하므로 Child는 리렌더링 되지 않습니다.
function ExpensiveComponent({ data }) {
// 🔴 문제: data가 안 바뀌어도 매번 재계산
const result = computeExpensiveValue(data);
return <div>{result}</div>;
}
// ✅ 해결: data가 바뀔 때만 재계산
function ExpensiveComponent({ data }) {
const result = useMemo(() => computeExpensiveValue(data), [data]);
return <div>{result}</div>;
}
useMemo는 값을 캐싱하고, useCallback은 함수를 캐싱합니다. 이 차이를 이해하는 데 시간이 좀 걸렸습니다. 결국 이거였습니다. "쓸데없는 재계산/재생성을 막자."
Virtual DOM을 한 줄로 요약하면, "비싼 DOM 작업을 최소화하기 위해 메모리에서 미리 계산하고 한 번에 반영하는 전략"입니다. 저는 이걸 이해하고 나서 리액트를 훨씬 효율적으로 쓸 수 있게 되었습니다.
하지만 요즘은 "Virtual DOM이 과연 최선인가?"라는 질문도 나오고 있습니다. Svelte 같은 프레임워크는 아예 Virtual DOM을 쓰지 않고, 컴파일 타임에 최적화된 코드를 생성합니다. 또 리액트도 React Compiler(구 React Forget)를 개발 중인데, 이게 나오면 useMemo/useCallback 없이도 자동으로 최적화가 될 수 있다고 합니다.
기술은 계속 진화합니다. 하지만 "왜 이 기술이 생겨났는가?"를 이해하면, 다음 기술도 쉽게 받아들일 수 있습니다. Virtual DOM은 제게 그런 기준점이 되었습니다.