기존 Virtual DOM 글의 심화 버전입니다. 같은 기초 위에서 Virtual DOM · Fiber · Concurrent Mode 사이의 관계를 확장하고, React 15 → 16 → 18 의 진화 과정을 함께 다룹니다. 기본적으로 모던 React(16~18)를 기준으로 설명하며, 대조가 필요한 곳에서만 React 15를 끌어옵니다. 아래 1번 섹션 도입부의 "이전 VDOM과 비교"는 고전적(React 15) 사고모델이며, 정확한 모던 동작은 2번 Step 2와 7번에서 정정합니다.
1. Virtual DOM이란?
실제 DOM과 달리, Virtual DOM은 메모리 상에 존재하는, DOM을 가볍게 표현한 JavaScript 객체입니다. state나 props가 바뀌면 React는 실제 DOM을 직접 고치지 않고, 먼저 Virtual DOM을 갱신한 뒤 이전 버전과 diff(비교) 하여 실제 DOM에는 꼭 필요한 최소한의 변경만 적용합니다. 이 과정을 재조정(Reconciliation) 이라고 부릅니다.
좁은 의미 vs 넓은 의미
"Virtual DOM"이라는 용어는 두 가지로 다르게 쓰이며, 이 둘을 뒤섞는 것이 대부분의 혼란의 원인입니다.
| 의미 | 가리키는 대상 | 유효한 시점 |
| 좁은 의미 | React Element 트리 (순수 { type, props, ... } 객체) | React 15 이하 — Element 트리를 직접 diff 했음 |
| 넓은 의미 | UI를 메모리 상에 표현한 모든 것 | 모던 React — 실제 구현체는 Fiber 트리 |
모던 React에서는: "Virtual DOM" ≈ Fiber 트리. Element 트리는 재조정의 입력값일 뿐이며, 메모리에 지속적으로 유지되는 표현이 아닙니다.
2. 동작 방식
1단계 — state 변경 → Virtual DOM 갱신
React 컴포넌트가 렌더링되면, React는 실제 DOM 구조를 JavaScript 객체로 표현한 Virtual DOM을 만듭니다. 예를 들어 <div class="my-class">Hello, world!</div> 는 다음과 같이 표현됩니다:
{
type: "div",
props: { className: "my-class", children: "Hello, world!" },
key: null,
ref: null,
_owner: null,
_store: {}
}
- key가 같으면 → React는 기존 DOM 노드를 재사용하고 내용만 갱신
- key가 바뀌면 → React는 기존 노드를 버리고 새 노드를 생성
2단계 — Diffing
여기서 React 15 모델과 React 16+ 모델이 갈립니다:
| React 15 | React 16+ | |
| 무엇을 비교하나 | 이전 Element 트리 ↔ 새 Element 트리 | 새 Element ↔ 기존 Fiber의 memoizedProps / memoizedState |
| 이유 | 두 Element 트리를 모두 메모리에 들고 있었음 | Fiber 트리가 이미 "이전 렌더" 상태를 보유하므로 별도의 Element 트리가 필요 없음 |
3단계 — 실제 DOM에 일괄(batch) 반영
diffing 과정에서 찾아낸 변경 사항들을 모아 한 번의 batch로 실제 DOM에 적용합니다. 덕분에 큰 UI나 깊게 중첩된 컴포넌트 트리에서도 성능이 안정적으로 유지됩니다.
3. 왜 Virtual DOM을 쓰는가?
- 불필요한 DOM 조작 최소화 — 실제 DOM을 자주 고치면 브라우저가 레이아웃을 재계산하고 다시 그려야 합니다. Virtual DOM은 이 빈도를 줄입니다.
- 더 효율적인 렌더링 — 바뀐 부분만 실제 DOM에 도달합니다.
- 개발 편의성 — 선언적이고 state 기반의 프로그래밍을 가능하게 합니다. 개발자는 UI가 어떤 모습이어야 하는지만 기술하고, DOM 조작은 React가 알아서 처리합니다.
- DocumentFragment와 유사하지만 더 확장된 개념 — Virtual DOM은 메모리 상에서 DOM 작업을 모았다가 한 번에 적용한다는 DocumentFragment의 아이디어를 확장한 것입니다.
4. DocumentFragment (배경 개념)
DocumentFragment는 메인 DOM 트리에 속하지 않는, 메모리 상의 임시 컨테이너 노드입니다. 여기에 추가된 노드들은 fragment 전체가 실제 DOM에 삽입되기 전까지는 렌더링되지 않습니다.
왜 중요한가: 개별 DOM 삽입은 매번 reflow와 repaint를 유발합니다. DocumentFragment를 쓰면 서브트리 전체를 메모리에서 먼저 만든 뒤 한 번의 작업으로 삽입 — reflow가 여러 번이 아니라 한 번만 발생합니다.
const list = document.getElementById('list');
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.appendChild(li);
}
// 한 번의 삽입 — reflow 한 번
list.appendChild(fragment);
DocumentFragment와 Virtual DOM의 관계:
| 설명 | |
| DocumentFragment | 가벼운 메모리 상의 컨테이너 — 노드들을 모았다가 실제 DOM에 한 번에 삽입 |
| Virtual DOM | UI 전체를 메모리에 표현하고, 차이(diff)만 실제 DOM에 적용 |
Virtual DOM은 DocumentFragment의 아이디어를 한 단계 더 발전시켜, state 기반 렌더링과 diff 알고리즘을 그 위에 얹은 것입니다.
5. Virtual DOM이 중요한 이유 — 계층적 관점
Virtual DOM의 효과는 큰 UI와 깊게 중첩된 컴포넌트 트리에서 가장 크게 나타납니다. 다만 모던 React를 제대로 이해하려면 세 가지 개념이 어떻게 쌓이는지를 봐야 합니다.
한 줄 요약
Virtual DOM은 "무엇을 바꿀지"를 계산한다. Fiber는 "그 작업을 어떻게 잘게 쪼갤지"를 정의한다. Concurrent Mode는 "그 조각들을 언제, 어떤 순서로 실행할지"를 결정한다.
스택 구조
┌─────────────────────────────────────┐
│ Concurrent Mode (스케줄링) │ ← "언제 실행할지"
├─────────────────────────────────────┤
│ Fiber Architecture (실행) │ ← "어떻게 쪼갤지"
├─────────────────────────────────────┤
│ Virtual DOM (변경 모델) │ ← "무엇이 바뀌었는지"
└─────────────────────────────────────┘
이 셋은 독립된 세 기능이 아니라, 각자 바로 아래 계층에 의존하는 계층(layer) 입니다.
비용은 실재한다
Virtual DOM이 공짜는 아닙니다. diffing과 reconciliation에는 그 자체의 메모리·CPU 비용이 따릅니다. 지나치게 잦은 state 변경이나 불필요한 리렌더는 여전히 성능 문제를 일으킬 수 있습니다. 컴포넌트 구조 최적화, 불필요한 state 갱신 회피, React.memo 같은 도구가 중요한 이유입니다.
6. React Element vs Fiber Node
가장 흔한 혼란의 원인: 둘 다 JavaScript 객체이지만, 근본적으로 다른 역할을 합니다.
React Element — "주문서"
{
$$typeof: Symbol(react.element),
type: "div",
props: { className: "my-class", children: "Hello, world!" },
key: null,
ref: null
}
- React.createElement / JSX로 생성
- 순수하고 가볍고 불변(immutable)
- state도, 라이프사이클 정보도, 트리 포인터도 없음
- 렌더마다 새로 생성되고, 소비된 직후 곧바로 GC됨
Fiber Node — "작업 추적기"
{
type: 'div',
child: ..., sibling: ..., return: ..., // 트리 포인터
pendingProps: { className: 'new' }, // 이번 렌더의 props
memoizedProps: { className: 'old' }, // 지난 렌더의 props
memoizedState: { count: 0 }, // hooks 상태
lanes: 0b0001, // 우선순위 비트
alternate: <twin fiber>, // 더블 버퍼링 짝
stateNode: <real DOM node>, // 실제 DOM 참조
flags: 0b0010 // effect 플래그
}
- React가 내부적으로 생성·관리
- 가변(mutable)이고 무겁고, 작업 추적에 필요한 모든 메타데이터를 보유
- 컴포넌트 인스턴스당 하나, 렌더 간 유지되며 제자리에서 변형됨
나란히 비교
| React Element | Fiber Node | |
| 무엇 | "이걸 렌더해라"라는 설명 | 그 렌더가 어떻게 처리되고 있는지의 기록 |
| 생성 주체 | 개발자 (JSX / createElement) | React 내부 |
| 수명 | 한 번의 렌더 — 생성·소비·GC | 컴포넌트 인스턴스 동안 유지 — 렌더 간 변형됨 |
| 가변성 | ❌ 불변 | ✅ 가변 |
| state 보유 | ❌ | ✅ (hooks, lanes, effects) |
| 트리 포인터 | props.children만 | child / sibling / return |
핵심 통찰
Element는 Fiber 안에 저장되지 않는다. React가 필요로 하는 필드(type, pendingProps)만 Fiber로 복사될 뿐, Element 자체는 소비된 후 버려집니다.
7. Virtual DOM 트리 vs Fiber 트리
자주 나오는 질문: "이 둘은 서로 다른 것인가?"
짧은 답: 모던 React에서 Fiber 트리가 곧 Virtual DOM(넓은 의미)입니다. Element 트리는 지속되는 트리가 아니라, 일시적인 입력값입니다.
시간에 따른 관계
Time →
T1 (렌더 시작)
Element 트리 (일시적) Fiber 트리 (지속적)
┌───────┐ ┌───────┐
│ App │ ──── 입력 ────→ │ App │
├───────┤ ├───────┤
│Header │ ──── 입력 ────→ │Header │
├───────┤ ├───────┤
│ Main │ ──── 입력 ────→ │ Main │
└───────┘ └───────┘
T2 (렌더 종료)
❌ GC 처리됨 ✅ 유지됨 (다음 렌더에서 재사용)
메모리 상의 트리 개수
렌더 도중 메모리에 실제로 존재하는 UI 트리 개수는:
- Element 트리 1개 (일시적 — 이번 렌더 후 폐기)
- Fiber 트리 2개 (current + work-in-progress — 유지, 영원히 재활용)
3개가 아닙니다. "이전 Element 트리"가 따로 남아있지 않습니다 — 그 역할은 current Fiber 트리의 memoizedProps / memoizedState가 대신합니다.
[1] 새 Element 트리 생성
↓
[2] 비교: 새 Element ↔ current Fiber.memoizedProps
↓
[3] 변경점을 WIP에 반영
- WIP.pendingProps에 새 props 기록
- WIP.flags에 효과(effect) 표시
↓
[4] WIP 완성 → commit (실제 DOM 반영)
↓
[5] ★ SWAP
- 이전 WIP → 새 current (화면 표시)
- 이전 current → 새 WIP (다음 사이클 재활용 대기)
↓
[6] Element 트리는 GC
1. Render phase의 "변경 표시" 단계
WIP를 빌드하는 동안 React는 각 Fiber 노드에 flags(이펙트 플래그) 를 찍어둡니다:
const fiber = {
type: 'div',
pendingProps: { className: 'new' },
memoizedProps: { className: 'old' },
flags: 0b00000010, // ← 변경 종류를 비트로 표시
// ...
};
주요 Effect Flag 종류
| Flag | 의미 | DOM 동작 |
| Placement | 새로 삽입 | appendChild, insertBefore |
| Update | props 변경 | setAttribute, nodeValue 등 |
| Deletion | 제거 | removeChild |
| ChildDeletion | 자식 중 일부 제거 | removeChild |
| Ref | ref 변경 | ref 콜백 호출 |
| Snapshot | getSnapshotBeforeUpdate | 라이프사이클 |
→ 변경 없는 노드는 flag가 0. commit 때 건너뜁니다.
2. Commit phase는 flag 찍힌 것만 처리
function commitWork(fiber) {
if (fiber.flags === 0) return; // ← 플래그 없으면 스킵
if (fiber.flags & Placement) {
parentDOM.appendChild(fiber.stateNode);
}
if (fiber.flags & Update) {
updateDOMProps(fiber.stateNode, fiber.memoizedProps, fiber.pendingProps);
}
if (fiber.flags & Deletion) {
parentDOM.removeChild(fiber.stateNode);
}
}
→ 변경된 Fiber 노드의 stateNode(실제 DOM 노드)만 만집니다. 나머지는 손도 대지 않습니다.
8. React Fiber 아키텍처
Fiber는 React 16에서 도입된 렌더링 엔진 재작성입니다. 핵심 목적은 렌더링 작업을 작은 단위로 쪼개어 우선순위를 매기고 끼워 넣을 수 있게 하는 것입니다.
Fiber 이전: React는 동기 렌더링을 사용했습니다 — state 갱신이 일어나면 Virtual DOM 전체를 diff하고 모든 DOM 변경을 중단 불가능한 한 번의 패스로 진행했습니다. 무거운 컴포넌트는 프레임 드랍을 일으켰습니다.
Fiber 이후: 각 컴포넌트가 자신의 Fiber 노드를 가집니다. 작업은 우선순위가 매겨진 단위들로 분할됩니다:
- 높은 우선순위 작업(사용자 입력, 애니메이션)이 먼저 실행
- 낮은 우선순위 작업은 백그라운드에서 실행
function SearchPage() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState([]);
return (
<>
<input
value={query}
onChange={(e) => {
setQuery(e.target.value); // Sync → input 즉시 반영
startTransition(() => {
setResults(filterHuge(e.target.value)); // Transition → 양보 가능
});
}}
/>
{isPending && <Spinner />}
<ResultList data={results} />
</>
);
}
Fiber가 진짜로 가능하게 한 것은 리렌더가 일어나야만 하는 경우에도 우선순위를 나눠서 양보하며 진행하는 것 — 즉 useTransition, useDeferredValue, Suspense 같은 동시성 기능들입니다.
"Fiber로 바꾸면서 렌더링을 중단·재개·우선순위 조정할 수 있는 인프라가 깔렸고, useTransition, useDeferredValue, Suspense 같은 그 인프라를 활용하는 API가 추가됐다."
"메인 스레드를 양보한다"는 말의 진짜 의미
흔히 쓰는 표현: "Fiber는 React가 메인 스레드를 브라우저에게 양보하게 해준다." 자주 오해되는 부분이니 정확히 풀어봅시다.
브라우저에는 메인 스레드가 단 하나뿐입니다. 다음이 모두 그 위에서 돌아갑니다:
- JavaScript 실행 (React 포함)
- 레이아웃 계산
- Paint (픽셀 그리기)
- 사용자 입력 처리 (click, scroll, keydown)
- 애니메이션 프레임
- setTimeout / Promise 콜백
"브라우저 스레드"와 분리된 별도의 "React 스레드"는 없습니다. 둘은 같은 하나의 스레드를 공유합니다.
"양보(yield)"란: React가 자신의 함수 실행을 자발적으로 멈춰 콜 스택을 비움으로써, 같은 스레드에 큐잉된 다른 작업(paint, 입력 핸들러)이 실행될 수 있게 하는 것입니다.
[Fiber 이전 — Stack Reconciler]
[click!] → React render ━━━━━━━━━━━━━━━━━━━ done
콜 스택이 100ms 동안 점유됨 → 다른 어떤 것도 실행 불가
❌ paint 없음, 입력 처리 없음, 얼어붙은 UI
[Fiber 이후]
[click!] → render1 → ⏸ → paint → render2 → ⏸ → input → render3 → ...
5ms 양보 5ms 양보
브라우저가 같은 스레드에서 다른 작업을 할 틈을 얻음
render = React가 CPU에서 하는 계산
구체적으로 그 5ms 동안 React가 하는 일:
| 작업 | 내용 |
| 컴포넌트 함수 실행 | SearchPage() 같은 함수를 호출해 새 Element 계산 |
| diff 비교 | 새 Element ↔ 기존 Fiber의 memoizedProps 비교 |
| WIP 트리 빌드 | 변경점을 WIP 노드의 pendingProps / flags에 기록 |
→ 전부 메모리 안에서 일어나는 자바스크립트 연산입니다. 화면에는 아무것도 안 나타납니다 (DOM 안 건드림).
네 가지 핵심 메커니즘
① Unit of Work — 재귀를 객체 순회로 대체
함수 재귀는 일시정지할 수 없습니다(콜 스택을 도중에 멈출 수 없음). Fiber는 이를 연결된 객체들에 대한 반복(iteration) 으로 대체합니다.
이전 — 재귀 (React 15)
function renderTree(node) {
doWork(node);
node.children.forEach(renderTree); // 재귀 호출
}
renderTree(root); // 진행 위치가 "콜 스택"에 쌓임 → 끝까지 가야만 끝남
"지금 몇 번째 노드까지 했는지"가 콜 스택 안에 숨어 있어 꺼낼 수도, 멈출 수도 없음.
이후 — 반복 (Fiber)
let nextUnit = rootFiber;
while (nextUnit && !shouldYield()) { // 5ms 넘으면 shouldYield()=true → 양보
nextUnit = performUnitOfWork(nextUnit); // 노드 1개 처리 → "다음 노드" 리턴
}
// 멈춤: nextUnit만 저장하고 빠져나옴 → 콜 스택이 비워짐
// 재개: 저장한 nextUnit으로 루프만 다시 시작
진행 위치가 nextUnit 변수 하나로 밖에 나와 있어, 저장했다가 그 지점부터 이어갈 수 있음.
| 이전: 재귀 | 이후: Fiber 반복 | |
| 진행 위치 저장 | 콜 스택 (암묵적) | nextUnit 변수 (명시적) |
| 중단 가능? | ❌ 끝까지 실행 | ✅ 언제든 멈추었다 재개 |
| 트리 이동 | 재귀가 자동 | child/sibling/return 포인터로 수동 |
② Two Phases — 계산과 적용의 분리
| Phase | 역할 | 성질 |
| Render | work-in-progress Fiber 트리 빌드 | 중단·재시작·폐기 가능. 부수 효과 금지. |
| Commit | 실제 DOM에 변경 적용, effect 실행 | 원자적, 동기적, 중단 불가 |
분리한 이유: 렌더는 버리고 다시 해도 안전하지만, DOM은 절반만 갱신된 상태로 둘 수 없기 때문입니다.
③ Double Buffering — 두 트리를 영원히 재활용
React는 메모리에 두 개의 Fiber 트리를 유지합니다:
- current: 현재 화면에 반영된 트리
- work-in-progress (WIP): 백그라운드에서 빌드 중인 트리
각 Fiber 노드는 다른 트리의 쌍둥이를 가리키는 alternate 포인터를 가집니다.
④ Lane Model (React 18+) — 우선순위 구분
| Lane | 예시 |
| Sync | 클릭, 이벤트 핸들러 내 타이핑 |
| InputContinuous | 드래그, 스크롤, mousemove |
| Default | async 콜백 내부의 setState |
| Transition | useTransition으로 감싼 갱신 |
| Idle | 백그라운드 prefetch |
9. 전체 렌더 사이클 — 단계별 분석
모던 React가 실제로 어떻게 동작하는지의 핵심입니다. 단 한 번의 setState 이후 무슨 일이 벌어지는지 따라가 봅시다:
[1] setState 발생
↓
[2] 컴포넌트 함수 실행 → 새 Element 트리 생성 (일시적)
↓
[3] React가 current Fiber 트리를 기반으로 WIP 빌드 시작
- current 노드의 정보를 alternate(WIP)로 복사
- 새 Element.props ↔ current Fiber.memoizedProps 비교
- 차이를 WIP.pendingProps + flags 에 기록
↓
[4] WIP 완성 → commit phase
- 실제 DOM에 변경 적용
- WIP.memoizedProps ← pendingProps
↓
[5] ★ SWAP
- root.current = WIP (WIP가 새 current로 승격)
- 기존 current는 그대로 유지 (다음 번 WIP가 됨)
↓
[6] Element 트리 → GC 처리
↓
[다음 setState 대기 → 1단계로 루프백]
사라지는 것 vs 살아남는 것
| 객체 | 한 사이클 동안 | 다음 사이클에서 |
| Element 트리 | 생성 → 소비 → GC | 처음부터 다시 생성 |
| current Fiber | 화면 표시 → commit 후 WIP로 강등 | 다음 commit에서 다시 current로 승격 |
| WIP Fiber | 빌드 중 → commit 후 current로 승격 | 다음 사이클을 위해 WIP로 강등 |
Element는 매 렌더마다 태어나고 죽는다. 두 Fiber 트리는 영원히 살아있다 — 매 commit마다 역할만 맞바꿀 뿐이다.
Double Buffering 다이어그램
T=0 안정 상태
┌─────────┐ ┌─────────┐
│ current │ ←─표시 │ WIP │ (지난 사이클의 잔여물)
└────┬────┘ └────┬────┘
└────alternate──────┘
T=1 setState → 새 Element 트리 생성 ✨ (일시적)
┌─────────┐
│ Element │
└────┬────┘
│ 입력
▼
T=2 ┌─────────┐ 비교 ┌─────────┐
│ current │ ───────→│ WIP │ ← 빌드 중
│ (표시중) │ │ (신규) │
└─────────┘ └─────────┘
T=3 WIP 완성 → commit (DOM 갱신)
Element → ❌ GC
T=4 ★ SWAP — 역할 교체
┌─────────┐ ┌──────────────┐
│ 이전 WIP │ ←─표시 │ 이전 current │
│ = 새 current │ = 새 WIP │ (재사용 대기)
└────┬────┘ └────┬─────────┘
└────alternate──────┘
T=5 다음 setState 대기 → T=1로 루프백
(두 Fiber 트리는 결코 파괴되지 않는다 — 자리만 맞바꿀 뿐.)
왜 이런 설계인가?
- GC 부담 감소 — Fiber 노드는 새로 할당하지 않고 제자리에서 변형됩니다. 끊임없는 메모리 churn이 없습니다.
- 안전한 중단 — current는 건드린 적이 없으므로 WIP를 버려도 안전합니다. 화면은 일관성을 유지합니다.
- 빠른 쌍둥이 접근 — alternate 포인터가 두 트리를 O(1)로 연결합니다.
10. Concurrent Mode
Concurrent Mode는 Fiber 위에 구축된 렌더링 전략입니다. React에게 렌더링 작업을 멈추고, 재개하고, 우선순위를 다시 매길 능력을 부여합니다.
목표: 사용자 이벤트를 위해 브라우저를 결코 막지 않음으로써 체감 UX를 개선하는 것.
활성화
import { createRoot } from 'react-dom/client';
import App from './App';
const root = createRoot(document.getElementById('root'));
root.render(<App />);
핵심 동작
- 긴 렌더가 분할되어, 브라우저 이벤트(클릭, 키 입력)가 UI를 얼리지 않고 처리됨
- 낮은 우선순위 갱신은 백그라운드에서 실행
- React는 준비됐을 때만 DOM에 commit
중단(Interrupt) 시 벌어지는 일
낮은 우선순위 렌더가 진행 중일 때 더 높은 우선순위 갱신(예: Sync lane)이 도착하면:
- 진행 중이던 WIP는 폐기됨 — current는 건드린 적이 없으므로 화면은 안전
- 더 높은 우선순위 갱신을 위한 새 WIP 시작
- 이것이 가능한 이유는 Double Buffering 덕분 — 그게 없으면 불가능
예시 — useTransition
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
setQuery(e.target.value); // Sync — input은 즉각적으로 느껴짐
startTransition(() => {
setResults(filterHugeList(e.target.value)); // Transition — 양보 가능
});
};
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<ResultList data={results} />
</>
);
}
Concurrent Mode가 열어주는 것
| 기능 | Fiber 의존성 |
| useTransition | Lane Model |
| useDeferredValue | Lane Model |
| <Suspense> (data) | Double Buffering + Lane Model |
| Automatic Batching | Unit-of-work 스케줄러 |
| Concurrent Rendering | 위의 모든 것 |
→ 이 중 어느 것도 Fiber 없이는 불가능합니다.
Fiber + Concurrent Mode 함께 보기
| 역할 | |
| Fiber | 렌더링을 작은 단위로 쪼갬; 우선순위 부여를 가능하게 함 |
| Concurrent Mode | 그 단위들을 유연하게 스케줄링 — 낮은 우선순위 작업을 미루고, 사용자 입력과 애니메이션을 우선시 |
Fiber는 작업의 단위를 정의하고, Concurrent Mode는 그것을 언제 어떤 순서로 실행할지를 결정합니다.
11. React 15 vs 16~17 vs 18 — 나란히 비교
아키텍처
| React 15 | React 16~17 | React 18 | |
| 엔진 | Stack Reconciler | Fiber (인프라만) | Fiber + Concurrent |
| 순회 방식 | 재귀 (콜 스택) | While 루프 (포인터) | While 루프 + 스케줄러 |
| 중단 가능 | ❌ | ✅ | ✅ |
| 재시작 가능 | ❌ | ✅ | ✅ |
| 우선순위 시스템 | ❌ 모두 동등 | ❌ (아직 없음) | ✅ Lane Model |
| Virtual DOM | 두 개의 Element 트리 | Fiber 트리 + 일시적 Element | 동일 |
| 메모리 상 트리 | Element 2개 | Element 1개 + Fiber 2개 | 동일 |
| 트리 재활용 | ❌ (매 렌더 새로 생성) | ✅ (current ↔ WIP swap) | ✅ |
| Commit | 동기, 한 번에 | 동기, 원자적 (분리됨) | 동기, 원자적 (분리됨) |
사용자 경험 — 동일한 시나리오
시나리오: 무거운 리스트가 렌더링 중일 때 사용자가 버튼을 클릭한다.
React 15
T=0 : click! 발생
T=0 : React render() 호출
T=0~120: 전체 트리를 재귀로 처리 (콜 스택 점유)
❌ 이 시간 동안 paint 없음
❌ 다른 클릭 무시됨
T=120 : 두 Element 트리 비교, DOM 갱신
T=121 : 다음 프레임 paint
→ 사용자는 121ms의 멈춤을 경험
React 18 (Concurrent)
T=0 : click! 발생 (Sync lane)
T=0 : Element 생성 → WIP 빌드 시작
T=5 : 5ms의 Fiber 작업 → 양보
T=5~16 : 브라우저 paint (current는 건드리지 않음 → 화면 정상)
T=16 : React 재개 (Transition lane)
T=21 : 다시 양보 → paint
... 반복 ...
T=120 : WIP 완성, commit, swap → 새 화면
→ 사용자는 내내 부드러운 프레임을 보고, 멈춤이 없음
개발자 API
| React 15 | React 16 | React 18 | |
| 컴포넌트 | 클래스 기반 | 클래스 + Hooks (16.8) | Hooks 우선 |
| 루트 마운트 | ReactDOM.render | ReactDOM.render | createRoot |
| 우선순위 hooks | ❌ | ❌ | useTransition, useDeferredValue |
| Suspense | ❌ | 코드 분할만 | 데이터 페칭까지 |
| StrictMode | 기본 검사 | 기본 검사 | 컴포넌트를 두 번 마운트 ★ |
| Batching | 이벤트 핸들러 내부만 | 동일 | 모든 곳 (Automatic) ★ |
개발자가 지켜야 할 규칙
| 규칙 | React 15 | React 18 |
| 렌더는 순수해야 함 | 권장 | 강제 (WIP가 폐기·재시도될 수 있음 → 렌더가 여러 번 실행될 수 있음) |
| useEffect cleanup | 권장 | 필수 (컴포넌트가 반복 마운트될 수 있음) |
| Stale closure | 대개 문제없음 | 함수형 setState 권장 |
| setState 직후 DOM 확인 | 가능 | flushSync 필요 (batching 때문) |
12. 개발자에게 미치는 영향 — 실제로 무엇이 바뀌었나
🟢 일상 코드 — 사실상 변화 없음
JSX, useState, useEffect, props, 컴포넌트 — 같은 API, 같은 문법. Fiber 재작성은 엔진 교체이며, 표면에서는 투명합니다.
🟡 선택적(Opt-In) 신규 기능
// 옛 방식 (16~17)
ReactDOM.render(<App />, root);
// 새 방식 (18+) — Concurrent 활성화
createRoot(root).render(<App />);
// Opt-in hooks
const [isPending, startTransition] = useTransition();
const deferredValue = useDeferredValue(value);
무거운 화면에만 선택적으로 적용하면 됩니다 — 전부 다시 쓸 필요 없습니다.
🔴 더 엄격해진 규칙 ★ 가장 중요한 변화
| 변화 | 영향 |
| 렌더는 순수해야 함 | 렌더 안의 부수 효과 → 여러 번 실행되거나 폐기됨 → 실제 버그 |
| Strict Mode가 컴포넌트를 두 번 마운트 | 누락된 useEffect cleanup을 즉시 드러냄 |
| useEffect cleanup 필수 | 반복 마운트/언마운트에도 안전 |
| Automatic Batching 확대 | async 콜백도 이제 batch됨. 즉시 렌더가 필요하면 flushSync 사용 |
| 함수형 setState 권장 | setCount(prev => prev + 1) — stale closure 회피 |
13. 요약
Virtual DOM은 React의 빠르고 효율적인 UI 갱신을 가능하게 하는 핵심 메커니즘입니다. 다만 모던 React를 이해하려면 세 계층을 모두 봐야 합니다:
- Virtual DOM — 변경 모델: 무엇을 갱신할지를 메모리에서 계산
- Fiber — 실행 엔진: 그 작업을 어떻게 중단 가능한 단위로 쪼갤지
- Concurrent Mode — 스케줄링 전략: 그 단위들을 언제, 어떤 순서로 실행할지
diffing과 reconciliation은 불필요한 DOM 작업을 최소화하고 규모가 커져도 성능을 안정적으로 유지합니다. 그러나 Virtual DOM에도 비용은 있습니다 — 과도한 state 변경과 불필요한 리렌더는 여전히 성능을 해칠 수 있습니다. 그래서 최적화 전략(컴포넌트 구조, React.memo, 중복 state 회피)이 함께 중요합니다.
Virtual DOM, Fiber, Concurrent Mode가 함께 작동하기에 React는 성능과 사용자 경험을 동시에 제공할 수 있습니다 — 그리고 React 15에서 18로의 개념적 진화는 근본적으로, React가 무엇을 계산하는지를 넘어 렌더링 작업을 언제 실행할지에 대한 통제력을 더 갖게 되는 과정입니다.
'Frontend > React' 카테고리의 다른 글
| React Context API vs Redux: 언제, 무엇을 써야할까? (0) | 2025.10.27 |
|---|---|
| useEffect vs useLayoutEffect (0) | 2025.10.27 |
| 상태(stateful) & 비상태(stateless) 컴포넌트 (0) | 2025.10.25 |
| React에서 비동기 처리 최적화하기 (0) | 2025.10.25 |
| useRef 자세히 알아보기 (0) | 2025.10.24 |