뒤로가기

React Fiber 아키텍처: 동시성 렌더링과 우선순위 기반 업데이트 메커니즘

react

React 16에서 도입된 Fiber 아키텍처는 React의 렌더링 엔진을 근본적으로 재설계한 핵심 개선사항입니다. 이전의 Stack Reconciler는 재귀 기반으로 동작하여 한 번 시작된 렌더링을 중단할 수 없었지만, Fiber는 작업을 중단하고 재개할 수 있는 능력을 갖춰 동시성 렌더링(Concurrent Rendering)을 가능하게 합니다.

이 글에서는 Fiber의 데이터 구조, Render Phase와 Commit Phase의 동작 원리, Time Slicing 메커니즘, 그리고 최적화 전략을 상세히 분석합니다.

Stack Reconciler의 한계

재귀 기반 렌더링의 문제

React 15 이전의 Stack Reconciler는 재귀 함수 기반으로 동작했습니다.

Stack Reconciler의 동작 방식:

// React 15 Stack Reconciler (개념적 구조)
function reconcile(element) {
  // 재귀 시작 → 중단 불가능
  if (element.children) {
    element.children.forEach(child => {
      reconcile(child); // 깊이 우선 탐색
    });
  }
 
  updateDOM(element);
  // 전체 트리를 순회할 때까지 메인 스레드 차단
}

문제점:

  1. 중단 불가능: 재귀 호출 스택이 비워질 때까지 메인 스레드 차단
  2. 프레임 드롭: 대규모 컴포넌트 트리 렌더링 시 16ms(60fps) 초과
  3. 우선순위 없음: 사용자 입력보다 백그라운드 업데이트가 먼저 처리될 수 있음

실제 영향:

// 1000개 항목 렌더링
const LargeList = () => (
  <ul>
    {items.map(item => (
      <ExpensiveComponent key={item.id} data={item} />
    ))}
  </ul>
);
 
// React 15: 약 150ms 동안 메인 스레드 차단 → 화면 멈춤
// React 18 (Fiber): 5ms씩 나눠서 처리 → 부드러운 UI

Fiber Node 데이터 구조

React Element vs Fiber Node

React Element (임시적):

// JSX → React.createElement() → React Element
const element = {
  type: 'div',
  props: {
    className: 'container',
    children: 'Hello'
  }
};
// 렌더링 시마다 재생성, 상태 없음

Fiber Node (영구적):

// Fiber Node의 실제 구조 (간소화)
const fiber = {
  // 타입 정보
  type: 'div',                    // 컴포넌트 타입
  elementType: 'div',
 
  // 상태 저장
  memoizedState: null,            // Hook 상태 저장
  memoizedProps: { className: 'container' },
  pendingProps: { className: 'container' },
 
  // 트리 구조
  return: parentFiber,            // 부모 Fiber
  child: firstChildFiber,         // 첫 번째 자식
  sibling: nextSiblingFiber,      // 다음 형제
 
  // 작업 정보
  flags: Update | Placement,      // 수행할 작업 플래그
  lanes: SyncLane,                // 우선순위
 
  // DOM 참조
  stateNode: divElement,          // 실제 DOM 노드
 
  // 더블 버퍼링
  alternate: workInProgressFiber, // 대체 Fiber (현재/작업 중)
};

Fiber 트리 구조

트리 탐색 메커니즘:

      App
       ↓
     Header
    ↙     ↘
  Logo   Nav
           ↓
         Link

Fiber 트리:
App (return: null, child: Header, sibling: null)
 ↓
Header (return: App, child: Logo, sibling: null)
 ↓
Logo (return: Header, child: null, sibling: Nav)
 →
Nav (return: Header, child: Link, sibling: null)
 ↓
Link (return: Nav, child: null, sibling: null)

탐색 순서:

// Fiber 트리 순회 (깊이 우선)
function performUnitOfWork(fiber) {
  // 1. 현재 Fiber 처리
  beginWork(fiber);
 
  // 2. 자식이 있으면 자식으로
  if (fiber.child) {
    return fiber.child;
  }
 
  // 3. 자식이 없으면 형제로
  while (fiber) {
    completeWork(fiber);
 
    if (fiber.sibling) {
      return fiber.sibling;
    }
 
    // 4. 형제도 없으면 부모로 올라감
    fiber = fiber.return;
  }
}

Render Phase: 비동기 작업 단계

Work Loop 메커니즘

Render Phase는 중단 가능한(interruptible) 작업 루프로 동작합니다.

// React 내부 Work Loop (간소화)
function workLoopConcurrent() {
  // shouldYield(): 메인 스레드를 양보해야 하는지 확인
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
 
  // shouldYield()가 true면 중단 → 브라우저에게 제어권 반환
  // 다음 프레임에서 재개
}
 
function shouldYield() {
  const currentTime = performance.now();
  return currentTime >= deadline; // 5ms 초과 시 true
}

Time Slicing 동작:

프레임 1 (0ms ~ 16ms):
├─ 0-5ms: React 렌더링 (Fiber 1-10 처리)
├─ 5-6ms: 브라우저 이벤트 처리
├─ 6-11ms: React 렌더링 (Fiber 11-20 처리)
└─ 11-16ms: 브라우저 페인팅

프레임 2 (16ms ~ 32ms):
└─ 16-21ms: React 렌더링 (Fiber 21-30 처리)
   ...계속

beginWork: 하향 순회 단계

beginWork는 Fiber 트리를 위에서 아래로 순회하며 변경 사항을 확인합니다.

// beginWork 내부 로직 (개념적)
function beginWork(current, workInProgress) {
  // 1. Props 비교 (얕은 비교)
  if (current !== null &&
      current.memoizedProps === workInProgress.pendingProps &&
      !hasContextChanged()) {
    // Props 변경 없음 → 자식 건너뛰기 (Bailout)
    return bailoutOnAlreadyFinishedWork(current, workInProgress);
  }
 
  // 2. 컴포넌트 타입에 따라 업데이트
  switch (workInProgress.tag) {
    case FunctionComponent:
      return updateFunctionComponent(current, workInProgress);
    case ClassComponent:
      return updateClassComponent(current, workInProgress);
    case HostComponent: // <div>, <span> 등
      return updateHostComponent(current, workInProgress);
  }
}
 
function updateFunctionComponent(current, workInProgress) {
  const Component = workInProgress.type;
  const props = workInProgress.pendingProps;
 
  // 함수 컴포넌트 실행
  const children = Component(props);
 
  // 자식 Fiber 생성/업데이트
  reconcileChildren(current, workInProgress, children);
 
  return workInProgress.child;
}

더티 체크와 Bailout:

// React.memo의 동작 원리
const MemoizedComponent = React.memo(ExpensiveComponent);
 
// beginWork에서 처리
function beginWork(current, workInProgress) {
  if (workInProgress.type._isMemoType) {
    const prevProps = current.memoizedProps;
    const nextProps = workInProgress.pendingProps;
 
    // Props 얕은 비교
    if (shallowEqual(prevProps, nextProps)) {
      // 변경 없음 → 렌더링 건너뛰기
      return bailoutOnAlreadyFinishedWork(current, workInProgress);
    }
  }
 
  // Props 변경됨 → 렌더링 계속
  return updateFunctionComponent(current, workInProgress);
}

completeWork: 상향 순회 단계

completeWork는 트리의 아래에서 위로 순회하며 DOM 노드를 생성/업데이트합니다.

// completeWork 내부 로직 (개념적)
function completeWork(current, workInProgress) {
  const newProps = workInProgress.pendingProps;
 
  switch (workInProgress.tag) {
    case HostComponent: {
      if (current === null) {
        // 1. 신규 DOM 노드 생성 (오프스크린)
        const instance = createInstance(
          workInProgress.type,
          newProps
        );
 
        // 2. 자식 DOM 연결
        appendAllChildren(instance, workInProgress);
 
        workInProgress.stateNode = instance;
 
        // 3. Props 설정
        finalizeInitialChildren(instance, newProps);
      } else {
        // 기존 DOM 노드 업데이트 (플래그만 마킹)
        const instance = workInProgress.stateNode;
        updateHostComponent(current, workInProgress, instance);
      }
 
      // Flags 설정 (Commit Phase에서 사용)
      if (needsUpdate) {
        workInProgress.flags |= Update;
      }
      break;
    }
  }
}

오프스크린 DOM 생성:

// 오프스크린 렌더링 예시
function createInstance(type, props) {
  const element = document.createElement(type);
  // 실제 DOM에 아직 추가되지 않음!
 
  Object.keys(props).forEach(key => {
    if (key === 'children') return;
    element[key] = props[key];
  });
 
  return element; // 메모리에만 존재
}
 
// Commit Phase에서 실제 DOM에 추가
function commitWork(fiber) {
  const parent = fiber.return.stateNode;
  parent.appendChild(fiber.stateNode); // ← 여기서 화면에 반영
}

Commit Phase: 동기 업데이트 단계

Commit Phase는 중단 불가능(uninterruptible) 하며, 실제 DOM을 동기적으로 업데이트합니다.

3단계 처리 과정

// Commit Phase의 3단계
function commitRoot(root) {
  // 준비: Effect 리스트 수집
  const finishedWork = root.finishedWork;
 
  // 1단계: Before Mutation
  commitBeforeMutationEffects(finishedWork);
 
  // 2단계: Mutation (DOM 변경)
  commitMutationEffects(finishedWork);
 
  // 3단계: Layout (useLayoutEffect 실행)
  commitLayoutEffects(finishedWork);
 
  // Passive Effects 스케줄링 (useEffect)
  schedulePassiveEffects(finishedWork);
}

1. Before Mutation 단계:

function commitBeforeMutationEffects(fiber) {
  // getSnapshotBeforeUpdate 실행 (Class Component)
  if (fiber.flags & Snapshot) {
    const instance = fiber.stateNode;
    instance.getSnapshotBeforeUpdate(prevProps, prevState);
  }
}

2. Mutation 단계:

function commitMutationEffects(fiber) {
  const flags = fiber.flags;
 
  // Placement: 새 노드 추가
  if (flags & Placement) {
    const parent = getParentFiber(fiber);
    parent.stateNode.appendChild(fiber.stateNode);
  }
 
  // Update: 기존 노드 업데이트
  if (flags & Update) {
    const instance = fiber.stateNode;
    updateDOMProperties(instance, fiber.memoizedProps, fiber.pendingProps);
  }
 
  // Deletion: 노드 제거
  if (flags & Deletion) {
    const parent = getParentFiber(fiber);
    parent.stateNode.removeChild(fiber.stateNode);
  }
}

3. Layout 단계:

function commitLayoutEffects(fiber) {
  // componentDidMount/Update 실행 (Class Component)
  // useLayoutEffect 실행 (Function Component)
 
  if (fiber.flags & Update) {
    const create = fiber.updateQueue.create;
    const destroy = create(); // useLayoutEffect 콜백 실행
 
    fiber.updateQueue.destroy = destroy; // cleanup 저장
  }
}

Effect 실행 타이밍

useLayoutEffect vs useEffect:

function Example() {
  const [width, setWidth] = useState(0);
  const ref = useRef();
 
  // ❶ Layout Effect: DOM 변경 직후, 페인팅 이전
  useLayoutEffect(() => {
    const measured = ref.current.getBoundingClientRect().width;
    setWidth(measured); // 페인팅 전에 반영 → 깜빡임 없음
  }, []);
 
  // ❷ Passive Effect: 페인팅 이후 (비동기)
  useEffect(() => {
    fetch('/api/data').then(setData); // 화면 업데이트 차단 안 함
  }, []);
 
  return <div ref={ref}>Width: {width}px</div>;
}

실행 순서:

1. Render Phase
   ↓
2. Commit Phase 시작
   ↓
3. Before Mutation (getSnapshotBeforeUpdate)
   ↓
4. Mutation (DOM 변경)
   ↓
5. Layout (useLayoutEffect 실행) ← 동기
   ↓
6. 브라우저 페인팅
   ↓
7. Passive Effects (useEffect 실행) ← 비동기

우선순위 시스템과 Lanes

Lane 기반 우선순위

React 18부터 우선순위는 Lanes 모델을 사용합니다.

// Lane 우선순위 (비트마스크)
const SyncLane          = 0b0000000000000000000000000000001; // 최우선
const InputContinuousLane = 0b0000000000000000000000000000100; // 입력 이벤트
const DefaultLane       = 0b0000000000000000000000000010000; // 기본
const TransitionLane    = 0b0000000000000000000000010000000; // Transition
const IdleLane          = 0b0100000000000000000000000000000; // 유휴 시간
 
// 사용 예시
function scheduleUpdate(fiber, lane) {
  fiber.lanes |= lane; // 비트 OR 연산으로 우선순위 마킹
 
  // 루트까지 전파
  let parent = fiber.return;
  while (parent !== null) {
    parent.childLanes |= lane;
    parent = parent.return;
  }
}

우선순위별 처리:

// 높은 우선순위 작업이 진행 중인 낮은 우선순위 작업 중단
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    // 더 높은 우선순위 업데이트 발생 시
    if (hasHigherPriorityUpdate()) {
      // 현재 작업 중단
      workInProgress = null;
 
      // 높은 우선순위 작업 시작
      performHighPriorityWork();
    }
 
    performUnitOfWork(workInProgress);
  }
}

startTransition을 통한 우선순위 제어

import { startTransition } from 'react';
 
function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
 
  function handleChange(e) {
    const value = e.target.value;
 
    // 높은 우선순위: 입력 즉시 반영
    setQuery(value);
 
    // 낮은 우선순위: 무거운 계산은 나중에
    startTransition(() => {
      const filtered = expensiveFilter(data, value);
      setResults(filtered);
    });
  }
 
  return (
    <>
      <input value={query} onChange={handleChange} />
      <ResultsList results={results} />
    </>
  );
}

실행 흐름:

사용자 입력 "React"
  ↓
① setQuery("React") 실행 (SyncLane)
  → Work Loop: query 업데이트 우선 처리
  → Input 값 즉시 변경 (16ms 내)
  ↓
② startTransition(() => setResults(...)) (TransitionLane)
  → Work Loop: 유휴 시간에 처리
  → 5ms씩 나눠서 expensiveFilter 실행
  → 입력이 계속되면 버려지고 새 작업 시작

더블 버퍼링 (Double Buffering)

Current vs WorkInProgress

React는 두 개의 Fiber 트리를 유지합니다.

Current Tree (화면에 표시 중)     WorkInProgress Tree (작업 중)
       App                              App'
        ↓                                ↓
      Header                           Header'
        ↓                                ↓
      Title                            Title'

current.alternate = workInProgress
workInProgress.alternate = current

더블 버퍼링 동작:

// Render Phase: WorkInProgress 트리 작업
function prepareFreshStack(root) {
  const current = root.current;
 
  // Current의 alternate를 WorkInProgress로 사용
  const workInProgress = current.alternate || createWorkInProgress(current);
 
  workInProgressRoot = root;
  workInProgressRootFiber = workInProgress;
}
 
// Commit Phase: WorkInProgress → Current로 전환
function commitRoot(root) {
  // ... Commit 작업 수행
 
  // 포인터 전환 (O(1) 연산!)
  root.current = root.finishedWork;
 
  // 이제 WorkInProgress가 새로운 Current
}

메모리 효율성:

// Fiber 재사용으로 GC 압력 감소
function createWorkInProgress(current) {
  let workInProgress = current.alternate;
 
  if (workInProgress === null) {
    // 최초 생성
    workInProgress = createFiber(current.type);
  } else {
    // 기존 Fiber 재사용
    workInProgress.pendingProps = current.pendingProps;
    workInProgress.flags = NoFlags;
  }
 
  // 양방향 연결
  workInProgress.alternate = current;
  current.alternate = workInProgress;
 
  return workInProgress;
}

최적화 전략

1. Bailout 조건 활용

// React.memo로 Props 비교
const ExpensiveList = React.memo(({ items }) => {
  console.log('렌더링!');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
});
 
function App() {
  const [count, setCount] = useState(0);
  const items = useMemo(() => [
    { id: 1, name: 'Item 1' },
    { id: 2, name: 'Item 2' }
  ], []); // 빈 의존성 → 항상 동일한 참조
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <ExpensiveList items={items} /> {/* Props 불변 → Bailout */}
    </>
  );
}

beginWork에서 발생:

setCount 호출
  ↓
App Fiber: beginWork 진입
  → count 변경 → 렌더링 필요
  ↓
ExpensiveList Fiber: beginWork 진입
  → Props 비교: items === items (참조 동일)
  → Bailout! (렌더링 건너뛰기)

2. useDeferredValue로 낮은 우선순위 설정

import { useDeferredValue } from 'react';
 
function SearchPage({ query }) {
  const deferredQuery = useDeferredValue(query);
 
  // query: 즉시 업데이트 (입력 필드용)
  // deferredQuery: 지연 업데이트 (검색 결과용)
 
  return (
    <>
      <input value={query} />
      <SearchResults query={deferredQuery} />
    </>
  );
}

내부 동작:

// useDeferredValue 구현 개념
function useDeferredValue(value) {
  const [deferredValue, setDeferredValue] = useState(value);
 
  useEffect(() => {
    startTransition(() => {
      setDeferredValue(value); // TransitionLane으로 스케줄
    });
  }, [value]);
 
  return deferredValue;
}

3. key를 통한 Fiber 재사용 제어

// [주의] 잘못된 사용: 인덱스를 key로 사용
{items.map((item, index) => (
  <Item key={index} data={item} />
))}
// 배열 순서 변경 시 모든 Item 재렌더링
 
// [권장] 올바른 사용: 고유 ID를 key로 사용
{items.map(item => (
  <Item key={item.id} data={item} />
))}
// 순서 변경 시 Fiber 재사용 → DOM 이동만 발생

Fiber 매칭 알고리즘:

// reconcileChildren 내부
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren) {
  let existingChildren = new Map();
 
  // 1. 기존 Fiber를 key로 Map에 저장
  let oldFiber = currentFirstChild;
  while (oldFiber !== null) {
    if (oldFiber.key !== null) {
      existingChildren.set(oldFiber.key, oldFiber);
    }
    oldFiber = oldFiber.sibling;
  }
 
  // 2. 새 Element와 key 매칭
  for (let i = 0; i < newChildren.length; i++) {
    const newChild = newChildren[i];
    const matchedFiber = existingChildren.get(newChild.key);
 
    if (matchedFiber && matchedFiber.type === newChild.type) {
      // Fiber 재사용 (DOM 유지)
      cloneFiber(matchedFiber, newChild.props);
    } else {
      // 새 Fiber 생성
      createFiber(newChild);
    }
  }
}

React DevTools로 Fiber 분석

Profiler로 Render Phase 측정

import { Profiler } from 'react';
 
function onRenderCallback(
  id,
  phase, // "mount" | "update"
  actualDuration, // 렌더링 소요 시간
  baseDuration, // 메모이제이션 없을 때 예상 시간
  startTime,
  commitTime
) {
  console.log(`${id} ${phase}: ${actualDuration}ms`);
}
 
<Profiler id="App" onRender={onRenderCallback}>
  <App />
</Profiler>

DevTools Profiler 탭 사용:

1. React DevTools 설치
2. Profiler 탭 → 녹화 시작
3. 앱 조작
4. 녹화 중지

확인 사항:
- Flamegraph: 컴포넌트별 렌더링 시간
- Ranked: 가장 느린 컴포넌트 순위
- Why did this render?: 렌더링 원인 분석

Components 탭에서 Fiber 확인

Components 탭 → 컴포넌트 선택 → Fiber 아이콘 클릭

확인 가능한 정보:
- Fiber 타입 (FunctionComponent, HostComponent 등)
- Props, State, Hooks
- Rendered by (부모 컴포넌트)

성능 최적화 실전 팁

1. 리렌더링이 반드시 나쁜 것은 아님

function Counter() {
  const [count, setCount] = useState(0);
 
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Render Phase:

  • beginWork: Props/State 비교 → 변경 감지
  • completeWork: 새 Virtual DOM 생성

Commit Phase:

  • Mutation: textContent만 변경 (매우 빠름)

결론: 단순한 컴포넌트의 리렌더링은 오버헤드가 미미합니다.

2. 복잡한 컴포넌트만 최적화

// [권장] 최적화 필요: 무거운 계산
const HeavyChart = React.memo(({ data }) => {
  const processed = useMemo(() => {
    return expensiveDataProcessing(data); // 10ms
  }, [data]);
 
  return <Chart data={processed} />;
});
 
// [주의] 과도한 최적화: 단순한 컴포넌트
const SimpleButton = React.memo(({ onClick }) => {
  return <button onClick={onClick}>Click</button>;
});
// React.memo 비교 비용 > 렌더링 비용

3. 모바일 환경 고려

import { unstable_batchedUpdates } from 'react-dom';
 
// [주의] 여러 번 렌더링 트리거
function handleScroll(e) {
  setScrollY(e.scrollY);
  setIsScrolling(true);
  setLastScrollTime(Date.now());
  // 3번의 렌더링 발생 (모바일에서 배터리 소모)
}
 
// [권장] Batching으로 1번만 렌더링
function handleScroll(e) {
  unstable_batchedUpdates(() => {
    setScrollY(e.scrollY);
    setIsScrolling(true);
    setLastScrollTime(Date.now());
  });
  // 1번의 렌더링만 발생
}

React 18 자동 Batching:

// React 18에서는 자동으로 batching
setTimeout(() => {
  setCount(1);
  setFlag(true);
  // 자동으로 1번만 렌더링 (이전에는 2번)
}, 1000);

결론

React Fiber 아키텍처는 렌더링을 작은 단위로 나누어 중단/재개 가능하게 만들어, 동시성 렌더링의 기반을 제공합니다.

핵심 권장사항:

  1. Fiber의 동작 원리 이해:

    • Render Phase는 비동기 (중단 가능)
    • Commit Phase는 동기 (중단 불가)
    • 더블 버퍼링으로 일관된 UI 보장
  2. 최적화 전략:

    • React.memo: Props 불변 시 Bailout 활성화
    • useMemo/useCallback: 참조 동일성 유지
    • startTransition: 무거운 업데이트 우선순위 낮추기
    • key: Fiber 재사용으로 DOM 조작 최소화
  3. 성능 측정:

    • Profiler로 실제 병목 지점 확인
    • 단순한 컴포넌트는 최적화 불필요
    • 모바일 환경에서는 불필요한 렌더링 방지
  4. Concurrent Features 활용:

    • useDeferredValue: 지연 가능한 업데이트 표시
    • useTransition: 우선순위 낮은 상태 업데이트
    • Suspense: 비동기 데이터 로딩 처리

Fiber는 React의 선언적 API를 유지하면서도 내부적으로 정교한 스케줄링을 수행하여, 복잡한 UI에서도 부드러운 사용자 경험을 제공합니다.

관련 아티클