React 렌더링: 브라우저 렌더링 위의 React 엔진

SilverAsh·6일 전
0

React

목록 보기
5/5

"React는 UI를 효율적으로 업데이트합니다"라는 말을 자주 듣지만, 정확히 어떻게 효율적일까?

이 글에서는 React가 브라우저의 렌더링 파이프라인 위에서 어떻게 작동하는지, 그 모든 과정을 자세히 알아가 보겠다.


📍 목차

  1. React 렌더링의 개념
  2. React의 두 가지 단계
  3. Render Phase 상세분석
  4. Commit Phase 상세분석
  5. 브라우저 렌더링과의 상호작용
  6. Virtual DOM의 역할

React 렌더링의 개념

먼저 React 렌더링과 브라우저 렌더링의 관계를 명확히 하자.

React 렌더링 ≠ 브라우저 렌더링

React 렌더링: 
└─ React 컴포넌트의 render 함수 실행
└─ JSX를 JavaScript 객체로 변환
└─ Virtual DOM 생성/업데이트

브라우저 렌더링:
└─ DOM 파싱
└─ 레이아웃 계산
└─ 페인팅
└─ 화면 표시

React 렌더링이 일어난다 ≠ 화면이 업데이트된다

React가 렌더링했어도 실제로 DOM이 변경되지 않으면 브라우저는 아무것도 그리지 않는다.


React의 두 가지 단계

React는 렌더링을 두 개의 명확한 단계로 나눈다.

┌─────────────────────────────────────────────────┐
│ 1️⃣ RENDER PHASE (렌더 단계)                       │
│                                                 │
│ - 컴포넌트 함수 실행                                │
│ - JSX 반환                                       │
│ - Virtual DOM 생성/업데이트                       │
│ - 변경사항 계산                                   │
│                                                 │
│ ⏸️  일시 중단 가능 (중단 후 재개 가능)                │
│ 🧵 메인 스레드 블로킹 가능                           │
│ 🔄 여러 번 실행 가능                                │
└─────────────────────────────────────────────────┘
            ↓
      (상태 업데이트 또는 의존성 변경)
            ↓
┌─────────────────────────────────────────────────┐
│ 2️⃣ COMMIT PHASE (커밋 단계)                       │
│                                                 │
│ - Virtual DOM을 실제 DOM에 적용                    │
│ - 레이아웃 사이드 이펙트 실행                        │
│   (useLayoutEffect)                            │
│ - 브라우저 렌더링 트리거                            │
│                                                 │
│ ⏸️  중단 불가능 (반드시 완료)                       │
│ 🧵 메인 스레드 블로킹                              │
│ 🔄 한 번 실행                                    │
└─────────────────────────────────────────────────┘
            ↓
     (브라우저 렌더링 시작)
            ↓
     (useEffect 실행 대기)
            ↓
┌─────────────────────────────────────────────────┐
│ 브라우저 렌더링 파이프라인 실행                        │
│ (스타일 계산 → 레이아웃 → 페인팅 → 컴포지팅)            │
└─────────────────────────────────────────────────┘
            ↓
     (브라우저 렌더링 완료)
            ↓
   (useEffect 실행 - 비동기)

Render Phase 상세분석

렌더 단계가 하는 일

// 간단한 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 내부 자료구조에 저장
*/

렌더 단계의 특징

1. 렌더 단계는 여러 번 실행될 수 있다

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 컴포넌트 렌더 시작
├─ 렌더 단계 완료
├─ 커밋 단계 실행

2. 렌더 단계는 순수해야 한다 (Pure)

/* ❌ 렌더 단계에서 부작용을 일으키면 안 됨 */
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>
  );
}

Render Phase의 핵심: Fiber 아키텍처

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 렌더  │
│                   └────────┘           │
│ 중간에 우선순위 높은 작업이 들어오면           │
│ 현재 작업을 중단하고 그것을 먼저 처리           │
└─────────────────────────────────────────┘

Commit Phase 상세분석

커밋 단계가 하는 일

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');                               │
│   // ← 브라우저 렌더링 후 실행                           │}, []);                                             │
└─────────────────────────────────────────────────────┘

실제 DOM 변경 예제

/* 상태 업데이트 전 */
<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 스케줄 (비동기로 나중에 실행)
*/

브라우저 렌더링과의 상호작용

전체 흐름도: React → Browser → Screen

┌────────────────────────────────────────────────────────────┐
│ 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 값)
*/

Virtual DOM의 역할

Virtual DOM이 필요한 이유

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 업데이트
  // - 포커스, 이벤트 리스너 유지
  // - 필요한 파싱만 수행
}

Virtual 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: '+'
        }
      }
    ]
  }
}

Reconciliation (재조정): 변경사항 계산

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는 사용자 입력에 빠르게 반응할 수 있다.

성능 최적화는 측정이 우선이다. 추측이 아닌 실제 측정을 통해 병목을 찾아야 한다.

최적화는 비용이 크기 때문에, 필요성이 검증되지 않은 최적화는 오히려 해롭다.
반드시 “측정 후”, “명확한 필요가 있을 때만” 최적화하자.

profile
Frontend Developer

0개의 댓글