"React는 UI를 효율적으로 업데이트합니다"라는 말을 자주 듣지만, 정확히 어떻게 효율적일까?
이 글에서는 React가 브라우저의 렌더링 파이프라인 위에서 어떻게 작동하는지, 그 모든 과정을 자세히 알아가 보겠다.
먼저 React 렌더링과 브라우저 렌더링의 관계를 명확히 하자.
React 렌더링 ≠ 브라우저 렌더링
React 렌더링:
└─ React 컴포넌트의 render 함수 실행
└─ JSX를 JavaScript 객체로 변환
└─ Virtual DOM 생성/업데이트
브라우저 렌더링:
└─ DOM 파싱
└─ 레이아웃 계산
└─ 페인팅
└─ 화면 표시
React 렌더링이 일어난다 ≠ 화면이 업데이트된다
React가 렌더링했어도 실제로 DOM이 변경되지 않으면 브라우저는 아무것도 그리지 않는다.
React는 렌더링을 두 개의 명확한 단계로 나눈다.
┌─────────────────────────────────────────────────┐
│ 1️⃣ RENDER PHASE (렌더 단계) │
│ │
│ - 컴포넌트 함수 실행 │
│ - JSX 반환 │
│ - Virtual DOM 생성/업데이트 │
│ - 변경사항 계산 │
│ │
│ ⏸️ 일시 중단 가능 (중단 후 재개 가능) │
│ 🧵 메인 스레드 블로킹 가능 │
│ 🔄 여러 번 실행 가능 │
└─────────────────────────────────────────────────┘
↓
(상태 업데이트 또는 의존성 변경)
↓
┌─────────────────────────────────────────────────┐
│ 2️⃣ COMMIT PHASE (커밋 단계) │
│ │
│ - Virtual DOM을 실제 DOM에 적용 │
│ - 레이아웃 사이드 이펙트 실행 │
│ (useLayoutEffect) │
│ - 브라우저 렌더링 트리거 │
│ │
│ ⏸️ 중단 불가능 (반드시 완료) │
│ 🧵 메인 스레드 블로킹 │
│ 🔄 한 번 실행 │
└─────────────────────────────────────────────────┘
↓
(브라우저 렌더링 시작)
↓
(useEffect 실행 대기)
↓
┌─────────────────────────────────────────────────┐
│ 브라우저 렌더링 파이프라인 실행 │
│ (스타일 계산 → 레이아웃 → 페인팅 → 컴포지팅) │
└─────────────────────────────────────────────────┘
↓
(브라우저 렌더링 완료)
↓
(useEffect 실행 - 비동기)
// 간단한 React 컴포넌트
function Counter({ initialValue }) {
const [count, setCount] = React.useState(initialValue);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
/* 렌더 단계에서 일어나는 일:
1. Counter 함수 호출
└─ initialValue = 0 (props)
2. useState 훅 실행
└─ [count, setCount] = [0, 함수]
3. JSX 반환
└ return (
<div>
<p>Count: {count}</p>
<button onClick={...}>Increment</button>
</div>
)
4. JSX를 React Element 객체로 변환 (Babel이 함)
└ {
type: 'div',
props: {
children: [
{ type: 'p', props: { children: 'Count: 0' } },
{ type: 'button', props: { children: 'Increment' } }
]
}
}
5. Virtual DOM 생성
└ React 내부 자료구조에 저장
*/
function App() {
const [count, setCount] = React.useState(0);
console.log('App 렌더 함수 실행'); // ← 몇 번 출력될까?
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
/* 실제 동작:
1. 초기 마운트:
→ 'App 렌더 함수 실행' (1번)
2. 버튼 클릭:
→ 'App 렌더 함수 실행' (렌더 단계 시작)
→ 만약 React가 중단하고 우선순위를 다시 조정한다면?
→ 'App 렌더 함수 실행' (다시 실행 - 렌더 단계 재개)
→ 'App 렌더 함수 실행' (최종 커밋 전 확인)
→ 커밋 단계 진행
*/
왜 여러 번 실행될 수 있을까?
React 18에서 도입된 Concurrent Rendering 때문이다. React는 렌더 단계를 일시 중단했다가 나중에 재개할 수 있다.
// 시나리오: 우선순위가 높은 업데이트 발생
시점 1: 낮은 우선순위 업데이트 시작
├─ 렌더 단계 시작 (A 컴포넌트)
├─ 렌더 단계 중단 (300ms 경과)
시점 2: 높은 우선순위 업데이트 발생 (사용자 입력)
├─ 진행 중인 렌더 단계 버림
├─ 새로운 렌더 단계 시작 (B 컴포넌트)
├─ 렌더 단계 완료
├─ 커밋 단계 실행
시점 3: 남은 작업 계속
├─ 다시 A 컴포넌트 렌더 시작
├─ 렌더 단계 완료
├─ 커밋 단계 실행
/* ❌ 렌더 단계에서 부작용을 일으키면 안 됨 */
function BadComponent() {
const [count, setCount] = React.useState(0);
// ❌ 직접 DOM 조작
document.getElementById('counter').textContent = count;
// ❌ 외부 변수 수정
window.globalCount = count;
// ❌ API 호출
fetch('/api/count');
// ❌ console.log도 여러 번 실행됨을 알 수 있음
console.log('렌더 중:', count);
return <div>{count}</div>;
}
/* 왜 문제인가?
- 렌더 단계가 여러 번 실행될 수 있음
- 따라서 부작용도 여러 번 일어남
- 예상치 못한 버그 발생
- 성능 저하
*/
/* ✅ 순수한 렌더 */
function GoodComponent() {
const [count, setCount] = React.useState(0);
// ✅ 순수 함수만 사용
const doubled = count * 2;
const message = `Count is ${count}`;
return (
<div>
<p>{message}</p>
<p>{doubled}</p>
</div>
);
}
React는 Fiber라는 데이터 구조를 사용해 렌더 단계를 관리한다.
Fiber: React 내부의 작업 단위
각 React 컴포넌트/DOM 요소 = 하나의 Fiber
Fiber 객체:
{
type: FunctionComponent, // 컴포넌트 타입
props: { ... }, // props
state: [ ... ], // 상태
effects: [ ... ], // useEffect 목록
child: Fiber | null, // 첫 번째 자식
sibling: Fiber | null, // 다음 형제
parent: Fiber | null, // 부모
alternate: Fiber | null, // 이전 버전 (비교용)
hooks: [ ... ], // 훅 상태 저장소
}
Fiber를 사용하는 이유
// Fiber 이전: 재귀적으로 전체 트리를 동시에 처리
// ❌ 문제: 중단 불가능, 높은 우선순위 작업 지연
// Fiber 이후: 작은 단위로 나누어 처리
// ✅ 장점: 중단 가능, 우선순위 조정 가능, 점진적 처리
/* 예: 3개 컴포넌트 렌더 */
┌─────────────────────────────────────────┐
│ Fiber 이전 (동기적) │
│ │
│ ┌─────────────┬─────────────────┐ │
│ │ Component A │ │ │
│ │ + B + C │ 계속 블로킹... │ │
│ │ │ │ │
│ └─────────────┴─────────────────┘ │
│ 10ms 10ms 10ms │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Fiber 이후 (점진적, 중단 가능) │
│ │
│ ┌────────┐ │
│ │Comp A │← 5ms 렌더 │
│ └────────┘ │
│ ┌────────┐ │
│ │Comp B │← 5ms 렌더 │
│ └────────┘ │
│ ┌────────┐ │
│ │Comp C │← 5ms 렌더 │
│ └────────┘ │
│ 중간에 우선순위 높은 작업이 들어오면 │
│ 현재 작업을 중단하고 그것을 먼저 처리 │
└─────────────────────────────────────────┘
Render Phase에서 Virtual DOM을 준비했다면, Commit Phase에서는 실제로 DOM을 변경한다.
/* Commit Phase의 단계 */
┌─────────────────────────────────────────────────────┐
│ 1단계: 레이아웃 효과 실행 전 (Before Layout Effects) │
│ │
│ - DOM 업데이트 준비 │
│ - 변경할 DOM 노드 계산 │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 2단계: DOM 변경 (Mutation Phase) │
│ │
│ React.createDOM()으로 DOM 업데이트 │
│ - 새로운 요소 추가 │
│ - 기존 요소 제거 │
│ - 속성 변경 │
│ - 이벤트 리스너 변경 │
│ │
│ ⚠️ 이 순간부터 DOM이 변경됨! │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 3단계: useLayoutEffect 실행 │
│ │
│ - DOM이 변경된 직후 실행 │
│ - 브라우저 렌더링이 일어나기 전 실행 │
│ - 여기서 DOM을 읽으면 정확한 값을 얻을 수 있음 │
│ │
│ 예: │
│ useLayoutEffect(() => { │
│ const height = element.offsetHeight; │
│ // ← 정확한 높이 값 │
│ }, []); │
└─────────────────────────────────────────────────────┘
↓
(브라우저 렌더링 시작 - Layout, Paint, Composite)
↓
┌─────────────────────────────────────────────────────┐
│ 4단계: useEffect 스케줄 (비동기 실행) │
│ │
│ - 브라우저 렌더링이 완료된 후 실행 │
│ - 메인 스레드에 여유가 생기면 실행 │
│ - 시간 제약 없음 │
│ │
│ 예: │
│ useEffect(() => { │
│ fetch('/api/data'); │
│ // ← 브라우저 렌더링 후 실행 │
│ }, []); │
└─────────────────────────────────────────────────────┘
/* 상태 업데이트 전 */
<div id="root">
<p>Count: 0</p>
<button>Increment</button>
</div>
/* 버튼 클릭 → setCount(1) 호출 */
/* Render Phase */
// React가 새로운 Virtual DOM 생성
// 이전 Virtual DOM과 비교 (Reconciliation)
// 변경사항 계산
/* Commit Phase */
function Counter() {
const [count, setCount] = React.useState(0);
// ← useLayoutEffect 아직 실행 안 됨
useLayoutEffect(() => {
console.log('DOM 업데이트 됨, 값:', element.textContent);
// ← 여기서는 새로운 값을 볼 수 있음
}, []);
useEffect(() => {
console.log('브라우저 렌더링 완료');
// ← useLayoutEffect 이후에 실행
}, []);
return (
<div>
<p ref={element}>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
/* 실행 순서:
1. 상태 업데이트: setCount(1)
2. Render Phase: Counter 함수 재실행 (count = 1)
3. Commit Phase 시작
4. DOM 변경: <p>Count: 1</p>
5. useLayoutEffect 실행
6. 브라우저 렌더링 (Layout, Paint, Composite)
7. useEffect 스케줄 (비동기로 나중에 실행)
*/
┌────────────────────────────────────────────────────────────┐
│ 1. 사용자 상호작용 또는 상태 업데이트 │
│ - 버튼 클릭 │
│ - useState에서 setState 호출 │
│ - props 변경 │
└────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────┐
│ 2. React Render Phase │
│ - 컴포넌트 함수 실행 │
│ - JSX 반환 │
│ - Virtual DOM 생성 │
│ - 이전 Virtual DOM과 비교 (Diff) │
│ - 변경사항 결정 │
│ │
│ ⏸️ 일시 중단 가능 (Concurrent Features) │
│ 🧵 메인 스레드 블로킹 │
│ 🔄 여러 번 재실행 가능 │
└────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────┐
│ 3. React Commit Phase │
│ │
│ 3-1. useLayoutEffect 전 준비 │
│ 3-2. 실제 DOM에 변경사항 적용 │
│ - appendChild() │
│ - removeChild() │
│ - setAttribute() │
│ - addEventListener() │
│ 3-3. useLayoutEffect 실행 │
│ - 레이아웃 관련 작업 │
│ - DOM 측정 │
│ - 즉시 업데이트 필요한 작업 │
│ │
│ ⏸️ 중단 불가능 (반드시 완료) │
│ 🧵 메인 스레드 블로킹 │
│ 🔄 한 번 실행 │
└────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────┐
│ 4. 브라우저 Parsing & Rendering (DOM이 변경되었으므로) │
│ │
│ 4-1. Style Calculation │
│ - CSS 적용 │
│ - 계산된 스타일 결정 │
│ 4-2. Layout (Reflow) │
│ - 각 요소의 크기/위치 계산 │
│ 4-3. Paint (Repaint) │
│ - 픽셀로 변환 │
│ 4-4. Composite │
│ - 레이어 합성 │
│ - 최종 이미지 생성 │
│ │
│ ⚠️ 이 단계는 React와 무관함 │
│ 🧵 브라우저의 렌더링 엔진이 처리 │
└────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────┐
│ 5. 화면에 표시 │
│ │
│ 사용자가 최종 결과물을 봄 │
└────────────────────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────────────────────┐
│ 6. React useEffect 실행 (비동기, 시간 제약 없음) │
│ │
│ - API 호출 │
│ - 이벤트 리스너 등록 │
│ - 타이머 설정 │
│ - localStorage 접근 │
│ │
│ ✅ 메인 스레드 블로킹 안 함 │
│ 🔄 여러 개 실행 가능 │
└────────────────────────────────────────────────────────────┘
function Counter() {
const [count, setCount] = React.useState(0);
const [message, setMessage] = React.useState('');
console.log('1. 렌더 함수 실행 (count =', count, ')');
// ❌ 렌더 단계에서 부작용을 일으킬 수 없음
// fetch('/api/count'); // 하지 말 것!
useLayoutEffect(() => {
console.log('3. useLayoutEffect 실행 (DOM 변경됨)');
console.log(' 현재 DOM:', document.getElementById('count').textContent);
return () => {
console.log('3-cleanup. useLayoutEffect 정리 함수');
};
}, [count]);
useEffect(() => {
console.log('4. useEffect 실행 (브라우저 렌더링 후)');
console.log(' API 호출하거나 이벤트 리스너 등록');
return () => {
console.log('4-cleanup. useEffect 정리 함수');
};
}, [count]);
return (
<div>
<p id="count">Count: {count}</p>
<button onClick={() => {
console.log('2. 클릭 → Render Phase 시작');
setCount(count + 1);
}}>
Increment
</button>
</div>
);
}
/* 실행 흐름
초기 마운트
1. 렌더 함수 실행 (count = 0)
2. (브라우저가 DOM 생성하기 전)
3. useLayoutEffect 실행 (DOM 변경됨)
4. 브라우저가 화면 렌더링
5. useEffect 실행 (브라우저 렌더링 후)
버튼 클릭
2. 클릭 → Render Phase 시작
1. 렌더 함수 실행 (count = 1)
2. (DOM이 변경되기 전에 정리)
3-cleanup. useLayoutEffect 정리 함수
3. useLayoutEffect 실행 (새로운 count 값)
4. 브라우저가 화면 렌더링
4-cleanup. useEffect 정리 함수
4. useEffect 실행 (새로운 count 값)
*/
React가 매번 모든 DOM을 새로 생성한다면 매우 비효율적이다.
/* ❌ 비효율적 방식 (Virtual DOM 없음) */
function render(data) {
// 전체 HTML을 새로 생성
document.innerHTML = `
<div>
<p>Name: ${data.name}</p>
<p>Age: ${data.age}</p>
<p>Email: ${data.email}</p>
</div>
`;
// 문제
// - 불필요한 DOM 노드 재생성
// - 입력 필드의 포커스 손실
// - 이벤트 리스너 손실
// - 각 업데이트마다 전체 파싱 필요
}
/* ✅ 효율적 방식 (Virtual DOM 사용) */
function render(data) {
// Virtual DOM에서 비교
const newVDOM = createVirtualDOM(data);
const changes = diff(oldVDOM, newVDOM);
// 변경된 부분만 업데이트
changes.forEach(change => {
applyChange(document, change);
});
// 장점:
// - 변경된 부분만 DOM 업데이트
// - 포커스, 이벤트 리스너 유지
// - 필요한 파싱만 수행
}
/* 렌더링할 JSX */
function App() {
const [count, setCount] = React.useState(0);
return (
<div className="container">
<h1>Counter</h1>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
/* Virtual DOM 표현 (개념적) */
{
type: 'div',
props: {
className: 'container',
children: [
{
type: 'h1',
props: {
children: 'Counter'
}
},
{
type: 'p',
props: {
children: 'Count: 0' // 상태에 따라 변함
}
},
{
type: 'button',
props: {
onClick: [Function],
children: '+'
}
}
]
}
}
React는 Diff 알고리즘을 사용해 이전 Virtual DOM과 새로운 Virtual DOM을 비교한다.
/* 상태 변경 전 */
const oldVDOM = {
type: 'p',
props: { children: 'Count: 0' }
};
/* 상태 변경 후 */
const newVDOM = {
type: 'p',
props: { children: 'Count: 1' }
};
/* Diff 결과 */
const changes = {
type: 'UPDATE_TEXT',
element: <p> 노드,
oldValue: 'Count: 0',
newValue: 'Count: 1'
};
/* 실제 DOM 업데이트 */
element.textContent = 'Count: 1';
Key Props의 중요성
/* ❌ Key 없음 - 비효율적 */
function List({ items }) {
return (
<ul>
{items.map(item => (
<li>{item.name}</li> // ← key 없음!
))}
</ul>
);
}
// items = ['Alice', 'Bob']
// 렌더링:
// <li>Alice</li>
// <li>Bob</li>
// items = ['Charlie', 'Alice', 'Bob']로 변경
// React가 계산한 비교:
// position 0: 'Alice' → 'Charlie' (업데이트)
// position 1: 'Bob' → 'Alice' (업데이트)
// position 2: (새로 추가)
// ❌ 불필요한 업데이트 3회 발생!
/* ✅ Key 있음 - 효율적 */
function List({ items }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li> // ← key 추가!
))}
</ul>
);
}
// items = [{id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}]
// 렌더링:
// <li key="1">Alice</li>
// <li key="2">Bob</li>
// items = [{id: 3, name: 'Charlie'}, {id: 1, name: 'Alice'}, {id: 2, name: 'Bob'}]로 변경
// React가 key로 계산한 비교:
// key "3": (새로 추가) ← 맨 앞에 추가
// key "1": 'Alice' (변경 없음) ← 위치만 이동
// key "2": 'Bob' (변경 없음) ← 위치만 이동
// ✅ 실제로는 하나의 요소만 추가되면 됨!
React의 렌더링 메커니즘을 이해하면 성능 최적화가 훨씬 쉬워진다.
Render Phase와 Commit Phase는 분리되어 있다. Render Phase는 여러 번 실행될 수 있지만, 순수해야 한다.
Virtual DOM은 효율성의 핵심이다. 변경된 부분만 DOM에 적용하므로 성능이 좋다.
Fiber 아키텍처가 우선순위 처리를 가능하게 한다. 이를 통해 React는 사용자 입력에 빠르게 반응할 수 있다.
성능 최적화는 측정이 우선이다. 추측이 아닌 실제 측정을 통해 병목을 찾아야 한다.
최적화는 비용이 크기 때문에, 필요성이 검증되지 않은 최적화는 오히려 해롭다.
반드시 “측정 후”, “명확한 필요가 있을 때만” 최적화하자.