React는 어떻게 렌더링을 최적화하는가?

Woody·2025년 11월 15일
0

Frontend

목록 보기
3/3

개요

JavaScript의 이벤트 루프를 이해했으니, React가 이를 어떻게 활용하는지 살펴보자. React는 왜 자체 큐를 만들었고, 어떻게 렌더링을 중단하고 재개할 수 있을까?

이 문서는 다음 질문에 답한다:

  • React는 상태 업데이트를 언제 처리하는가?
  • 왜 여러 번의 setState가 한 번의 렌더링으로 처리되는가?
  • React Fiber는 무엇이고 어떻게 동작하는가?
  • useEffect와 useLayoutEffect의 실행 타이밍은 어떻게 다른가?

이 문서를 읽고 나면:

  • React의 Batching 메커니즘을 이해할 수 있다
  • Fiber 아키텍처의 동작 원리를 설명할 수 있다
  • Hook의 실행 타이밍을 정확히 예측할 수 있다

선행 지식:
이 문서는 "JavaScript는 어떻게 싱글 스레드에서 비동기를 처리하는가?"를 읽었다고 가정한다.


1. React의 상태 업데이트는 언제 일어나는가?

예상하기 어려운 동작

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

결과:

  • count는 1만 증가
  • 화면은 1번만 렌더링

왜 이렇게 동작하는가?

핵심: 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로 설정하라"고 요청
};

상태 업데이트는 이벤트 핸들러가 끝난 후 처리된다.


2. React의 Batching: 왜 자체 큐를 만들었는가?

브라우저 Queue를 사용하지 않는 이유

만약 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번 렌더링!

Batching의 장점

불필요한 렌더링 방지:

  • 성능 향상
  • 일관된 화면 상태

실행 흐름:

1. 클릭 이벤트 → Call Stack에 handleClick 올라감
2. setCount 3번 호출 → React 내부 큐에 저장
3. console.log 실행
4. handleClick 종료 → Call Stack 비워짐
5. React가 큐의 업데이트를 처리 → 렌더링 1번

3. React 18의 Automatic Batching

React 17 이전의 한계

const handleClick = async () => {
  await fetch('/api/data');
  setCount(count + 1); // 비동기 이후
  setCount(count + 1); // 비동기 이후
  setCount(count + 1); // 비동기 이후
};

React 17:

  • fetch 이전: batching ✅
  • fetch 이후: batching ❌ (렌더링 3번)

React 18:

  • fetch 이전: batching ✅
  • fetch 이후: batching ✅ (렌더링 1번!)

Microtask 타이밍 활용

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 타이밍인가?

Microtask Queue의 특성:

  • 한 번 확인하면 큐가 빌 때까지 전부 실행
  • Task Queue보다 우선순위 높음
  • React는 이 특성을 활용하여 효율적으로 batching

비교:

// 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의 보호:

  • 50번 연속 렌더링 감지 시 에러 발생
  • "Too many re-renders" 에러 메시지

4. React Fiber: 렌더링을 어떻게 나누는가?

문제 상황

function HeavyComponent() {
  // 10,000개 아이템 렌더링 (1초 걸림)
  const items = Array.from({ length: 10000 }, (_, i) => (
    <ExpensiveItem key={i} />
  ));
  
  return <div>{items}</div>;
}

문제:

  • 렌더링이 1초 동안 Call Stack 점유
  • 화면이 얼어붙음
  • 사용자 입력 무시

해결책: 작업을 나누기

JavaScript에서 배운 방법:

// setTimeout으로 나누기
function renderInChunks() {
  renderChunk(0, 1000);
  setTimeout(() => renderChunk(1000, 2000), 0);
  setTimeout(() => renderChunk(2000, 3000), 0);
}

하지만 React는 더 복잡한 문제를 해결해야 한다:

  1. 우선순위 문제
사용자 클릭 → 즉시 반영 (긴급)
데이터 fetching → 천천히 해도 됨 (비긴급)
애니메이션 → 부드러워야 함 (중요)
  1. 중단 가능성
비긴급 렌더링 50% 진행 중
→ 사용자 클릭! (긴급)
→ 기존 렌더링을 멈추고 클릭 처리해야 함

Fiber: 렌더링 작업을 데이터 구조로

기존 방식 (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));
  }
}

핵심: 함수 호출이 아니라 데이터 순회이므로 중간에 멈출 수 있다.


5. 우선순위 기반 스케줄링

우선순위 레벨

const priorities = {
  Immediate: 1,       // 클릭, 입력 (즉시)
  UserBlocking: 2,    // 호버, 스크롤 (250ms 이내)
  Normal: 3,          // 데이터 페칭 (5초 이내)
  Low: 4,             // 분석, 로깅 (10초 이내)
  Idle: 5             // 오프스크린 콘텐츠 (여유있을 때)
};

Scheduler의 동작

// 낮은 우선순위 작업 시작
scheduler.scheduleCallback(IdlePriority, () => {
  renderHeavyList(); // 조금씩 실행
});

// 중간에 높은 우선순위 작업 발생
scheduler.scheduleCallback(ImmediatePriority, () => {
  updateInput(); // 즉시 실행
});

MessageChannel 활용

React Scheduler가 사용하는 API:

API최소 지연단점
setTimeout4ms너무 느림
requestAnimationFrame16ms애니메이션에만 적합
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: 계속 진행

6. Fiber 트리 순회

Fiber 트리 구조

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 포인터를 저장했다가 나중에 이어서 진행할 수 있다.


7. Render Phase vs Commit Phase

화면 일관성 문제

만약 렌더링 중간에 값이 바뀌면?

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:

  • 사이드 이펙트 가능
  • 한 번만 실행됨
  • 중단 불가

8. Hook의 실행 타이밍

세 가지 실행 시점

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로 예약된 작업

useEffect vs useLayoutEffect

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 측정/수정useLayoutEffectPaint 전 실행으로 깜빡임 방지
데이터 fetchinguseEffectPaint 후 실행으로 블로킹 방지
구독/이벤트 리스너useEffect화면 렌더링에 영향 없음
스크롤 위치 복원useLayoutEffect사용자가 보기 전에 완료

9. 전체 흐름 정리

상태 업데이트부터 렌더링까지

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 이벤트 루프와의 관계

┌─────────────────────────────────────────┐
│         JavaScript 이벤트 루프           │
└─────────────────────────────────────────┘
                   ↓
        ┌──────────────────────┐
        │   Call Stack         │
        │  handleClick 실행     │
        └──────────────────────┘
                   ↓
        ┌──────────────────────┐
        │  Microtask Queue     │
        │  React 렌더링 예약    │
        └──────────────────────┘
                   ↓
┌─────────────────────────────────────────┐
│            React Fiber                  │
│  ┌──────────────┐  ┌─────────────────┐ │
│  │ Render Phase │→ │  Commit Phase   │ │
│  │ (중단 가능)   │  │  (중단 불가)     │ │
│  └──────────────┘  └─────────────────┘ │
└─────────────────────────────────────────┘
                   ↓
        ┌──────────────────────┐
        │   브라우저 Paint      │
        └──────────────────────┘
                   ↓
        ┌──────────────────────┐
        │  Passive Effects     │
        │  useEffect 실행       │
        └──────────────────────┘

10. 핵심 개념 요약

React의 렌더링 최적화

1. Batching

  • 여러 상태 업데이트를 하나로 합침
  • Microtask 타이밍에 렌더링 예약
  • React 18에서 비동기 이후에도 동작

2. Fiber 아키텍처

  • 렌더링 작업을 데이터 구조로 표현
  • 작은 단위로 쪼개서 중단 가능
  • 우선순위 기반 스케줄링

3. Render Phase vs Commit Phase

  • Render: 중단 가능, Virtual DOM 계산
  • Commit: 중단 불가, 실제 DOM 업데이트
  • 화면 일관성 보장

4. Scheduler

  • MessageChannel로 작업 예약
  • 우선순위별 처리
  • 5ms 단위로 시간 체크

5. Hook 타이밍

  • Render Phase: 컴포넌트 함수 실행
  • Commit Phase: useLayoutEffect (동기)
  • Paint 후: useEffect (비동기)

이전 문서와의 연결

JavaScript 이벤트 루프:

  • Call Stack, Task Queue, Microtask Queue
  • 브라우저 렌더링 타이밍
  • 무거운 작업 나누기

React의 활용:

  • Microtask 타이밍으로 Batching
  • MessageChannel로 작업 스케줄링
  • Fiber로 작업을 나누고 우선순위 관리

참고 자료

profile
프론트엔드 개발자로 살아가기

0개의 댓글