React 내부 파헤치기 - Overview

Taegyu Hwang·2024년 4월 1일
0
post-thumbnail

React 내부를 파헤쳐보자!

React를 실무에서 사용하며 React로 코딩하는 것에 점점 익숙해지지만, 그만큼 이해도가 높아지는 것 같지는 않습니다. 당연한 말이지만, React도 코드 조각으로 이루어진 프로그램인데, 내부 로직에는 눈을 가린채 일종의 '마법'처럼 사용하고 있는 것 같기도 합니다.

마구 짠 코드들의 비효율이 모여 꽤 커져버린 성능 저하, 복잡한 버그 등을 해결할 땐 리액트에 대한 보다 깊은 이해에 대한 갈증이 생깁니다.

동아리에서 알게된 Jser.dev 블로그React Internals Deep Dive에 React 내부 코드를 살펴보는 포스팅이 잘 정리되어 있어, 이 시리즈를 바탕으로 React 내부를 파헤치며 이해를 심화시키고자 합니다. 오늘은 그 첫번째로, 큰 틀에서 훑어보며 내부 로직에 대한 상상을 하는 것으로 시작합니다.


React 실행흐름 훑어보기

바로 React 코드를 하나하나 열어보기보다, 실제 런타임에서 어떤 함수들이 호출되는지를 보며 큰 맥락을 파악해보면 좋을 것 같습니다.

1. 세팅

call stack을 보며 React의 실행 흐름을 훑어보기 위한 간단한 예시 코드에 debuuger를 추가합니다.

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

  useEffect(() => {
    debugger; 
    setCount((count) => count + 1);
  }, []);
  
  return <button>{count}</button>;

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

DOM이 조작될 때의 call stack도 보기 위해, DOM Breakpoint도 추가합니다.
react root에 breakpoint 추가

2. 첫 pause (component mount 시점)

call stack에서 몇가지 주요 함수들을 살펴보겠습니다.

2.1. ReactDOMRoot.render

ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render = function (children) {
    var root = this._internalRoot;
    // 에러처리 생략
    updateContainer(children, root, null, null);
  };
  • 우리가 작성한 코드입니다.
  • 인자와 root에 대한 에러처리를 수행 후 다음 함수를 호출합니다.

2.2. updateContainer

// 임의로 많이 생략된 코드입니다.
function updateContainer(element, container, parentComponent, callback) {
    var current$1 = container.current;
    var eventTime = requestEventTime();
    var lane = requestUpdateLane(current$1);

    var update = createUpdate(eventTime, lane); 

    update.payload = {
      element: element
    };

    var root = enqueueUpdate(current$1, update, lane);

    if (root !== null) {
      scheduleUpdateOnFiber(root, current$1, lane, eventTime);
    }

    return lane;
  }
  • container, element(App), lane 등의 정보를 담아 fiber(root)를 만듭니다.
  • lane은 fiber conciler가 업데이트의 우선순위를 결정할 때 사용하는 값으로 보입니다.

2.3. scheduleUpdateOnFiber

  • React에게 렌더링할 위치를 알려주는 함수입니다.

2.4. ensureRootIsScheduled

// 임의로 많이 생략된 코드입니다.
function ensureRootIsScheduled(root, currentTime) {
    var newCallbackPriority = getHighestPriorityLane(nextLanes); // Check if there's an existing task. We may be able to reuse it.
    var existingCallbackPriority = root.callbackPriority;

    if (existingCallbackPriority === newCallbackPriority) {
      // 에러처리 생략	
	  // The priority hasn't changed. We can reuse the existing task. Exit.
      return;
    }

    var newCallbackNode;

    if (newCallbackPriority === SyncLane) {
      // 스케줄 로직 생략
      }
      newCallbackNode = null;
    } else {
      var schedulerPriorityLevel;

      switch (lanesToEventPriority(nextLanes)) {
        case DiscreteEventPriority:
          schedulerPriorityLevel = ImmediatePriority;
          break;
        // 우선순위 레벨 결정 케이스 생략
        default:
          schedulerPriorityLevel = NormalPriority;
          break;
      }
      newCallbackNode = scheduleCallback$1(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root));
    }

    root.callbackPriority = newCallbackPriority;
    root.callbackNode = newCallbackNode;
  }
  • 기존 콜백과 새 콜백간의 우선순위를 따져 다음 콜백을 결정하는 함수로 보입니다.
  • 이 예제의 경우performConcurrentWorkOnRoot가 예약 됩니다.

2.5. scheduleCallback

function unstable_scheduleCallback(priorityLevel, callback, options) {
    var currentTime = getCurrentTime();
    var startTime;
    if (typeof options === "object" && options !== null) {
      // 여러 조건(options, delay 등)에 따라 startTime 설정하는 로직 생략
    }

    var timeout;
    switch (priorityLevel) {
      // prioirityLevel에 따라 timeout 설정하는 로직 생략
    }

    var expirationTime = startTime + timeout;
    var newTask = {
      id: taskIdCounter++,
      callback: callback,
      priorityLevel: priorityLevel,
      startTime: startTime,
      expirationTime: expirationTime,
      sortIndex: -1,
    };

    if (startTime > currentTime) {
      // delayed task 처리하는 로직 생략
    } else {
      newTask.sortIndex = expirationTime;
      push(taskQueue, newTask);
      // wait until the next time we yield.
      if (!isHostCallbackScheduled && !isPerformingWork) {
        isHostCallbackScheduled = true;
        requestHostCallback(flushWork);
      }
    }
    return newTask;
  }
  • 우선순위에 따라 timeout이 차등으로 매겨지며, 이를 이용해 task의 expirationTime을 결정하는 등 실질적인 작업의 스케줄링을 담당합니다.
  • 위에서는 생략했지만, task의 delay 등도 처리합니다.
  • 이후 호출되는 함수에선 postMessage를 통해 비동기적으로 스케줄된 작업을 시작합니다.

2.6. performWorkUntilDeadline

// 이 함수는 짧고, 주석에 React 개발자들의 의도가 많이 담겨있어 전체를 가져왔습니다.
var performWorkUntilDeadline = function () {
    if (scheduledHostCallback !== null) {
      var currentTime = getCurrentTime(); // Keep track of the start time so we can measure how long the main thread
      // has been blocked.

      startTime = currentTime;
      var hasTimeRemaining = true; // If a scheduler task throws, exit the current browser task so the
      // error can be observed.
      //
      // Intentionally not using a try-catch, since that makes some debugging
      // techniques harder. Instead, if `scheduledHostCallback` errors, then
      // `hasMoreWork` will remain true, and we'll continue the work loop.

      var hasMoreWork = true;

      try {
        hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
      } finally {
        if (hasMoreWork) {
          // If there's more work, schedule the next message event at the end
          // of the preceding one.
          schedulePerformWorkUntilDeadline();
        } else {
          isMessageLoopRunning = false;
          scheduledHostCallback = null;
        }
      }
    } else {
      isMessageLoopRunning = false;
    } // Yielding to the browser will give it a chance to paint, so we can
  };
  • postMessage에 의해 비동기적으로 호출되는 함수입니다.
  • hasMoreWork가 true이면, schedulePerformWorkUntilDeadline를 다시 호출합니다. 즉, 다음 메시지 이벤트를 예약합니다.

2.7. workLoop

  • React Scheduler가 작업을 수행합니다.

2.8. performConcurrentWorkRoot

  • 드디어 ensureRootIsScheduled 함수에서 등록했던 함수가 실행됩니다.
  • 컴포넌트가 렌더링 됩니다. (mountIndeterminateComponent)

3. 두번째 pause (DOM 조작 시점)

3.1. commitRoot

  • performConcurrentWorkRoot에서 render가 끝나고 나면, commit 단계로 넘어옵니다.
  • render 단계에서 파생된 '필요한' DOM 업데이트를 commit합니다.

3.2. commitMutationEffectsOnFiber

function commitMutationEffectsOnFiber(finishedWork, root, lanes) {
  // 생략
  switch (finishedWork.tag) {
 	// ...
    case HostRoot: {
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork);
    // ...
}
  • 첫 mount엔 HostRoot이므로, recursivelyTraverseMutationEffects를 호출하고, 그 안에선 child를 넘기며 commitMutationEffectsOnFiber를 다시 호출합니다.
  • 즉, FiberNode 트리를 DFS하며 호출합니다.
  • (완전 좋은 참고 - React 파이버 아키텍처 분석: commit 단계)

3.3. appendChildToContainer

  • 최종적으로 이 함수에서 DOM에 appendChild를 하여 리액트로 만든 첫 화면이 브라우저에 그려지게 됩니다.

4. 세번째 pause (effect 시점)

4.1. flushPassiveEffects

  • useEffect에 의해 생성된 passive effects를 flush 한다는데, 정확히 어떤 역할인지는 잘 모르겠습니다.

5. 네번째 pause (re-render 시점)

  • useEffect 안에서 setState를 하여 유발된 re-render 시점입니다.
  • call stack을 보면 위에서 살펴본 initial mount 때와 거의 유사합니다.
  • initial mount 때는 mountIndeterminateComponent를 호출했던 것 대신 updateFunctionComponent 함수가 호출되는 것 정도가 다릅니다.

React 흐름을 단계별로 정리하기

(출처: https://jser.dev/2023-07-11-overall-of-react-internals)

위 예시에서 debugger와 breakpoint를 통해 살펴본 리액트 내부의 흐름을 단계별로 정리해봅시다.

1. Trigger

  • 말 그대로, 리액트의 작업을 유발하는 단계입니다.
  • 어느 부분을 렌더링 해야하는지(scheduleUpdateOnFiber)를 react에 알려줍니다.
  • 이 단계는 '작업 생성' 단계로도 볼 수 있으며 위에서 살펴본 바와 같이 ensureRootIsScheduled가 작업을 결정하고 예약하는 마지막 단계입니다.
  • 이후 scheduleCallback을 통해 스케줄 단계로 넘어갑니다.

2. Schedule

  • 기본적으로 우선순위에 따라 작업을 처리하는 우선순위 큐의 형태로 작업을 처리합니다.
  • 렌더링이나 effect 등의 작업을 예약합니다.
  • workLoop는 실질적인 작업을 실행합니다.

3. Render

  • trigger 단계에서 생성되고, schedule 단계에서 우선순위가 조정된 작업이 수행됩니다.
  • 새로운 fiber tree를 계산하고 host DOM에 적용해야할 부분을 파악합니다.
  • 참고로, fiber tree는 앱의 현재 상태를 나타내는 구조이며, 이전에는 virtual DOM이라고 불렸지만, 지금은 DOM에만 사용되지 않기 때문에 React 팀에서는 더 이상 virtual DOM이라고 부르지 않는다고 합니다.

4. Commit

  • render 단계에서 필요한 최소 업데이트가 계산되면 이를 host DOM에 적용하는 단계입니다.
  • DOM을 조작하는 작업 외에 effect를 다루는 작업도 수행합니다. (flushPassiveEffects, commitLayoutEffects 등)

정리

오늘은, 간단한 예제코드를 통해 큰 틀에서의 리액트 흐름을 살펴보았습니다.
call stack에 어떤 함수들이 등록되는지, 어떤 인자들을 주고 받으며 이어지는지를 보며 역할을 추측했고, 자세한 디테일은 다루지 않았습니다.

이후, 오늘 글의 소스가 된 Jser.dev의 React Internals Deep Dive 시리즈를 바탕으로 리액트의 더 깊숙한 부분으로 파고들어볼 예정입니다.

0개의 댓글

관련 채용 정보