"React를 구현하라" — Virtual DOM과 Diff 알고리즘을
직접 구현한 3인 팀 프로젝트 회고
React를 사용할 때마다 궁금했다.
"Virtual DOM은 실제로 무엇을, 어떻게 비교할까?"
React를 사용할 때마다 한 가지가 늘 궁금했다.
"Virtual DOM이 빠르다고 하는데, 실제로는 어떤 방식으로 비교가 일어날까?"
크래프톤 정글의 수요코딩회에서 React의 핵심 개념인 Virtual DOM과 Diff 알고리즘을 직접 구현하는 과제가 주어졌다. 외부 라이브러리 없이 HTML·CSS·Vanilla JavaScript만으로, React가 내부에서 수행하는 일을 우리 손으로 다시 만들어보는 프로젝트였다.
이 글은 그 과정에서 내가 맡아 설계하고 구현한 부분, 팀원들과 병합하며 정리한 역할 분담, 그리고 직접 구현하면서 비로소 선명해진 브라우저 렌더링 구조를 기록한 회고다.
시작은 크래프톤 정글 수요코딩회
단 하루, AI를 도구 삼아 팀별 프로젝트에 몰입한다. 결과물뿐만 아니라 과정도 중요하다.
크래프톤 정글의 수요코딩회는 매주 수요일 진행되는 하루 몰입형 팀 프로젝트다. 이번 과제의 키워드는 딱 하나였다.
"React의 핵심 개념인
Virtual DOM과 Diff 알고리즘을 구현하라."
단순히 코드만 동작하면 되는 게 아니라, AI가 작성한 코드를 완벽히 소화해 그 원리를 설명할 수 있어야 한다. 낯선 기능도 Top-down으로 빠르게 풀어내며 '학습 민첩성'을 극대화하는 것이 목표였다.
📋 과제 요구사항
DOM → Virtual DOM 변환
브라우저의 실제 DOM 정보를 읽어서 Virtual DOM 트리로 변환하는 함수 작성
Diff 알고리즘 구현
두 Virtual DOM을 비교하여 변경된 부분을 찾아내는 알고리즘 작성
Patch 적용
변경된 부분만 "실제 영역"에 렌더링 — Patch, 뒤로가기, 앞으로가기 버튼 포함
State History
VDOM history에 저장하여 Undo/Redo 지원, 실제 영역과 테스트 영역 동기화
팀 프로젝트: pre-Fiber React 엔진 + Demo Lab + Benchmark
팀은 3명이었고, 각자 독립 브랜치에서 구현한 뒤 최종 결과물을 병합하는 전략을 택했다. 그 과정에서 내 브랜치(choihyunjin, choihyunjin2)에서는 리액트 엔진 코어와 벤치마크 시스템을 중심으로 설계와 구현을 맡았다.
Patch · Undo · Redo
Render · Traverse
성능 비교 측정
📁 프로젝트 구조
팀원별 역할 분담 & 나의 기여
3명이 각자 독립 브랜치에서 구현한 뒤 최종 병합하는 전략을 택했다.
나는 프로젝트의 초기 구조와 엔진 코어, 벤치마크 시스템을 맡았고, 팀원들은 Diff Lab 구현과 문서화, 마무리 정리를 담당했다.
| 팀원 | 브랜치 | 핵심 담당 |
|---|---|---|
| 최현진 (나) | choihyunjin, choihyunjin2 |
🔵 프로젝트 기반 구축 · 리액트 엔진 코어 · 벤치마크 시스템 · UI/시각화 설계 및 구현 |
| 최영빈 | choiyeongbeen |
🟢 Diff Lab 구현 · 기술 매뉴얼/스펙 문서 작성 · README 정비 |
| 정찬빈 | jungchanbin |
🟡 코드 수정 · README 업데이트 |
🔵 내 기여 — 커밋 타임라인
프로젝트의 첫 커밋부터 최종 벤치마크 안정화까지, 내가 맡은 설계·구현 흐름을 시간순으로 정리했다.
Add Virtual DOM service demo — 03/24 10:32
프로젝트 최초 커밋. 전체 프로젝트 구조 수립, VDOM 서비스 데모 기반을 만듦. 이 커밋 위에 모든 팀원의 작업이 올라감.
Add admin validation drawer — 03/24 11:02
관리자 검증 기능 추가. VDOM 상태를 검증하고 디버깅할 수 있는 관리자 패널 구현.
feat: UI redesign + Virtual DOM Tree enhanced visualization — 03/25 00:56
UI 전면 리디자인. Virtual DOM 트리를 시각적으로 탐색할 수 있는 강화된 시각화 시스템 구축.
Refine benchmark safety and controls — 03/25 03:55
벤치마크 시스템 안전장치 및 컨트롤 개선. VDOM vs RDOM 성능 비교 측정 체계 완성.
feat: Add 20s auto-stop & expandable Tree UI cutoff — 03/25 05:44
벤치마크 20초 자동 정지 기능 추가. 대형 트리에서도 안전하게 동작하도록 확장/축소 가능한 트리 UI 구현.
🎯 내가 직접 설계하고 구현한 핵심 기능
① 프로젝트 기반 구축
프로젝트 최초 커밋으로 전체 디렉토리 구조, 모듈 분리, VDOM 서비스 데모의 뼈대를 만들었다. 이 기반 위에 모든 팀원의 작업이 올라갔다.
② 리액트 엔진 코어 (4계층 파이프라인)
VDOM 생성 → 정규화 → 비교(Diff) → 적용(Patch)까지 이어지는 엔진 파이프라인 전체를 설계. core/vdom.js와 packages/team3-react/src/index.js의 핵심 로직.
③ 벤치마크 시스템
VDOM 생성·DOM walk·diff+patch 시간을 각각 측정하는 벤치마크 시스템 전체를 설계·구현. 안전장치(20초 자동 정지)까지 포함.
④ UI / VDOM 트리 시각화
Virtual DOM 트리 구조를 시각적으로 탐색할 수 있는 인터랙티브 시각화 시스템. 확장/축소 가능한 트리 UI + 전체 페이지 UI 리디자인.
⑤ 관리자 검증 기능 (Validation Drawer)
VDOM 상태를 검증하고 디버깅할 수 있는 관리자 패널. 내부 상태 확인과 엣지 케이스 검증에 활용.
📊 팀원별 담당 영역 한눈에 보기
아래 영역은 기여도의 크기 비교가 아니라 각 팀원이 맡은 역할을 동일한 비중으로 정리한 섹션이다. 팀 프로젝트인 만큼 막대 길이는 동일하게 두고, 담당한 범주만 다르게 표기했다.
이 프로젝트에서 나는 초기 구조를 세우고, 그 위에 리액트 엔진 코어와 벤치마크 시스템을 설계·구현했다. 단순히 기능을 추가하는 데서 끝나지 않고, 팀원들이 각자 작업한 결과물을 자연스럽게 합칠 수 있도록 프로젝트 구조와 인터페이스를 먼저 정리한 점이 특히 의미 있었다.
내가 만든 것: React 엔진 — 4계층 파이프라인
— 단순히 "Diff만 구현했다"가 아니라, DOM → VDOM 생성 → 정규화 → 비교 → 패치 적용까지
하나의 엔진 파이프라인으로 설계했다.
1. VDOM 생성 계층
- domNodeToVNode
- domToVNode
- sourceToVNode
- createElementVNode
- createTextVNode
- createRootContainer
- normalizeVNodePaths
2. 렌더 / 탐색 계층
- createDOMFromVNode
- renderVNodeToRoot
- findVNodeByPath
- traverseDFS
- traverseBFS
3. 비교 계층 (Diff)
- diffAttrs
- diffChildren
- createChildKeyMap
- diff
- diffRoot
4. 적용 계층 (Patch)
- getDomNodeByPath
- applyCreate / Remove / Replace / Text
- applyAttrSet / Remove
- applyReorderPatch
- applyPatches
- patchRoot
🌳 Virtual DOM 노드 구조
HTML DOM을 JS 객체로 표현한 가벼운 트리 구조. 각 노드는 아래 필드를 가진다.
// VDOM 노드 구조
{
type: "element" | "text",
tag: "div" | "article" | "button" | ...,
attrs: { id: "app", class: "main" },
children: [ /* child VNodes */ ],
text: "Hello World",
key: "unique-key",
path: "0.1.2", // patch 적용 위치 추적용
depth: 2 // 트리 깊이
}🔧 Patch 타입 — Diff 결과물
Diff 알고리즘이 두 VDOM을 비교한 결과로 생성하는 변경 명세서.
왜 Virtual DOM이 필요한가 — 브라우저 렌더링의 비용
프로젝트를 하면서 가장 깊게 파고든 질문이었다.
"리액트는 싸고 브라우저 렌더링 엔진은 비싸다" — 이 말의 진짜 의미는 무엇인가.
🌐 브라우저 렌더링 파이프라인
| 구분 | React 엔진 (Virtual DOM) | 브라우저 렌더링 엔진 |
|---|---|---|
| 역할 | 변경점 계산 (어디를 바꿀지 결정) | 실제 화면 생성 및 그리기 |
| 실행 위치 | JavaScript 엔진 (V8) | 브라우저 렌더링 파이프라인 |
| CPU 사용 | ✔️ 매우 많이 사용 | ✔️ 많이 사용 |
| GPU 사용 | ❌ 사용 안 함 | ✔️ Composite 등에서 사용 |
| 데이터 | 메모리 내부 JS 객체 (Virtual DOM) | 실제 DOM + CSSOM + Render Tree |
| 연산 성격 | 순수 계산 (논리/비교) | 계산 + 그래픽 처리 |
| 비용 | 비교적 가벼움 | 매우 비쌈 (Layout + Paint + GPU) |
| 최적화 목표 | 불필요한 렌더링 줄이기 | Reflow / Repaint 최소화 |
⚡ 전체 흐름 — React가 끼어드는 위치
Virtual DOM diff → 변경점 계산 → Style 계산 → Layout → Paint → Composite
리액트의 Virtual DOM 비교는 자바스크립트 메모리 상에서 일어나는 CPU 연산이라 비교적 가볍다. 반면 실제 DOM 변경은 브라우저의 스타일 계산, 레이아웃 재계산, 페인트 같은 후속 렌더링 비용을 유발할 수 있어서 더 비싸다. 그래서 리액트는 먼저 싼 계산으로 변경점을 찾고, 비싼 실제 렌더링 작업은 최소화하려는 전략이다.
🔥 Reflow / Repaint / Composite — 비용 차이
| 단계 | 의미 | 비용 | CPU/GPU | 트리거 예시 |
|---|---|---|---|---|
| Layout (Reflow) | 요소의 크기/위치 재계산 | ❌ 가장 비쌈 | CPU | width, height, margin |
| Paint (Repaint) | 픽셀 다시 그리기 | ⚠️ 중간 | CPU/GPU | color, background |
| Composite | 레이어 합성 | ✔️ 비교적 저렴 | GPU | transform, opacity |
Layout은 "각 요소를 어디에, 얼마나 크게 놓을까"를 계산하는 설계도 단계다. 하나가 바뀌면 부모·형제·자식까지 연쇄적으로 재계산이 퍼질 수 있어서 가장 비싸다.
Paint는 그 설계도를 바탕으로 실제 색, 글자, 그림자를 칠하는 단계다.
Composite는 이미 그려진 레이어들을 합치는 단계로, GPU가 병렬 처리하므로 상대적으로 가볍다.
Diff 알고리즘 — 두 트리의 차이를 찾는 핵심
이전 Virtual DOM과 새로운 Virtual DOM을 비교해서, 무엇이 바뀌었는지 찾아내는 과정.
🔍 Diff → Patch → DOM 반영 흐름
🎯 Diff의 5가지 핵심 케이스
| 케이스 | 조건 | 결과 Patch |
|---|---|---|
| ① 노드 추가 | 이전엔 없고, 새로 생김 | CREATE |
| ② 노드 삭제 | 이전엔 있었고, 사라짐 | REMOVE |
| ③ 태그 교체 | 태그 이름 자체가 다름 (p→div) | REPLACE |
| ④ 텍스트/속성 변경 | 같은 태그, 내용만 다름 | TEXT / ATTR_SET |
| ⑤ 자식 재정렬 | key 기반으로 순서/추가/삭제 감지 | REORDER_CHILDREN |
🔑 key가 왜 중요한가
리스트에서 단순 인덱스 비교만 하면 "위치만 바뀐 것"과 "내용이 바뀐 것"을 구분할 수 없다. key를 통해 노드를 식별하면, 추가·삭제·이동을 정확하게 판단할 수 있다.
// key가 없으면 — 전부 "변경"으로 판단
<li>A</li> → <li>B</li> // "A→B 변경"
<li>B</li> → <li>A</li> // "B→A 변경"
// key가 있으면 — "이동"으로 정확히 판단
<li key="1">A</li> → <li key="2">B</li>
<li key="2">B</li> → <li key="1">A</li>
// → REORDER_CHILDREN (이동만 하면 됨)구현에서는 data-key, key, id 속성 순으로 key를 추출하고, createChildKeyMap으로 Map(hashtable) 기반 O(1) 조회를 지원했다.
일반적인 트리 비교는 O(n³)이지만, React 방식은 "같은 레벨끼리만 비교 + key로 리스트 비교"라는 규칙을 적용해 O(n)으로 최적화한다. 직접 구현해보니 이 규칙이 왜 필요한지, 왜 이 정도 타협이 합리적인지를 코드로 이해하게 됐다.
가장 어려웠던 기술적 챌린지 3가지
구현하면서 "이건 그냥 넘길 수 없다"고 느꼈던 순간들
🌐 실제 DOM → VDOM 변환 — "살아있는 구조"를 "죽은 객체"로 바꾸기
브라우저의 실제 DOM은 단순한 데이터가 아니라 이벤트 리스너, 스타일 계산, 자식 노드 관계 등이 얽힌 "살아있는 자료구조"다. 이걸 순수한 JS 객체 트리로 변환하는 것이 첫 번째 관문이었다.
// 실제 DOM 노드를 VDOM 객체로 변환
function domNodeToVNode(domNode) {
if (domNode.nodeType === Node.TEXT_NODE) {
return createTextVNode(domNode.textContent);
}
const attrs = {};
for (const attr of domNode.attributes) {
attrs[attr.name] = attr.value;
}
const children = Array.from(domNode.childNodes)
.map(child => domNodeToVNode(child));
return createElementVNode(domNode.tagName, attrs, children);
}변환 후에는 normalizeVNodePaths로 각 노드에 path와 depth를 부여해야 했다. 이 정규화 과정이 없으면 나중에 Patch를 적용할 때 "어떤 DOM 노드에 적용할지" 찾을 수 없기 때문이다.
DOM은 화면과 연결된 "살아있는 자료구조"라서 값 하나 바꾸는 것도 비싸다. Virtual DOM은 화면과 연결이 끊긴 "순수 JS 객체"라서 비교·복사·수정이 자유롭다. 이 차이를 직접 구현하면서 체감했다.
⚙️ Diff 알고리즘 — diffChildren이 가장 까다로웠다
단일 노드 비교(diff)는 비교적 단순하다. 태그 다르면 교체, 텍스트 다르면 수정. 하지만 자식 노드 리스트 비교(diffChildren)는 완전히 다른 문제였다.
단순 인덱스 비교로는 "추가·삭제·이동"을 구분할 수 없다. key 기반으로 createChildKeyMap을 만들어 Map(hashtable) 조회를 쓰고, 기존 노드를 재사용할 수 있는 경우와 새로 만들어야 하는 경우를 분리하는 로직이 필요했다.
React가 key를 요구하는 이유를 이때 비로소 이해했다. key 없이는 diff 알고리즘이 "이동"을 감지하지 못하고 전부 "삭제 후 재생성"으로 처리해야 하는데, 이건 성능적으로 Virtual DOM의 이점을 완전히 날려버리는 것이었다.
📊 벤치마크 시스템 — "진짜 빠른지" 증명하기
"Virtual DOM이 빠르다"는 이론을 검증하기 위해 벤치마크 시스템을 직접 만들었다. VDOM 생성 시간, DOM walk 시간, diff + patch를 통한 DOM 업데이트 시간을 각각 측정했다.
벤치마크 결과, 변경이 적은 경우 VDOM 방식이 압도적으로 유리했다. I/O 없이 메모리 안에서 비교하는 것과, 실제 DOM을 통째로 재생성하는 것의 차이는 직접 수치로 보니 확연했다.
"Virtual DOM이 빠르다"는 무조건이 아니다. 변경이 거의 없을 때, 복잡한 트리에서 일부만 바뀔 때 유리하다. 정말 작은 화면에서 변경이 전체적으로 일어나면 오히려 diff 오버헤드가 추가될 수도 있다. 이걸 벤치마크로 직접 확인한 것이 큰 수확이었다.
Virtual DOM만이 답은 아니다 — 경쟁 업데이트 방식 비교
프로젝트를 통해 "React 방식이 유일한 정답인가?"라는 질문까지 확장하게 됐다.
| 방식 | 대표 | 변경 감지 | Diff 필요? | 특징 |
|---|---|---|---|---|
| Virtual DOM | React | 전체 트리 비교 | ✔️ 있음 | 안정적, 범용 |
| Fine-grained Reactivity | SolidJS | 값 단위 직접 추적 | ❌ 없음 | 매우 빠름 |
| Compile-time | Svelte | 컴파일 시 분석 | ❌ 없음 | 번들 작음, 매우 빠름 |
| Signals | Angular (최신) | 상태 기반 자동 추적 | ❌ 없음 | 직관적, 구조화 |
| Incremental DOM | 직접 DOM 비교+수정 | ❌ 없음 | 메모리 적음 |
🎯 핵심 차이 — "비교해서 찾느냐" vs "처음부터 추적하느냐"
⚛️ React 방식
state 변경 → 컴포넌트 재실행 → Virtual DOM 전체 생성 → 이전과 비교 → 바뀐 부분만 DOM 업데이트
"다시 그리고 → 걸러냄"
🎯 Solid/Svelte 방식
count 변경 → count를 사용하는 DOM만 바로 업데이트. 컴포넌트 재실행 없음. diff 없음.
"처음부터 필요한 것만 실행"
결과는 비슷하지만 과정(알고리즘 구조)이 완전히 다르다. React는 "트리 비교 알고리즘 문제"이고, Solid/Svelte는 "의존성 그래프 추적 문제"다. 결국 둘 다 목표는 같다 — 브라우저의 비싼 작업을 최소화하자. 차이는 "어디서 바뀐 걸 알아내느냐"에 있다.
Demo Lab — 직접 눈으로 확인하는 VDOM → Diff → Patch
구현한 엔진을 시각적으로 검증할 수 있는 데모 페이지.
🖥️ Demo Lab 동작 흐름
페이지 로드
"실제 영역"의 샘플 HTML을 Virtual DOM 트리로 변환 → 테스트 영역에 렌더링
사용자 수정
"테스트 영역"에서 HTML을 자유롭게 수정 (태그 추가, 텍스트 변경, 속성 수정 등)
Patch 버튼 클릭
수정된 상태 → 새 VDOM 생성 → 이전 VDOM과 Diff → 변경분만 "실제 영역"에 반영
State History 저장
변경된 VDOM이 history에 저장됨 → 뒤로가기/앞으로가기로 특정 시점 복원 가능
🔍 Demo Lab에서 확인할 수 있는 것들
팀 병합 — 5개 브랜치를 하나로
3명이 각자 다른 방향으로 구현한 뒤 DEVELOPE 브랜치로 병합하는 과정은 생각보다 섬세한 정리가 필요했다.
🔀 Merge Strategy
기준 선정
각 브랜치의 핵심 기여물을 먼저 파악하고, 중복 구현은 가장 완성도 높은 것 기준으로 통합
엔진 코어 기반 — choihyunjin + choihyunjin2
내 브랜치에서 만든 리액트 엔진 코어(4계층 파이프라인) + 벤치마크 시스템 + UI/시각화가 최종 결과물의 뼈대가 됨
Diff Lab + 문서 통합 — choiyeongbeen
최영빈의 Diff Lab 구현과 기술 매뉴얼/스펙 문서가 엔진 위에 통합됨
README + 최종 정리 — jungchanbin
정찬빈의 README 업데이트와 최종 마무리 작업이 합쳐져 DEVELOPE 브랜치로 완성
초기에 프로젝트 구조와 엔진 인터페이스를 먼저 정리해 둔 덕분에, 팀원들이 각자의 브랜치에서 작업한 결과물을 하나의 흐름으로 묶어내기가 훨씬 수월했다. 이번 병합 과정은 좋은 기반 설계가 결국 팀 전체의 병합 비용을 줄인다는 사실을 체감하게 해 준 경험이었다.
배운 것들 — 직접 만들어야만 보이는 것들
DOM은 "살아있는 자료구조"다
JS 객체의 값 하나 바꾸는 건 싸지만, DOM의 텍스트 하나 바꾸는 건 Layout·Paint·Composite를 유발할 수 있다. 직접 변환 함수를 짜면서 이 차이를 체감했다.
O(n) Diff가 당연하지 않다
일반 트리 비교는 O(n³). "같은 레벨끼리만 비교 + key로 식별"이라는 규칙이 있어야 O(n)이 되는 것이고, 이 규칙은 "완벽한 비교를 포기하는 대신 실용적 속도를 택하는 타협"이다.
React가 key를 요구하는 이유
key 없이 diffChildren을 구현해보면 "이동"과 "변경"을 구분할 수 없다. key가 있으면 Map 기반 O(1) 조회로 정확한 판단이 가능해진다. 직접 겪어야 이해되는 부분이었다.
Virtual DOM이 무조건 빠르진 않다
벤치마크를 직접 짜보니, 변경이 적을 때는 압도적으로 유리하지만, 전체가 바뀌는 경우 diff 오버헤드가 추가된다. Svelte/Solid 같은 경쟁 방식이 나오는 이유를 알게 됐다.
브라우저 렌더링 파이프라인 이해
Layout → Paint → Composite. 왜 Layout이 비싸고, 왜 transform은 빠른지. 프론트엔드 성능 최적화의 본질은 결국 "비싼 단계를 덜 돌리는 것"이었다.
도구를 만들면 도구가 보인다
React를 쓰기만 했을 때는 "Virtual DOM이 빠르대"였다. 직접 만들어보니 "왜 빠른지, 언제 빠른지, 한계는 뭔지"가 코드 레벨에서 읽히게 됐다.
발표 Q&A — 준비했던 답변들
transform이나 opacity 같은 속성은 Layout을 건드리지 않고 GPU의 Composite 단계에서만 처리돼서 빠릅니다.createChildKeyMap으로 O(1) 조회가 가능해져 정확하게 이동만 처리할 수 있습니다.React를 사용하던 입장에서,
이제는 그 내부 구조를 설명할 수 있게 됐다.
📂 프로젝트 GitHub
'개발 > 프로젝트' 카테고리의 다른 글
| 크래프트 정글 × 바이브 프로젝트Mini SQL을 두 번 만들고 나서야보인 것들 (1) | 2026.04.16 |
|---|---|
| 크래프톤 정글 × 바이브 프로젝트Mini SQL을 두 번 만들고 나서야보인 것들 (1) | 2026.04.08 |
| 크래프톤 정글 × 수요코딩회Custom React 구현기 2편 — 과제 제약이 가르쳐준 React의 설계 원리 (0) | 2026.04.02 |
| 크래프톤 정글 × 수요코딩회 - 자체제작 Redis, 타겟팅 서비스 (0) | 2026.03.19 |
| 바이브코딩으로 하루 만에 팀 프로젝트 완성하기 — Clean Email 회고 (0) | 2026.03.12 |