dev.syw

React가 화면을 갱신하는 내부 메커니즘 — Fiber 아키텍처와 재조정 알고리즘.

렌더링 내부 구조: Fiber와 재조정

입문편에서는 useStateuseEffect를 통해 상태가 바뀌면 화면이 갱신된다는 사실을 배웠습니다. 하지만 React는 실제로 어떻게 이전 화면과 새 화면의 차이를 계산하고, 브라우저 DOM을 최소한으로 수정할까요? 그 답은 Fiber 아키텍처재조정(reconciliation) 알고리즘에 있습니다.

이 레슨은 React 내부 엔진의 동작 원리를 이해하는 것이 목표입니다. 이 원리를 알면 성능 문제가 왜 발생하는지, key가 왜 중요한지, StrictMode가 왜 컴포넌트를 두 번 렌더하는지—그 모든 "왜?"에 자신 있게 답할 수 있게 됩니다.

학습 목표

  • Virtual DOM의 역할과 한계, 그리고 React가 이를 어떻게 활용하는지 이해한다.
  • Fiber 아키텍처가 기존 Stack Reconciler를 대체한 이유와 작업 단위(work unit)의 개념을 설명할 수 있다.
  • 렌더 단계(render phase)커밋 단계(commit phase) 의 차이와 각 단계에서 발생하는 일을 구분한다.
  • key prop이 diffing 알고리즘에 미치는 영향과 재마운트가 발생하는 조건을 정확히 이해한다.
  • StrictMode가 부수효과를 검출하는 방식과 React DevTools Profiler 로 렌더링 흐름을 추적하는 방법을 익힌다.

Virtual DOM과 재조정 알고리즘

Virtual DOM이란 무엇인가

Virtual DOM은 실제 브라우저 DOM의 경량 JavaScript 표현입니다. React는 컴포넌트를 렌더링할 때마다 새로운 Virtual DOM 트리를 메모리 안에서 만들고, 이전 트리와 비교(diffing)하여 실제 DOM 변경을 최소화합니다.

// JSX가 컴파일되면 React.createElement 호출이 된다
// <div className="box"><p>Hello</p></div>
// 위 JSX는 아래와 같은 객체(Virtual DOM 노드)로 변환된다

{
  type: 'div',
  props: {
    className: 'box',
    children: {
      type: 'p',
      props: { children: 'Hello' }
    }
  }
}

중요한 점은 Virtual DOM 자체가 성능의 비결이 아니라는 것입니다. 실제 성능 이득은 "변경된 부분만 찾아 최소한의 DOM 조작을 수행" 하는 diffing 전략에서 옵니다.

재조정 알고리즘의 두 가지 가정

임의의 두 트리를 비교하는 일반 알고리즘은 O(n³)의 복잡도를 가집니다. React는 두 가지 휴리스틱(heuristic)을 도입하여 이를 O(n)으로 줄입니다.

가정내용
타입이 다르면 다른 트리부모 엘리먼트의 타입이 바뀌면 하위 트리 전체를 폐기하고 새로 마운트한다.
key로 자식 식별리스트의 자식은 key를 사용해 이전 렌더와 같은 엘리먼트인지 판별한다.
// ❌ key 없이 리스트를 렌더하면 React는 순서로만 동일성을 판단한다
function BadList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li>{item.name}</li> // key 누락 — 재정렬 시 전체 재렌더
      ))}
    </ul>
  );
}

// ✅ 안정적인 고유 id를 key로 사용한다
function GoodList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

Fiber 아키텍처: 작업 단위와 우선순위

Stack Reconciler의 한계

React 16 이전의 Stack Reconciler는 재조정 작업을 재귀 호출(call stack) 로 수행했습니다. 한 번 시작하면 전체 트리를 다 순회할 때까지 멈출 수 없었기 때문에, 수백 개의 컴포넌트를 가진 앱에서는 메인 스레드가 수십 밀리초 동안 블로킹되어 입력 지연(janky input)이 발생했습니다.

Fiber: 중단 가능한 작업 단위

React 16에서 도입된 Fiber는 각 컴포넌트를 독립적인 작업 단위(work unit)로 표현하는 데이터 구조입니다. 단순화하면 이렇게 생겼습니다.

// Fiber 노드의 핵심 필드 (실제 소스 기반 단순화)
const fiber = {
  type: MyComponent,       // 컴포넌트 타입
  key: null,
  stateNode: domNode,      // 실제 DOM 노드 또는 컴포넌트 인스턴스
  return: parentFiber,     // 부모 Fiber
  child: firstChildFiber,  // 첫 번째 자식 Fiber
  sibling: nextFiber,      // 형제 Fiber
  pendingProps: {},        // 이번 렌더에서 받은 props
  memoizedProps: {},       // 이전 렌더의 props
  memoizedState: {},       // 훅 상태 링크드 리스트
  flags: Update,           // 커밋 시 수행할 작업 플래그 (Update, Placement, Deletion 등)
  lanes: DefaultLane,      // 우선순위 레인
};
JavaScript

Fiber 트리는 링크드 리스트 형태로 구성되기 때문에 순회를 재귀 대신 반복문(work loop)으로 수행할 수 있고, 중간에 멈추었다가 나중에 재개하는 것이 가능합니다.

Work Loop와 스케줄러

React의 Scheduler 패키지는 work loop에 시간을 나누어 줍니다. shouldYield()true를 반환하면 작업을 중단하고 브라우저에 제어권을 돌려준 뒤, 다음 작업을 매크로태스크로 재개 예약합니다.

// 개념적 work loop (실제 React 소스 단순화)
function workLoop() {
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }

  if (workInProgress !== null) {
    // 아직 남은 작업이 있으면 스케줄러가 매크로태스크로 다음 작업을 재개 예약
    scheduleCallback(workLoop);
  } else {
    // 모든 렌더 단계 작업 완료 → 커밋 단계 진입
    commitRoot();
  }
}
JavaScript

우선순위 레인(Lanes)

React 18에서는 비트마스크 기반의 Lanes 시스템으로 우선순위를 관리합니다. 높은 우선순위 업데이트(예: 키 입력)가 들어오면 낮은 우선순위 작업(예: 데이터 패칭 후 화면 전환)을 중단하고 먼저 처리합니다.

import { startTransition } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  function handleChange(e) {
    // ✅ 입력값 업데이트는 긴급(SyncLane) — 즉시 반영
    setQuery(e.target.value);

    // ✅ 검색 결과 업데이트는 전환(TransitionLane) — 양보 가능
    startTransition(() => {
      setResults(search(e.target.value));
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      <ResultList items={results} />
    </>
  );
}

💡 TIP startTransition의 동작 원리는 2강 "동시성 렌더링과 전환 우선순위"에서 더 깊이 다룹니다. 이번 레슨에서는 Fiber가 우선순위를 가진 작업 단위를 스케줄링하는 엔진이라는 점을 이해하는 데 집중하세요.

렌더 단계와 커밋 단계

React의 업데이트 처리는 두 개의 뚜렷한 단계로 분리됩니다.

렌더 단계 (Render Phase)

렌더 단계는 순수하고 부수효과가 없어야 하는 단계입니다. React는 이 단계에서 Fiber 트리를 순회하며 "무엇이 변했는지"를 계산합니다.

  • 컴포넌트 함수(또는 render 메서드)를 호출한다.
  • 새 Virtual DOM과 이전 Virtual DOM을 비교(diffing)한다.
  • 변경이 필요한 Fiber 노드에 플래그(flags)를 표시한다. (예: Update, Placement, Deletion)
  • 실제 DOM을 건드리지 않는다.
  • 중단·재개·폐기가 가능하다. (Concurrent 모드에서)
// 렌더 단계에서 실행되는 코드 예시
// 컴포넌트 함수 본문 + 렌더 중 실행되는 훅의 일부

function Counter({ initialCount }) {
  // useState의 초기화 로직 — 렌더 단계에서 실행
  const [count, setCount] = useState(initialCount);

  // useMemo 계산 — 렌더 단계에서 실행
  const doubled = useMemo(() => count * 2, [count]);

  // 반환된 JSX는 새 Fiber 트리를 만들기 위해 사용됨
  return <div>{doubled}</div>;
}

⚠️ 주의 렌더 단계는 중단·재실행될 수 있으므로, 컴포넌트 함수 본문에서 직접 API 요청을 보내거나 로컬 스토리지를 쓰는 등의 부수효과를 일으키면 안 됩니다. 이러한 작업은 반드시 useEffect 안에서 수행하세요.

커밋 단계 (Commit Phase)

커밋 단계는 렌더 단계에서 계산된 결과를 실제 DOM에 적용하는 단계입니다. 이 단계는 동기적으로 실행되며 중단할 수 없습니다.

커밋 단계는 세 하위 단계로 나뉩니다.

하위 단계설명
Before MutationgetSnapshotBeforeUpdate 호출, 스크롤 위치 등 DOM 읽기
MutationDOM 노드 삽입·수정·삭제 (flags 기반으로 실행)
LayoutuseLayoutEffect 실행, ref 업데이트
import { useEffect, useLayoutEffect, useRef } from 'react';

function AnimatedBox() {
  const boxRef = useRef(null);

  // ✅ useLayoutEffect: DOM 변경 직후, 브라우저 페인트 이전에 동기 실행
  // 레이아웃 측정이나 스크롤 위치 조정에 사용
  useLayoutEffect(() => {
    const { width } = boxRef.current.getBoundingClientRect();
    console.log('DOM 반영 직후 너비:', width); // 정확한 값
  });

  // ✅ useEffect: 브라우저 페인트 이후 비동기 실행
  // 데이터 패칭, 구독 등 부수효과에 사용
  useEffect(() => {
    console.log('화면에 페인트된 이후');
  });

  return <div ref={boxRef} className="box" />;
}
[업데이트 흐름 요약]

setState() 호출
    ↓
Scheduler가 작업 예약
    ↓
[렌더 단계] (중단 가능, 반복 가능)
  컴포넌트 함수 호출 → Fiber 트리 비교 → flags 표시
    ↓
[커밋 단계] (동기, 중단 불가)
  Before Mutation → Mutation (DOM 조작) → Layout
    ↓
브라우저 페인트
    ↓
useEffect 실행 (비동기)

key가 Diffing에 미치는 영향

key의 동작 원리

React는 리스트를 비교할 때 key를 사용해 "이 자식이 이전과 동일한 엘리먼트인가?"를 판단합니다. key가 같으면 기존 Fiber를 재사용(update)하고, key가 다르면 이전 Fiber를 폐기하고 새로 생성(mount)합니다.

// items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }, { id: 3, name: 'C' }]
// 위 배열에서 id=1 항목을 삭제하면?

// ❌ index를 key로 사용할 때
// 삭제 후: [{ id: 2, name: 'B' }, { id: 3, name: 'C' }]
// 이전:  key=0(A)  key=1(B)  key=2(C)
// 이후:  key=0(B)  key=1(C)
// → React는 key=0, key=1을 '같은 항목'으로 보고 내용만 업데이트
// → key=2가 사라졌으므로 C 제거
// → B, C 각각 불필요한 업데이트 발생 + 내부 state는 초기화되지 않고
//    이전 위치의 상태(A의 상태)가 엉뚱한 항목에 남는다 (상태 누수/오매칭)

function IndexKeyList({ items }) {
  return (
    <ul>
      {items.map((item, index) => (
        <ListItem key={index} item={item} /> // ❌ 재정렬·삭제 시 버그 가능
      ))}
    </ul>
  );
}

// ✅ 안정적인 id를 key로 사용할 때
// 이전:  key=1(A)  key=2(B)  key=3(C)
// 이후:  key=2(B)  key=3(C)
// → key=1 Fiber 폐기 (A 언마운트), key=2·3은 재사용
// → 정확하고 최소한의 DOM 변경

function IdKeyList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <ListItem key={item.id} item={item} /> // ✅
      ))}
    </ul>
  );
}

key를 이용한 의도적 재마운트

key는 최적화 도구이기도 하지만, 의도적으로 컴포넌트를 완전히 초기화하는 기법으로도 사용됩니다.

function ProfilePage({ userId }) {
  // ✅ userId가 바뀔 때마다 UserProfile을 완전히 새로 마운트
  // 내부 state, ref, effect가 모두 초기화됨
  // useEffect 내 cleanup 로직 없이 깔끔하게 처리 가능
  return <UserProfile key={userId} userId={userId} />;
}

// 같은 타입의 컴포넌트라도 key가 바뀌면 React는
// 이전 Fiber를 폐기하고 새 Fiber를 생성한다 (재마운트).

⚠️ 주의 key는 형제 노드 사이에서만 유일하면 됩니다. 전체 앱에서 전역 유일성은 필요하지 않습니다. 또한 key는 prop으로 자식 컴포넌트에 전달되지 않습니다 (props.key는 항상 undefined).

StrictMode와 부수효과 검출

StrictMode가 두 번 렌더하는 이유

개발 모드에서 <React.StrictMode>로 감싸인 컴포넌트는 렌더 단계 함수(컴포넌트 함수, useState 초기화 함수, useMemo·useReducer 계산 함수)를 의도적으로 두 번 호출합니다.

이것은 버그가 아닙니다. 렌더 단계가 중단·재개 가능한 Concurrent 모드에서는 컴포넌트 함수가 여러 번 실행될 수 있습니다. StrictMode는 이 상황을 개발 환경에서 시뮬레이션하여 부수효과(side effect)를 포함한 컴포넌트를 조기에 발견하도록 도와줍니다.

// ❌ 렌더 단계에서 부수효과를 일으키는 잘못된 코드
function BadCounter() {
  // 컴포넌트 함수 본문에서 직접 외부 변수를 변경
  // StrictMode에서 두 번 실행되면 count가 2씩 증가해 버그가 드러남
  externalCount++;
  return <div>{externalCount}</div>;
}

// ✅ 부수효과는 useEffect 안에서만 실행
function GoodCounter() {
  useEffect(() => {
    externalCount++;
    return () => { externalCount--; }; // cleanup도 올바르게 구현
  }, []);
  return <div>{externalCount}</div>;
}

StrictMode에서 useEffect는 두 번 실행됩니다(마운트 → cleanup → 재마운트). 이 동작은 cleanup 함수가 올바르게 구현되었는지 검증합니다.

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    // ✅ cleanup이 없으면 StrictMode에서 interval이 두 개 실행됨
    return () => clearInterval(id);
  }, []);

  return <div>{seconds}초</div>;
}

💡 TIP StrictMode의 이중 실행은 개발 모드(development)에서만 발생합니다. npm run build로 만든 프로덕션 빌드에서는 한 번만 실행됩니다.

React DevTools Profiler로 렌더링 흐름 추적

Profiler 설치와 기본 사용

React DevTools를 브라우저 확장 프로그램으로 설치한 뒤, DevTools 패널에서 Profiler 탭을 선택합니다.

  1. "Record" 버튼을 클릭해 프로파일링 시작.
  2. 앱에서 인터랙션(클릭, 입력 등)을 수행.
  3. "Stop" 버튼을 클릭해 종료.
  4. 결과 화면에서 각 커밋(commit)별 렌더링 시간과 컴포넌트별 렌더 원인을 확인.

Profiler API로 프로그래밍 방식 측정

코드 안에서 직접 렌더 성능을 측정하려면 <Profiler> 컴포넌트를 사용합니다.

import { Profiler, useState } from 'react';

function onRenderCallback(
  id,           // Profiler의 id prop
  phase,        // "mount" 또는 "update"
  actualDuration,  // 이번 렌더에 걸린 실제 시간 (ms)
  baseDuration,    // 메모이제이션 없이 렌더 시 예상 시간 (ms)
  startTime,    // 렌더 시작 타임스탬프
  commitTime,   // 커밋 타임스탬프
) {
  // 성능 모니터링 서비스로 데이터 전송 가능
  console.log(`[${id}] ${phase}: ${actualDuration.toFixed(2)}ms`);
}

function App() {
  const [count, setCount] = useState(0);

  return (
    <Profiler id="Counter" onRender={onRenderCallback}>
      <div>
        <p>Count: {count}</p>
        <button onClick={() => setCount(c => c + 1)}>증가</button>
      </div>
    </Profiler>
  );
}

Flamegraph 읽는 법

Profiler의 Flamegraph(불꽃 그래프)에서 각 막대의 너비는 해당 컴포넌트가 렌더링에 소요한 시간을 나타냅니다.

  • 회색 막대: 이번 커밋에서 렌더링되지 않은 컴포넌트 (재사용).
  • 노란색/주황색 막대: 렌더링 시간이 길수록 진한 색.
  • "Why did this render?" 패널: DevTools 설정에서 "Record why each component rendered"를 활성화하면 props/state 변경 원인을 확인할 수 있음.
// 렌더 원인을 추적할 때 유용한 패턴: 커스텀 훅으로 로깅
import { useEffect, useRef } from 'react';

function useWhyDidYouRender(componentName, props) {
  const previousProps = useRef(props);

  useEffect(() => {
    const changedProps = Object.entries(props).filter(
      ([key, value]) => previousProps.current[key] !== value
    );

    if (changedProps.length > 0) {
      console.log(`[${componentName}] 변경된 props:`, Object.fromEntries(changedProps));
    }

    previousProps.current = props;
  });
}

// 사용 예
function ExpensiveList({ items, onSelect }) {
  useWhyDidYouRender('ExpensiveList', { items, onSelect });
  // ...
}

💡 TIP why-did-you-render 라이브러리는 이 패턴을 전체 앱에 자동으로 적용해 주는 도구입니다. 불필요한 리렌더를 찾는 데 매우 유용합니다.

요약

  • React는 Virtual DOM 비교(diffing)를 통해 최소한의 DOM 변경만 수행하며, 이 알고리즘은 타입 일치key 기반 식별이라는 두 가지 휴리스틱으로 O(n) 복잡도를 달성한다.
  • Fiber는 각 컴포넌트를 독립적인 작업 단위로 표현하며, Scheduler와 협력하여 작업을 중단하고 우선순위에 따라 재개하는 것이 가능하다.
  • 렌더 단계는 중단 가능하고 부수효과가 없어야 하며, 커밋 단계는 동기적이고 중단 불가능하다. 이 분리가 Concurrent 모드의 핵심이다.
  • key는 diffing 성능의 핵심이며, 안정적인 고유 id를 사용해야 한다. 의도적으로 key를 바꾸면 컴포넌트를 완전히 재마운트시킬 수 있다.
  • StrictMode는 렌더 단계를 의도적으로 두 번 실행해 부수효과를 조기에 발견하며, 이는 개발 모드 전용 동작이다.
  • React DevTools Profiler의 Flamegraph와 <Profiler> 컴포넌트를 활용하면 어느 컴포넌트가, 왜, 얼마나 오래 렌더링되었는지 정확히 추적할 수 있다.

연습문제

  1. 다음 컴포넌트에서 key={index}를 사용할 때 발생할 수 있는 구체적인 문제를 설명하고, 올바른 코드로 수정하세요.
function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <TodoItem key={index} todo={todo} />
      ))}
    </ul>
  );
}

힌트 TodoItem이 내부적으로 useState로 편집 상태를 관리한다고 가정하세요.

  1. 아래 컴포넌트는 StrictMode 환경에서 실행할 때 콘솔에 "API 호출"이 두 번 출력됩니다. 왜 그런지 설명하고, 의도대로 한 번만 호출되도록 수정하세요.
function DataLoader({ url }) {
  const [data, setData] = useState(null);

  // 문제 있는 코드
  fetch(url).then(r => r.json()).then(setData);

  return <div>{JSON.stringify(data)}</div>;
}

힌트 렌더 단계와 커밋 단계의 차이, useEffect의 역할을 생각해 보세요.

  1. 다음 요구사항을 만족하는 UserCard 컴포넌트를 작성하세요: 사용자 ID(userId)가 변경될 때마다 컴포넌트가 완전히 초기화(재마운트)되어야 하며, 컴포넌트 내부에는 이름을 편집하는 useState가 포함되어야 합니다. 이 컴포넌트를 사용하는 부모 ProfilePage도 함께 작성하세요.

힌트 부모에서 key prop을 어떻게 설정하는지가 핵심입니다.

  1. <Profiler> 컴포넌트를 사용하여, 버튼을 클릭할 때마다 렌더 단계의 소요 시간을 콘솔에 출력하는 간단한 앱을 작성하세요. onRender 콜백에서 phase"mount"인 경우와 "update"인 경우를 구분하여 다른 메시지를 출력하세요.

힌트 <Profiler>onRender 콜백 시그니처를 참고하세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

React.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.