리액트의 Fiber를 모르는 Chill guy일 때

드뮴·2025년 1월 18일
100

🐾 리액트

목록 보기
1/3
post-thumbnail

이전에 리액트의 내부 동작을 구현하기 위해 리액트 동작을 공부한 적이 있었다. 당시 시간이 부족해서 Fiber의 개념에 대해 제대로 공부하지 않고 동작 구현을 할 때 Fiber 개념을 도입하지 않았었다.

최신 리액트에서 Fiber는 핵심 개념이고, 실제 최신 리액트의 내부 동작을 이해하기 위해 Fiber 개념 공부가 필요하다는 걸 깨달았다. 그래서 실제 Fiber 노드 코드도 확인해보고 도입한 이유도 공부해보았다. 그리고 실제 렌더링 과정이 어떻게 이루어지는지 이때 Fiber 노드를 어떻게 이용하는지도 공부해보았다.


목차

최신 리액트에서 도입한 Fiber
ㅤFiber란 무엇일까?
ㅤFiber를 생성하는 createFiber 함수
ㅤFiber를 도입하게 된 이유

리액트의 렌더링 과정 살펴보기
ㅤ렌더링이란 무엇일까?
ㅤ이중 버퍼링 기법
ㅤ초기 렌더링
ㅤ리렌더링
ㅤ실제 코드로 알아보는 초기 렌더링과 리렌더링


최신 리액트에서 도입한 Fiber

Fiber란 무엇일까?

리액트 16에서 새롭게 도입된 재조정 엔진이다. Fiber는 리액트 컴포넌트의 정보를 담고 있는 자바스크립트 객체다.

  • Fiber는 리액트 컴포넌트에 대한 작업 단위를 나타내는 내부 객체다.
  • 각 리액트 엘리먼트마다 대응되는 Fiber 노드가 있으며, 이는 컴포넌트 상태와 DOM을 추적한다.

Fiber는 리액트의 작업 단위를 나타내는 특별한 객체다. 실제 DOM 노드, 컴포넌트의 인스턴스에 대응되며 렌더링 작업을 관리하는데 필요한 정보를 담고 있다. 웹 애플리케이션의 UI는 하나의 트리 구조인데, 리액트는 이 UI 트리의 각 노드에 대해 Fiber 노드를 만들어 관리한다.

그렇다면 Fiber 노드의 구조는 어떻게 되어있는지 실제 코드를 통해 알아보았다.

Fiber 코드 뜯어보기

실제 코드에서 __DEV__ 플래그가 있는 부분은 지웠다. 개발 모드와 프로덕션 모드를 구분하는 것인데, 개발 과정에서 디버깅, 문제해결을 돕고 성능 최적화에 도움이 되는 정보 제공 및 잠재적 버그 발견을 위해 있는 것이라 Fiber를 분석할 때 굳이 필요 없을거 같아 지웠다.

function FiberNode(
  this: $FlowFixMe,
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null;

  // Fiber
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  this.ref = null;
  this.refCleanup = null;

  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;
  this.memoizedState = null;
  this.dependencies = null;

  this.mode = mode;

  // Effects
  this.flags = NoFlags;
  this.subtreeFlags = NoFlags;
  this.deletions = null;

  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  this.alternate = null;
}

Fiber 노드에는 위 코드와 같이 많은 정보를 담고 있다. 하나씩 살펴보자.

instance 속성

  • tag: Fiber의 유형을 나타낸다. 숫자값이 들어가는데 함수형 컴포넌트면 FunctionComponent, 클래스 컴포넌트라면 ClassComponent로 표현한다.
    • tag에 들어가는 값은 HostRoot(루트 Fiber로 ReactDOM.render()의 컨테이너), HostText(텍스트 노드), HostComponent(div, span 등) 등 다양한 값이 있다.
  • key: 리액트에서 컴포넌트를 구분하기 위한 고유 식별자이다.
  • elementType: 리액트 엘리먼트의 원본 타입을 저장한다. div 태그라면 'div', 커스텀 컴포넌트 App 컴포넌트라면 App 함수가 저장된다.
  • type: 실제 작업에 사용되는 타입이다.
  • stateNode: Fiber와 관련된 실제 인스턴스를 가리킨다. DOM 노드, 클래스 컴포넌트의 인스턴스 등이 있다.

elementType과 type의 차이는 무엇일까?

기본적인 경우 둘은 차이가 없다. 그러나 고차 컴포넌트(HOC)를 사용하면 차이가 발생한다.

function withLogging(WrappedComponent) {
  return function LoggedComponent(props) {
    return <WrappedComponent {...props}/>;
  }
}

const LoggedButton = withLogging(Button);

<LoggedButton>클릭</LoggedButton>

elementType === Button
type === LoggedComponent
  • 위 예시를 보면 elementType은 Button이다. elementType은 우리가 사용하는 컴포넌트를 저장한다. elementType은 원본 컴포넌트의 참조를 유지하는 것이 목적이다.
  • type은 LoggedComponent를 저장하는데, 실제 렌더링되는 컴포넌트를 참조하는 것이 목적이다.

elementType은 실제 원본 컴포넌트인 Button을 가리키고, type은 실제 렌더링을 담당하는 컴포넌트를 가리킨다.

Fiber 트리 구조 속성

  • return: 부모 Fiber로 작업이 완료된 후 돌아갈 뜻으로 return을 사용한다.
  • child: 첫번째 자식 Fiber를 가리킨다.
  • sibling: 다음 형제 Fiber를 가리킨다.
  • index: 형제들 사이에서의 인덱스 번호다.
  • ref: 리액트 ref 객체를 저장한다.

왜 첫번째 자식만 저장할까?

A 컴포넌트의 자식이 1, 2, 3이 있다면 Fiber에서는 자식 1만 child에 저장한다. 모든 자식에 대해 배열을 유지하는게 아닌 최소한의 참조만 저장하는 것이다. 그렇다면 자식 1의 sibling을 통해 그 다음 자식을 찾을 수 있다.

상태 관련 속성

  • pendingProps: 아직 처리되지 않은 새로운 props를 나타낸다.
  • memoizedProps: 마지막 렌더링에 사용된 props를 나타낸다.
  • updateQueue: 상태 업데이트, 콜백 등의 대기열이다.
  • memoizedState: 마지막 렌더링에 사용된 state이다.
  • dependencies: context, events 등의 의존성 정보다.
  • mode: Fiber의 동작 모드(Concurrent, Sync 등)를 나타낸다.

Effect 관련 속성

  • flags: Fiber에 필요한 작업인 생성, 업데이트, 삭제 등을 표시한다.
  • subtreeFlags: 자식들에 대한 작업에 대해 표시한다.
  • deletions: 삭제될 자식들의 목록이다.
  • lanes: 작업들의 우선순위를 나타낸다.
  • childLanes: 자식들의 작업 우선순위를 나타낸다.
  • alternate: 다른 트리에 대응되는 Fiber이다.

최신 리액트에는 current 트리workInProgress 트리 2가지를 사용한다.
간단하게만 말하자면 현재 화면에 렌더링된 UI를 표현하는 건 current 트리이고, 모든 변경 작업을 하는 작업용 트리는 workInProgress 트리다. 즉, workInProgress 트리에서 작업이 끝나면 이를 current 트리로 바꾸어주게 된다.
이를 통해 현재 UI의 안정성을 보장하고, 불완전한 UI 노출이 방지된다.


Fiber를 생성하는 createFiber 함수

위에서 FiberNode 생성자 코드를 보았는데, FiberNode 생성자로 바로 Fiber 노드를 생성하는게 아닌 CreateFiber를 호출해서 생성한다.

const createFiber = function(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
): Fiber {
  return new FiberNode(tag, pendingProps, key, mode);
};

function createFiberFromElement(
  element: ReactElement,
  mode: TypeOfMode,
  lanes: Lanes,
): Fiber {
  let owner = null;
  const type = element.type;
  const key = element.key;
  const pendingProps = element.props;
  const fiber = createFiberFromTypeAndProps(
    type,
    key,
    pendingProps,
    owner,
    mode,
    lanes,
  );
    
  return fiber;
}

function createFiberFromTypeAndProps(
  type: any, // React$ElementType
  key: null | string,
  pendingProps: any,
  owner: null | ReactComponentInfo | Fiber,
  mode: TypeOfMode,
  lanes: Lanes,
): Fiber {
  let fiberTag = FunctionComponent;
  let resolvedType = type;
  if (typeof type === 'function') {
    if (shouldConstruct(type)) {
      fiberTag = ClassComponent;
    }
  } else if (typeof type === 'string') {
    if (supportsResources && supportsSingletons) {
      const hostContext = getHostContext();
      fiberTag = isHostHoistableType(type, pendingProps, hostContext)
        ? HostHoistable
        : isHostSingletonType(type)
          ? HostSingleton
          : HostComponent;
    } else if (supportsResources) {
      const hostContext = getHostContext();
      fiberTag = isHostHoistableType(type, pendingProps, hostContext)
        ? HostHoistable
        : HostComponent;
    } else if (supportsSingletons) {
      fiberTag = isHostSingletonType(type) ? HostSingleton : HostComponent;
    } else {
      fiberTag = HostComponent;
    }
  } else {
    getTag: switch (type) {
      case REACT_FRAGMENT_TYPE:
        return createFiberFromFragment(pendingProps.children, mode, lanes, key);
      case REACT_STRICT_MODE_TYPE:
        fiberTag = Mode;
        mode |= StrictLegacyMode;
        if (disableLegacyMode || (mode & ConcurrentMode) !== NoMode) {
          mode |= StrictEffectsMode;
          if (
            enableDO_NOT_USE_disableStrictPassiveEffect &&
            pendingProps.DO_NOT_USE_disableStrictPassiveEffect
          ) {
            mode |= NoStrictPassiveEffectsMode;
          }
        }
        break;
      case REACT_PROFILER_TYPE:
        return createFiberFromProfiler(pendingProps, mode, lanes, key);
      case REACT_SUSPENSE_TYPE:
        return createFiberFromSuspense(pendingProps, mode, lanes, key);
      case REACT_SUSPENSE_LIST_TYPE:
        return createFiberFromSuspenseList(pendingProps, mode, lanes, key);
      case REACT_OFFSCREEN_TYPE:
        return createFiberFromOffscreen(pendingProps, mode, lanes, key);
      case REACT_LEGACY_HIDDEN_TYPE:
        if (enableLegacyHidden) {
          return createFiberFromLegacyHidden(pendingProps, mode, lanes, key);
        }
      case REACT_VIEW_TRANSITION_TYPE:
        if (enableViewTransition) {
          return createFiberFromViewTransition(pendingProps, mode, lanes, key);
        }
      case REACT_SCOPE_TYPE:
        if (enableScopeAPI) {
          return createFiberFromScope(type, pendingProps, mode, lanes, key);
        }
      case REACT_TRACING_MARKER_TYPE:
        if (enableTransitionTracing) {
          return createFiberFromTracingMarker(pendingProps, mode, lanes, key);
        }
      default: {
        if (typeof type === 'object' && type !== null) {
          switch (type.$$typeof) {
            case REACT_PROVIDER_TYPE:
              if (!enableRenderableContext) {
                fiberTag = ContextProvider;
                break getTag;
              }
            case REACT_CONTEXT_TYPE:
              if (enableRenderableContext) {
                fiberTag = ContextProvider;
                break getTag;
              } else {
                fiberTag = ContextConsumer;
                break getTag;
              }
            case REACT_CONSUMER_TYPE:
              if (enableRenderableContext) {
                fiberTag = ContextConsumer;
                break getTag;
              }
            case REACT_FORWARD_REF_TYPE:
              fiberTag = ForwardRef;
              break getTag;
            case REACT_MEMO_TYPE:
              fiberTag = MemoComponent;
              break getTag;
            case REACT_LAZY_TYPE:
              fiberTag = LazyComponent;
              resolvedType = null;
              break getTag;
          }
        }
        let info = '';
        let typeString;
        fiberTag = Throw;
        pendingProps = new Error(
          'Element type is invalid: expected a string (for built-in ' +
            'components) or a class/function (for composite components) ' +
            `but got: ${typeString}.${info}`,
        );
        resolvedType = null;
      }
    }
  }

  const fiber = createFiber(fiberTag, pendingProps, key, mode);
  fiber.elementType = type;
  fiber.type = resolvedType;
  fiber.lanes = lanes;

  return fiber;
}

createFiber, createFiberFromElement, createFiberFromTypeAndProps만 살펴볼 것이다.

createFiber는 그냥 FiberNode 생성자를 호출해서 return해주는 역할을 한다. 실제 최초 렌더링 시에는 createFiberFromElement가 호출되고 그 안에서 createFiberFromTypeAndProps를 호출하고 이 안에서 createFiber를 호출한다.

  • createFiberFromElement 함수는 createFiberFromTypeAndProps를 호출할 때, element의 타입과 키, pendingProps를 추출해서 전달한다.
  • createFiberFromTypeAndProps에서는 전달 받은 타입과 속성들에 따라 tag를 결정하는 로직을 가지고 있다. 그리고 마지막에는 결정한 정보를 바탕으로 createFiber를 호출해서 생성한 Fiber 노드를 return한다.

왜 이렇게 동작할까?

  • createFiber는 기본적인 Fiber 노드를 생성하는 함수이고, createFiberFromElement는 리액트 엘리먼트로부터 Fiber 노드를 생성하는 더 높은 수준의 함수다.
  • 리액트 엘리먼트가 있을 때 createFiber를 바로 호출하는 구조가 아닌 이유는 아래와 같다.

jsx 함수를 통해 리액트 엘리먼트가 만들어지면 다음과 같은 구조일 것이다.

const element = {
  type: 'div',
  props: { className: 'container', children: [] },
  key: 'unique-key',
  ...
};

위 엘리먼트를 바로 createFiber를 호출해 Fiber 노드를 생성하려하면 tag를 직접 지정해서 호출해야한다. 즉, 어떤 태그인지 호출할 때 알아야하는 것이다.

그러나 createFiberFromElement는 위 엘리먼트를 전달해서 createFiberFromTypeAndProps를 호출해서 어떤 tag인지 정해준다. 함수를 이렇게 다 분리해둔 이유는 단일 책임 원칙을 따르기 위해서다.


Fiber를 도입하게 된 이유

이전 리액트에서는 스택 조정자로, 하나의 스택에 렌더링에 필요한 작업이 쌓이면 이 스택에서 꺼내 동기적으로 작업이 이루어졌다. 대규모 컴포넌트 트리 조정 작업이 시작되면 이 작업이 완료될 때까지 메인 스레드가 차단되었다. 이로 인해서 프레임 드랍이 발생하고 사용자 입력에 대한 반응이 지연되었다.

프레임 드랍이란?

특정 순간에 I/O나 리소스 로드, 연산 처리 등으로 인해 평균 프레임 이하로 떨어져서 끊기는 듯한 현상을 말한다. 예를 들어 60fps는 1초에 60장의 정지 화면을 보여주는데 만약 위와 같은 이유로 평균 프레임 이하로 떨어져서 60장보다 적은 화면을 보여주게 되면 사용자는 화면이 끊기는 현상을 느끼게 된다.

Fiber를 도입하기 전의 문제

  1. 렌더링 작업 중단 불가
    재조정 프로세스가 시작되면 전체 컴포넌트 트리를 순회해야했고, 이 과정을 중간에 중단할 수 없었다.
  2. 우선순위 처리 불가능
    모든 업데이트가 동일한 우선순위로 처리되어, 긴급한 업데이트와 중요한 업데이트를 구분할 수 없었다.
  3. 비동기 렌더링 지원 X
    렌더링 작업을 여러 프레임에 걸쳐 분산시킬 수 없어 복잡한 업데이트 시 성능 문제가 발생했다.

Fiber를 도입하고 달라진 점

  1. 작업 분할과 중단 가능
    이전에는 재조정 과정이 한 번에 완료되어야했다. Fiber는 작업을 작은 단위로 나누어 중단하고 다시 시작하는게 가능하다.

    이전에는 재귀적으로 처리해서 콜 스택이 비워질 때까지(모든 하위 컴포넌트를 처리할 때까지) 중간에 멈출 수 없었다. Fiber는 이 문제를 해결하기 위해 작업을 나중에 이어할 수 있는 자체 스케줄러를 사용한다.

  2. 우선 순위 기반 처리
    이전에는 모든 업데이트가 동일한 우선순위로 처리되었다. Fiber는 다양한 우선순위 레벨을 지원한다.
  3. 더 나아진 에러 처리
    이전에는 에러가 발생하면 전체 애플리케이션이 영향을 받았다. Fiber는 에러 바운더리를 통해 우아한 실패 처리가 가능하다.

    이전에는 에러가 발생하면 한 컴포넌트의 에러가 전체 애플리케이션을 중단시켰다. 그러나 Fiber 아키텍처에서는 각 노드가 자체적 에러 상태를 추적할 수 있다. Fiber는 각 노드의 상태와 생명주기를 독립적으로 관리하기 때문에 가능하다.

작업 분할 프로세스

리액트 Fiber 아키텍처에서는 작업을 작은 단위로 나누어 처리한다고 했다. 이 과정을 좀 더 자세히 알아보았다.

  • 리액트는 전체 컴포넌트 트리를 Fiber 노드로 구성된 연결리스트로 변환한다.
    • 각 Fiber 노드는 하나의 작업 단위를 나타내고, 다음에 처리할 작업에 대한 참조를 포함한다.
  • 연결리스트를 따라가며 각 노드의 작업을 처리한다. 그런데 할당된 작업 시간이 끝나면, 현재 작업을 중단하고 다음 노드 위치를 저장한다.
    • 이후 브라우저가 작업이 없다면 저장된 위치부터 작업을 다시 시작한다.

이전 리액트에서는 스택 조정자 렌더링 시스템으로, 컴포넌트 트리를 순회할 때 각 컴포넌트 처리를 콜 스택에 추가했고 하위 컴포넌트가 처리될 때까지 이 프로세스는 계속 되었다.
이 방식은 렌더링 프로세스를 중단할 수 없었고, 브라우저 메인 스레드를 차단하는 방식이었기 때문에 사용자 입력이나 애니메이션에 즉각적 반응을 할 수 없게 만들었다.


리액트의 렌더링 과정 살펴보기

Fiber 아키텍처가 도입된 리액트의 렌더링 과정은 어떻게 변화했을까? 이에 대해서도 공부해보았다.

렌더링이란 무엇일까?

렌더링은 리액트 컴포넌트의 현재 속성(props)과 상태(state)를 기반으로 UI를 계산하고 업데이트하는 전체 프로세스를 의미한다.

렌더링의 과정은 크게 3단계로 이루어진다.

1. 리액트가 렌더링이 필요한 시점 인식

초기 마운트상태 업데이트와 같은 이벤트에 의해 렌더링이 필요하다고 인식한다.

2. 렌더 단계

리액트가 컴포넌트를 호출해서 변경사항을 계산한다. 이때 가상 DOM을 사용하여 실제 DOM에 적용할 변경사항을 결정한다. 이 단계는 비동기적으로 처리될 수 있고, 리액트는 필요한 경우 이 작업을 일시 중단하거나 폐기할 수 있다.

3. 커밋 단계

계산된 변경사항을 실제 DOM에 적용한다. 이 단계는 동기적으로 처리되어 사용자 인터페이스의 일관성을 보장한다. DOM 업데이트가 완료된 후에는 useLayoutEffect와 같은 동기적 효과가 실행되고, 이어서 useEffect와 같은 비동기적 효과가 처리된다.

더 자세한 내용은 아래에 정리해두었다.


이중 버퍼링 기법

리액트에서는 2개의 트리를 사용하여 이중 버퍼링 기법을 구현한다.

  • current 트리는 현재 화면에 보이는 UI를 나타내는 Fiber 트리다.
  • workInProgress 트리는 업데이트를 준비하는 작업용 트리다.

새로운 변경 사항이 생기면 workInProgress 트리에 변경 사항을 반영하고, 업데이트가 끝나면 current 트리와 교체한다. 이렇게 구현하여 사용자는 불완전한 UI를 보지 않게 된다.


리액트 렌더링 과정은 초기 렌더링과 리렌더링으로 나누어볼 수 있다. 초기 렌더링은 아무것도 없는 상태이기 때문에 트리를 생성하는 과정이지만, 리렌더링은 상태가 변경되어 변경된 부분을 찾아 그 부분만 업데이트하는 과정이다.

초기 렌더링

1. 초기 마운트

ReactDOM.createRoot(container).render(<App />);

위 코드가 호출되면 리액트는 애플리케이션의 기반 구조를 설정한다. 이때 2개의 중요한 객체가 생성된다.

  • createRoot를 통해 FiberRoot 노드를 생성한다.
  • HostRoot Fiber 노드가 생성된다.
[DOM 레벨]
div#root (containerInfo)
    │
[React 레벨]
FiberRoot
    │
    ├─ current → HostRootFiber
    │                  │
    │                  ├─ stateNode → FiberRoot 참조
    │                  │
    │                  └─ child → AppFiber
    │                                 │
    └─ finishedWork                   └─ 하위 컴포넌트

FiberRoot
리액트 인스턴스 전체를 관리하는 컨테이너다. 렌더링 스케줄링과 우선순위를 관리하고, 현재 화면에 보이는 트리와 작업 중인 트리인 current 트리와 workInProgress 트리를 추적한다. 전역적인 상태 관리를 한다.

HostRootFiber
실제 렌더링 트리의 시작점이다. 첫 번째 실제 Fiber 노드로, 컴포넌트의 부모 역할을 하며, 업데이트의 시작점이다.

2. 렌더링 프로세스 시작

render()가 호출되면 리액트는 렌더링 프로세스를 시작한다.

이때 2가지의 중요한 작업이 이루어진다.

  1. 최초의 renderRootSync/Concurrent를 실행한다. 동기 모드로 실행할지 동시 모드로 실행할지 결정한다.
  2. workInProgress 트리 생성을 시작한다.

renderRootSync와 renderRootConcurrent
리액트 렌더링 모드를 나타내는데, Sync 모드는 전통적 방식으로 렌더링이 시작되면 완료될 때까지 중단 없이 진행된다. Concurrent 모드는 렌더링 작업을 작은 단위로 나누어 처리하고 우선순위가 높은 작업이 들어오면 현재 렌더링을 잠시 중단하고 나중에 재개할 수 있다.

3. 컴포넌트 처리 단계

beginWork 단계에서 리액트는 컴포넌트 트리를 위에서 아래로 순회하며 각 컴포넌트를 처리한다.

각 컴포넌트에 대해서

  • 컴포넌트의 렌더링 함수를 실행해 리액트 엘리먼트를 가져온다.
  • 해당 엘리먼트를 기반으로 새로운 Fiber 노드를 생성한다.
  • 자식 컴포넌트들에 대한 Fiber를 생성하고 연결한다.

이미 생성된 workInProgress 트리의 노드를 처리하는 함수다. 트리를 순회하며 각 노드의 자식을 처리하고 필요한 업데이트를 수행한다. 렌더링 로직을 실행하고 자식 Fiber 노드를 생성하거나 업데이트한다.

4. DOM 준비 단계

completeWork 단계에서는 실제 DOM 업데이트를 위한 준비가 이루어진다.

  • 각 Fiber 노드에 대응하는 DOM 노드가 생성된다.
  • className, style 등의 다양한 속성이 설정된다.
  • onClick 등의 이벤트 리스너가 연결된다.
  • 텍스트 콘텐츠가 설정된다.

completeWork 단계에서는 DOM 업데이트 준비를 하는데, 실제 DOM을 수정하는 것이 아닌 커밋 단계에서 필요한 모든 정보를 수집한다.

  1. Fiber 노드에 해당하는 DOM 노드의 인스턴스를 생성하고 Fiber 노드의 stateNode 속성에 저장한다.
  2. DOM 노드에 필요한 속성인 className, style, 이벤트 리스너 등을 계산한다.
  3. 필요한 DOM 조작을 나타내는 Update, Delete와 같은 flags를 설정한다.

beginWork와 completeWork의 순서

beginWork는 트리를 아래로 순회하며 컴포넌트 변경사항을 계산한다. 아래로 순회하는 이유는 부모 컴포넌트의 변경이 자식 컴포넌트에 어떤 영향을 미치는지 파악하기 위해서다.

completeWork는 트리를 위로 올라가며 실행된다. 이는 자식 컴포넌트 변경사항이 모두 처리된 후 부모 컴포넌트의 DOM 업데이트를 준비하는 것이 효율적이기 때문이다.

  • 한 번에 하나의 경로만 메모리에 유지하며, 효율적으로 메모리를 사용한다.
  • 부모에서 자식으로 데이터 흐름을 보장한다.
  • 변경사항을 배치로 처리하여 최적화된 DOM 업데이트가 가능하다.

5. 최종 커밋 단계

commitRoot 단계에서는 모든 준비된 변경사항이 실제 DOM에 적용된다.

  • 준비된 DOM 노드들이 실제 문서에 삽입된다.
  • ref가 설정된다.
  • useLayoutEffect 훅이 실행된다.
  • useEffect 등의 부수 효과가 실행된다.

리렌더링

1. 리렌더링 트리거

리액트의 리렌더링은 아래와 같은 이유로 발생한다.

  • 상태(state) 변경
  • 속성(props) 변경
  • 부모 컴포넌트의 리렌더링

useState를 사용해 상태를 변경하거나, 부모 컴포넌트로부터 새로운 props를 받거나 상위 컴포넌트의 리렌더링 등의 이유가 있다.

2. 업데이트 처리 준비

리액트는 발생한 업데이트를 즉시 처리하지 않고, 우선 업데이트 큐에 추가한다. 이때 각 업데이트에는 우선순위가 할당된다. 예를 들어 사용자 입력에 의한 업데이트는 높은 우선순위를, 데이터 가져오기 같은 백그라운드 작업은 낮은 우선순위를 받게 된다.

이때 진행되는 일을 정리하면 아래와 같다.

  • 발생한 업데이트를 업데이트 큐에 추가한다.
  • 우선순위를 설정하고 렌더링 스케줄링을 한다.

3. 작업용 트리 준비

현재 화면에 표시되는 UI를 나타내는 current 트리가 이미 존재하므로, 리액트는 이 트리를 기반으로 새로운 workInProgress 트리를 생성한다.

완전히 새로운 트리를 만드는게 아니라 current 트리를 복제하여 시작한다.

4. 변경사항 계산

beginWork 단계에서 리액트는 트리를 순회하며 각 컴포넌트의 변경사항을 확인한다.

Diffing 알고리즘을 사용하여 최소한의 필요한 업데이트만을 계산한다. 예를 들어 props가 변경되지 않은 컴포넌트는 기존 결과를 재사용한다.

5. DOM 업데이트 준비

completeWork 단계에서는 실제 DOM 업데이트를 위한 준비가 이루어진다.

  • 각 Fiber 노드에 대해 필요한 DOM 업데이트를 나타내는 flags가 설정된다.
    • 텍스트가 변경되었다면 flags는 Update, 새로운 요소가 추가되었다면 flags는 Placement가 설정된다.

Fiber 노드에 대해서 노드를 새로 생성해야하는지, 업데이트해야 하는지, 삭제해야 하는지 등을 flags에 설정한다.

6. 변경사항 적용

commitRoot 단계에서 모든 준비된 사항이 실제 DOM에 적용된다. 이전 단계에서 설정된 flags를 기반으로 필요한 DOM 업데이트만 수행된다.

또한 useEffect와 같은 효과들도 이 단계에서 필요한 경우에만 실행된다.


실제 코드로 알아보는 초기 렌더링과 리렌더링

위 페이지는 좋아요 버튼을 누르면 좋아하는 사람 옆의 숫자가 올라간다.

위 예시에서 초기 마운트 시 트리는 어떻게 구성되는지, 그리고 버튼을 누르면 리렌더링은 어떤 컴포넌트에 발생하는지 알아보았다. 코드는 아래와 같다.

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

  return (
    <div className="App">
      <Title />
      <Content count={count} />
      <LikeButton setCount={setCount} />
    </div>
  );
}
  • Title 컴포넌트는 리액트 렌더링 알아보기 텍스트만 담겨있다.
  • Content에는 떡볶이 좋아하는 사람 텍스트와 좋아요 개수 정보를 담고 있다.
  • LikeButton은 좋아요를 누르면 setCount를 통해 count를 증가시킨다.

초기 렌더링

왼쪽의 트리는 위에 작성한 코드를 트리 구조로 그렸다.

  • 초기 렌더링을 시작하게 되면 createRoot가 호출된다.
  • 이때 2가지 FiberRoot 객체와 HostRootFiber가 생성된다.

  • render 함수가 호출되면 리액트는 전달 받은 App 컴포넌트를 기반으로 새로운 Fiber 노드를 생성한다.
  • 생성된 App의 Fiber 노드는 HostRootFiber의 child 속성으로 연결된다.

render 함수가 호출되며 렌더링이 시작되고 App 컴포넌트를 기반으로 새로운 Fiber 노드가 생성되고 연결된다. 이때부터 HostRootFiber에 대한 beginWork가 실행된다.

  • render가 호출되고 App 컴포넌트의 beginWork 과정에서 함수 컴포넌트가 실행된다.
  • 이때 useState 훅이 호출되며 초기화가 이루어진다.

Fiber 노드에는 memoizedState라는 속성을 가지고 있는데, 이 속성에 훅의 상태가 저장된다. useState가 호출될 때 초기값을 memoizedState에 저장하고 useState는 상태 값과 상태를 업데이트하는 함수를 반환한다.
이런 초기화 과정은 초기 렌더링 시에만 발생하고 이후 리렌더링 시에는 memoizedState에 저장된 값을 사용한다.

  • beginWork를 자식이 없을 때까지 내려가서 실행하고, 더 이상 자식이 없다면 completeWork를 실행한다.

beginWork는 내려가며 각 노드를 방문하면서 해당 컴포넌트 렌더링 로직을 실행하고, 자식 컴포넌트의 Fiber 노드를 생성하고 업데이트한다. 이 과정은 자식이 없는 노드 즉, 트리의 가장 끝 노드에 도달할 때까지 계속된다.
가장 끝 노드에 도달하면 리액트는 completeWork 단계로 전환해서 올라가며 해당 노드에 필요한 DOM 업데이트 준비를 한다.

  • Title 컴포넌트의 completeWork가 끝나면 Title 컴포넌트의 형제 노드인 Content 컴포넌트로 이동해서 beginWork를 시작한다.
  • 마찬가지로 자식이 없을 때까지 beginWork를 수행하고, completeWork를 하며 트리 위로 올라온다.
  • 그 후 형제 컴포넌트 LikeButton도 똑같이 수행한 후 형제 노드도 없다면 부모 노드인 App 컴포넌트로 이동해서 completeWork를 수행한다.

HostRootFiber까지 completeWork를 완료하게되면?

workInProgress 트리를 FiberRoot의 finishedWork 속성에 할당한다.
finishedWork는 작업이 완료된 workInProgress 트리다.
이는 렌더 단계에서 만들어진 새로운 Fiber 트리가 이제 커밋될 준비가 되었음을 나타낸다.

위 과정까지가 렌더 단계(Render Phase)였고, 리액트는 이제 commitRoot 함수를 호출해 커밋 단계(Commit Phase)를 시작한다.

이때 finishedWork 트리를 사용하여 3가지 단계를 수행한다.

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

  return (
    <div className="App">
      <Title />
      <Content count={count} />
      <LikeButton setCount={setCount} />
    </div>
  );
}
  1. beforeMutation: DOM 변경 전 필요한 작업을 수행
  2. mutation: 실제 DOM 업데이트
    • div.App 엘리먼트를 생성하고, 다음으로 Title 컴포넌트에 해당하는 h1 태그를 생성하고 "리액트 렌더링 알아보기" 텍스트를 추가한다.
    • 다음으로 Content 컴포넌트에 대해 p.main 태그를 생성하고 텍스트와 span 태그를 추가하며 실제 DOM 업데이트를 진행한다.
  3. layout: DOM 업데이트 이후 작업을 처리
    • ref나 useLayoutEffect 효과를 처리한다.

레이아웃 효과는 커밋 단계 마지막 부분에서 처리된다. DOM이 업데이트 되고, 그 다음 ref가 설정되고 마지막으로 useLayoutEffect 콜백이 실행된다. 이 순서는 레이아웃 관련 코드(DOM의 레이아웃인 배치나 크기, 위치와 관련된 계산이나 조작을 수행하는 코드)가 최신 DOM 상태에 접근할 수 있도록 보장한다.

커밋이 완료되면 finishedWork는 새로운 current 트리가 되고, 이전의 current 트리는 다음 업데이트를 위해 보관된다.

리렌더링

이제 위에 작성했던 코드로 리렌더링을 살펴볼 생각이다.

여기서 좋아요 버튼을 누르면 리렌더링되는 컴포넌트는 어떤 컴포넌트일까? 직접 코드를 실행해서 리렌더링 되는 부분이 어딘지 살펴보았다.

정답은 모든 컴포넌트가 리렌더링된다.

왜 모든 컴포넌트가 리렌더링되는지 정리해보았다.

  • count 변수는 App 컴포넌트에서 관리하고 있다. 그런데 사용자가 버튼을 누르면 count 상태가 변경되기 때문에 App 컴포넌트는 리렌더링된다.
  • Content 컴포넌트에는 좋아요를 클릭한 횟수인 count를 props로 받기 때문에 props가 변경되므로 리렌더링된다.
  • 마지막으로 Title과 LikeButton 컴포넌트는 왜 리렌더링 될까?
    • 이 경우에는 부모 컴포넌트인 App 컴포넌트가 리렌더링되기 때문에 리렌더링된다.

Title, LikeButton 컴포넌트는 리렌더링을 막을 수 있지 않을까?
Title, LikeButton 변경되는 상태가 없는데 부모가 리렌더링되기 때문에 리렌더링된다. 이를 막기 위해서 대표적으로는 React.memo를 사용한다. props가 변경되지 않은 경우 컴포넌트 리렌더링을 건너뛸 수 있는 것이다.

React.memo를 사용하지 않고 위 코드에서 리렌더링을 막는 방법은 또 없을까?

상태를 필요한 곳에 가깝게 위치시키는 것이다.

  • App 컴포넌트에서 count 상태를 관리하는게 아닌 count 상태를 보여주는 컴포넌트에서 count를 관리하도록 구조를 변경한다.
  • 이때 Content는 count를 props로 전달받아 리렌더링되고, LikeButton도 부모인 CountSection이 리렌더링되므로 리렌더링 된다.
    • LikeButton은 setState 함수를 props로 전달받기 때문에 다른 함수와 달리 생명주기 동안 참조를 유지하므로 useCallback을 사용할 필요가 없다.
    • LikeButton을 React.memo를 사용해서 props 변경이 없을 때 리렌더링을 막아주도록 한다.

📌 React.memo, useCallback을 사용해서 리렌더링을 막는게 항상 최선의 결과를 보장하는 것은 아니다. 무분별한 사용은 오히려 성능 저하를 일으킬 수 있다.
React.memo는 이전 props와 새로운 props를 비교하는 작업이 필요하고, useCallback은 함수를 메모이제이션하기 때문에 메모리를 사용한다.

  • 컴포넌트가 자주 리렌더링되거나 props 비교가 복잡한 객체는 최적화가 오히려 성능 저하를 일으킬 수 있다.
  • 작은 컴포넌트나 간단한 렌더링 로직의 경우는, 리액트의 기본적인 렌더링 성능이 충분히 최적화되어 있기 때문에 오히려 복잡성만 증가시킬 수 있다.

효과적인 최적화를 위해서는 성능 문제가 실제로 존재하는지 확인 후, React DevTools의 Profiler를 사용해서 렌더링 성능을 측정하고, 실제 최적화가 필요한 부분을 식별하는게 중요하다.

렌더링 최적화는 간단하게만 이렇게 정리하고 이제 기존 코드의 리렌더링 과정을 살펴볼 생각이다.

이전에 초기 렌더링을 통해 트리를 생성했고, current 트리는 위와 같다.

사용자가 좋아요 버튼을 눌렀을 때 리렌더링 과정은 어떻게 될까?

  • 사용자가 좋아요 버튼을 누르면 LikeButton 컴포넌트의 setCount가 호출된다.
  • count 상태 업데이트를 리액트는 업데이트 큐에 추가하고 우선순위를 설정한다.
    • 해당 업데이트는 사용자 상호작용에 의한 업데이트이므로 높은 우선순위가 부여된다.
    • 리액트는 우선순위를 기반으로 렌더링을 스케줄링한다.
  • 렌더 단계가 시작되면, 위의 current 트리를 기반으로 workInProgress 트리를 생성한다.
    • 이때 가능한 많은 기존 Fiber 노드를 재사용하려고 한다.
    • HostRootFiber부터 beginWork를 실행한다. 초기 렌더링에서 봤던 순서대로 진행된다.
    • beginWork 과정에서 함수 컴포넌트가 다시 실행되며, useState 훅은 업데이트된 count 값을 반환한다.

Fiber 노드의 memoizedState에 상태 값을 저장한다고 했다. 그렇다면 setCount를 호출해서 count 값을 변경한다면 이 값은 업데이트 큐에 저장하고 새로운 상태 값을 계산해 workInProgress Fiber 노드의 memoizedState에 저장한다.

  • 트리를 순회하며 props나 상태 변경이 없는 경우에는 Fiber 노드를 재사용한다.
  • beginWork를 수행하며 자식이 없는 곳까지 도달하면, completeWork를 통해 변경이 필요한 DOM 업데이트 준비를 한다.
    • Content 컴포넌트는 새로운 count 값을 표시하기 위해 업데이트 flags를 설정해준다.
  • HostRootFiber까지 completeWork가 완료되면, workInProgress 트리는 finishedWork 속성에 할당되고 커밋 단계가 시작된다.

커밋 단계는 초기 렌더링 과정에서 봤듯이 변경된 사항을 실제 DOM에 업데이트하는 과정으로, 초기 렌더링 과정에 적어둔 것과 같아 생략했다.

리액트 리렌더링 정리

  • 리액트는 항상 HostRootFiber부터 시작하여 트리를 순회하며 각 컴포넌트를 검사한다.
    • 이때 props나 상태 변경이 있는지 확인하고 이에 따라 리렌더링 여부를 결정한다.
  • 해당 컴포넌트에 변경사항이 없다면, 리액트는 해당 컴포넌트의 이전 렌더링 결과를 재사용한다.
  • 해당 컴포넌트에 변경사항이 있다면, 리액트는 해당 컴포넌트를 리렌더링하고 자식 컴포넌트도 검사하게 된다.
  • 리렌더링을 통해 모든 컴포넌트 함수를 검사하지만, 실제 DOM 업데이트는 이전 렌더링과 새로운 렌더링 결과가 다른 곳에만 업데이트를 한다.

☑️ 요약 정리

Fiber 아키텍처

Fiber는 리액트 16에서 도입된 재조정 엔진으로, 각 리액트 컴포넌트의 작업 단위를 나타내는 자바스크립트 객체다.

Fiber 노드가 포함하는 정보

  • Instance: 컴포넌트 유형, 키, 상태 등
  • 트리 구조: 부모, 자식, 형제 노드 참조
  • 상태 관련: props, state, 업데이트 큐
  • Effect 관련: 작업 우선순위, 부수 효과

이전 리액트의 스택 조정자

  • 렌더링 작업 중단 불가
  • 우선순위 처리 불가능
  • 동기적 처리로 인한 성능 문제

Fiber의 도입

  • 작업 분할과 중단 가능
  • 우선순위 기반 처리
  • 향상된 에러 처리
  • 점진적 렌더링 가능

리액트 렌더링 프로세스

렌더링은 리액트 컴포넌트의 속성과 상태를 기반으로 UI를 계산하고 업데이트하는 과정이다. 초기 마운트 시와 상태 업데이트와 같은 이벤트에 의해 렌더링이 발생하게 된다.

리액트에서는 현재 UI를 표시해주는 current 트리와 작업을 진행하는 workInProgress 트리 2가지를 사용한다. workInProgress 트리에 변경 사항을 반영해 사용자가 불완전한 UI를 보지 않도록 한다.

렌더 단계

  • 비동기적으로 처리 가능
  • beginWork: 트리를 아래로 내려가며 변경사항을 계산
  • completeWork: 트리를 위로 올라가며 DOM 업데이트 준비

커밋 단계

  • 동기적으로 처리
  • 실제 DOM 업데이트 처리
  • 부수 효과(useLayoutEffect, useEffect) 처리

참고 자료

profile
안녕하세오

13개의 댓글

comment-user-thumbnail
2025년 1월 19일

기가막히게 잘 정리된 글이네요 잘 보고 갑니다

1개의 답글
comment-user-thumbnail
2025년 1월 19일

(정말 chill하다..)

1개의 답글
comment-user-thumbnail
2025년 1월 20일

쩐당..

1개의 답글
comment-user-thumbnail
2025년 1월 21일

많이 배우고 갑니다...!

1개의 답글
comment-user-thumbnail
2025년 2월 1일

chill 하네요 ..

1개의 답글

관련 채용 정보