JavaScript의 이벤트 루프를 이해했으니, React가 이를 어떻게 활용하는지 살펴보자. React는 왜 자체 큐를 만들었고, 어떻게 렌더링을 중단하고 재개할 수 있을까?
이 문서는 다음 질문에 답한다:
이 문서를 읽고 나면:
선행 지식:
이 문서는 "JavaScript는 어떻게 싱글 스레드에서 비동기를 처리하는가?"를 읽었다고 가정한다.
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
console.log('Before:', count); // 0
setCount(count + 1);
console.log('After 1:', count); // ?
setCount(count + 1);
console.log('After 2:', count); // ?
setCount(count + 1);
console.log('After 3:', count); // ?
};
return <button onClick={handleClick}>{count}</button>;
}
실제 출력:
Before: 0
After 1: 0
After 2: 0
After 3: 0
결과:
핵심: setCount 호출 직후에 count가 바뀌지 않는다.
const handleClick = () => {
setCount(count + 1); // count = 0, 그래서 0 + 1 = 1
setCount(count + 1); // count = 0, 그래서 0 + 1 = 1
setCount(count + 1); // count = 0, 그래서 0 + 1 = 1
// 세 번 다 "1로 설정하라"고 요청
};
상태 업데이트는 이벤트 핸들러가 끝난 후 처리된다.
만약 Task Queue를 사용했다면:
setCount(1); // Task 1: count를 1로 설정 → 렌더링
setCount(2); // Task 2: count를 2로 설정 → 렌더링
setCount(3); // Task 3: count를 3로 설정 → 렌더링
// 총 3번 렌더링!
React 자체 큐를 사용하면:
setCount(1); // React 큐에 추가
setCount(2); // React 큐에 추가
setCount(3); // React 큐에 추가
// 합쳐서 처리 → 최종적으로 3으로 설정
// 총 1번 렌더링!
불필요한 렌더링 방지:
실행 흐름:
1. 클릭 이벤트 → Call Stack에 handleClick 올라감
2. setCount 3번 호출 → React 내부 큐에 저장
3. console.log 실행
4. handleClick 종료 → Call Stack 비워짐
5. React가 큐의 업데이트를 처리 → 렌더링 1번
const handleClick = async () => {
await fetch('/api/data');
setCount(count + 1); // 비동기 이후
setCount(count + 1); // 비동기 이후
setCount(count + 1); // 비동기 이후
};
React 17:
React 18:
React 18의 메커니즘:
// React 내부 동작 (의사코드)
let isScheduled = false;
const updateQueue = [];
function scheduleUpdate(update) {
updateQueue.push(update);
if (!isScheduled) {
isScheduled = true;
// Microtask 타이밍에 업데이트 스케줄링
scheduleMicrotask(() => {
flushUpdates(); // 모든 업데이트를 한번에 처리
isScheduled = false;
});
}
}
실행 흐름:
1. setCount 호출 → React 큐에 추가
2. scheduleUpdate() → Microtask 타이밍에 렌더링 예약
3. setCount 또 호출 → React 큐에 추가
4. scheduleUpdate() → 이미 예약됨, 스킵
5. setCount 또 호출 → React 큐에 추가
6. Call Stack 비워짐
7. Microtask Queue 실행 → 모든 업데이트 한번에 처리
Microtask Queue의 특성:
비교:
// setTimeout 사용 시 (Task Queue)
setCount(1); // Task 1 등록
setCount(2); // Task 2 등록
setCount(3); // Task 3 등록
// Task 1 실행 → 렌더링 → Task 2 실행 → 렌더링 → Task 3 실행 → 렌더링
// Microtask 타이밍 활용 시
setCount(1); // React 큐에 추가
setCount(2); // React 큐에 추가
setCount(3); // React 큐에 추가
// Microtask 타이밍에 스케줄링
// Call Stack 비움 → Microtask 실행 → 모든 업데이트 한번에 처리
function InfiniteUpdate() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // 무한 루프!
}, [count]);
}
React의 보호:
function HeavyComponent() {
// 10,000개 아이템 렌더링 (1초 걸림)
const items = Array.from({ length: 10000 }, (_, i) => (
<ExpensiveItem key={i} />
));
return <div>{items}</div>;
}
문제:
JavaScript에서 배운 방법:
// setTimeout으로 나누기
function renderInChunks() {
renderChunk(0, 1000);
setTimeout(() => renderChunk(1000, 2000), 0);
setTimeout(() => renderChunk(2000, 3000), 0);
}
하지만 React는 더 복잡한 문제를 해결해야 한다:
사용자 클릭 → 즉시 반영 (긴급)
데이터 fetching → 천천히 해도 됨 (비긴급)
애니메이션 → 부드러워야 함 (중요)
비긴급 렌더링 50% 진행 중
→ 사용자 클릭! (긴급)
→ 기존 렌더링을 멈추고 클릭 처리해야 함
기존 방식 (React 15):
function render() {
renderA();
renderB();
renderC();
// 함수 호출 스택 → 중간에 멈출 수 없음
}
Fiber 방식 (React 16+):
// 렌더링 작업을 객체로 표현
const fiber = {
type: 'div',
child: {
type: 'span',
sibling: {
type: 'button'
}
}
};
function performWork(fiber, timeRemaining) {
let currentFiber = fiber;
while (currentFiber && timeRemaining() > 0) {
processOneFiber(currentFiber); // 작은 단위로 처리
currentFiber = getNextFiber(currentFiber);
}
if (currentFiber) {
// 시간 부족 → 다음번에 이어서
scheduleWork(() => performWork(currentFiber, timeRemaining));
}
}
핵심: 함수 호출이 아니라 데이터 순회이므로 중간에 멈출 수 있다.
const priorities = {
Immediate: 1, // 클릭, 입력 (즉시)
UserBlocking: 2, // 호버, 스크롤 (250ms 이내)
Normal: 3, // 데이터 페칭 (5초 이내)
Low: 4, // 분석, 로깅 (10초 이내)
Idle: 5 // 오프스크린 콘텐츠 (여유있을 때)
};
// 낮은 우선순위 작업 시작
scheduler.scheduleCallback(IdlePriority, () => {
renderHeavyList(); // 조금씩 실행
});
// 중간에 높은 우선순위 작업 발생
scheduler.scheduleCallback(ImmediatePriority, () => {
updateInput(); // 즉시 실행
});
React Scheduler가 사용하는 API:
| API | 최소 지연 | 단점 |
|---|---|---|
| setTimeout | 4ms | 너무 느림 |
| requestAnimationFrame | 16ms | 애니메이션에만 적합 |
| requestIdleCallback | 불확실 | Safari 미지원 |
| MessageChannel | ~0ms | ✅ 빠르고 안정적 |
MessageChannel 사용:
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = () => {
performWork(); // 예약된 작업 실행
};
function scheduleWork() {
port.postMessage(null); // Task Queue에 추가
}
동작 흐름:
1. 높은 우선순위 작업 발생
2. MessageChannel.postMessage()
3. Task Queue에 추가 (거의 즉시)
4. 현재 Task 끝나면 실행
5. 렌더링 진행 (작은 단위로)
6. 5ms 지났거나 긴급 작업 발생?
→ Yes: 중단하고 다시 예약
→ No: 계속 진행
function App() {
return (
<div>
<Header />
<HeavyList /> {/* 10,000개 아이템 */}
<Footer />
</div>
);
}
Fiber 트리:
App (Fiber)
├─ div (Fiber)
├─ Header (Fiber)
├─ HeavyList (Fiber)
│ ├─ Item 1 (Fiber)
│ ├─ Item 2 (Fiber)
│ ├─ ...
│ └─ Item 10000 (Fiber)
└─ Footer (Fiber)
1. App Fiber 처리 (1ms)
2. div Fiber 처리 (1ms)
3. Header Fiber 처리 (2ms)
4. HeavyList Fiber 처리 시작
5. Item 1-100 처리 (5ms)
→ 시간 체크: 5ms 지남
→ 현재 위치 저장: "Item 100까지 완료"
→ MessageChannel로 다음 작업 예약
→ Task 종료
6. [렌더링 기회] ← 화면 업데이트 가능
7. 다음 Task 시작
8. Item 101-200 처리 (5ms)
→ 긴급 작업 체크
→ 사용자가 버튼 클릭!
9. HeavyList 렌더링 중단
10. 버튼 클릭 처리 (높은 우선순위)
11. HeavyList 렌더링 재개 (Item 201부터)
핵심: Fiber 포인터를 저장했다가 나중에 이어서 진행할 수 있다.
만약 렌더링 중간에 값이 바뀌면?
1. <p>{count}</p> 렌더링 → count = 5
2. 중단!
3. 사용자가 버튼 클릭 → count = 6
4. 재개
5. <button> 렌더링 → count = ?
p는 5를 보여주는데 button은 6을 보여주면 화면이 일관성 없어진다.
1. Render Phase (중단 가능)
// Virtual DOM 계산만 함 (실제 화면 반영 X)
<p>5</p> 계산
→ 중단!
→ count가 6으로 변경
→ 기존 작업 버림
→ 처음부터 다시 계산
<p>6</p> 계산
<button>6</button> 계산
→ 완료
2. Commit Phase (중단 불가)
// 실제 DOM에 한번에 반영
<p>6</p> → DOM 업데이트
<button>6</button> → DOM 업데이트
// 이 단계는 매우 빠르고 중단 안됨
시각화:
[Render Phase - 중단 가능]
┌─────────────────────────────────┐
│ Fiber 트리 순회하며 계산 │
│ 5ms 단위로 쪼개짐 │
│ 중간에 긴급 작업 있으면 중단 │
│ 값이 변하면 처음부터 다시 │
└─────────────────────────────────┘
↓ 완료되면
[Commit Phase - 중단 불가]
┌─────────────────────────────────┐
│ 실제 DOM에 한번에 반영 │
│ 매우 빠름 (보통 16ms 이내) │
│ 사용자는 일관된 화면 봄 │
└─────────────────────────────────┘
Render Phase:
Commit Phase:
function Component() {
const [count, setCount] = useState(0);
console.log('A: 렌더링 중');
useLayoutEffect(() => {
console.log('C: Layout Effect');
});
useEffect(() => {
console.log('B: Effect');
});
return <div>{count}</div>;
}
실행 순서: A → C → B
[Render Phase]
├─ console.log('A: 렌더링 중')
│ └─ Virtual DOM 계산
│ └─ 여러 번 실행될 수 있음
[Commit Phase]
├─ DOM 업데이트
├─ useLayoutEffect 실행 (동기)
│ └─ console.log('C: Layout Effect')
│ └─ 화면 Paint 전에 실행
└─ Commit 완료
[Paint] ← 사용자가 화면 봄
[Passive Effects]
└─ useEffect 실행 (비동기)
└─ console.log('B: Effect')
└─ MessageChannel로 예약된 작업
useLayoutEffect (동기):
useLayoutEffect(() => {
const height = divRef.current.offsetHeight;
divRef.current.style.height = height * 2 + 'px';
// Paint 전에 실행 → 깜빡임 없음
});
useEffect (비동기):
useEffect(() => {
fetch('/api/data').then(setData);
// Paint 후에 실행 → 화면 블로킹 안함
});
선택 기준:
| 상황 | Hook | 이유 |
|---|---|---|
| DOM 측정/수정 | useLayoutEffect | Paint 전 실행으로 깜빡임 방지 |
| 데이터 fetching | useEffect | Paint 후 실행으로 블로킹 방지 |
| 구독/이벤트 리스너 | useEffect | 화면 렌더링에 영향 없음 |
| 스크롤 위치 복원 | useLayoutEffect | 사용자가 보기 전에 완료 |
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
useEffect(() => {
console.log('Effect:', count);
});
return <button onClick={handleClick}>{count}</button>;
}
완전한 실행 흐름:
1. 버튼 클릭 → Call Stack에 handleClick
2. setCount 3번 호출 → React 내부 큐에 저장
3. handleClick 종료 → Call Stack 비워짐
4. Microtask 타이밍에 예약된 콜백 실행
└─ React가 큐 확인: [1, 1, 1]
└─ 최종 값 결정: count = 1
5. [Render Phase 시작] (중단 가능)
├─ Counter 함수 실행
├─ Virtual DOM 계산
└─ 완료
6. [Commit Phase 시작] (중단 불가)
├─ 실제 DOM 업데이트 (<button>1</button>)
└─ useLayoutEffect 있으면 실행
7. [Paint]
└─ 사용자가 화면에서 1 확인
8. [Passive Effects]
└─ useEffect 실행
└─ console.log('Effect:', 1)
┌─────────────────────────────────────────┐
│ JavaScript 이벤트 루프 │
└─────────────────────────────────────────┘
↓
┌──────────────────────┐
│ Call Stack │
│ handleClick 실행 │
└──────────────────────┘
↓
┌──────────────────────┐
│ Microtask Queue │
│ React 렌더링 예약 │
└──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ React Fiber │
│ ┌──────────────┐ ┌─────────────────┐ │
│ │ Render Phase │→ │ Commit Phase │ │
│ │ (중단 가능) │ │ (중단 불가) │ │
│ └──────────────┘ └─────────────────┘ │
└─────────────────────────────────────────┘
↓
┌──────────────────────┐
│ 브라우저 Paint │
└──────────────────────┘
↓
┌──────────────────────┐
│ Passive Effects │
│ useEffect 실행 │
└──────────────────────┘
1. Batching
2. Fiber 아키텍처
3. Render Phase vs Commit Phase
4. Scheduler
5. Hook 타이밍
JavaScript 이벤트 루프:
React의 활용: