Profile Picture

윤찬의 개발노트

2025. 4. 27.

React 만들기 - reconciler

React.jsJavaScript
"바닥까지 파는 습관"

코어

리액트의 라이프사이클을 크게 render phase, commit phase 두개로 나눌 수 있다면,

  • render phase VDOM 재조정
  • commit phase 재조정한 VDOM을 DOM에 적용하며, 라이프사이클 실행

코어와 가장 밀접하게 연결된 부분은 render phase라고 생각한다. 이 단계에서 리액트는 실제 DOM에 영향을 주지 않는 선에서, 어떤 부분이 변경되어야 하는지를 계산하고 비교하는 과정을 수행한다. 이 과정을 reconciliation이라고 하며, 패키지 구조 관점에서는 reconciler가 이 역할을 맡는다.

즉, render phase는 fiber 아키텍처 위에서 각 렌더링 주기마다 작업을 유연하게 중단하거나 우선순위를 조정할 수 있게 해준다.


reconciler의 실제 동작

useState에 의해 실제 렌더링이 발생했다고 가정해보면, 먼저 핵심 함수인 dispatchAction에 초점을 맞춰야한다.

위는 실제 리액트 코드인데, dispatchAction은 실제 React 내부에서 상태를 변경하고, 업데이트사항을 큐에 넣고, 렌더링을 트리거하는 핵심 함수이다. 그래서, 함수에서 제일 처음 나오는 로직의 경우에는 렌더링 중인지의 여부 판단이며 렌더링중인지의 여부는

위와 같이 구현이 되어있는데, fiber == currentlyRenderingFiber는 현재 렌더링 중인 fiber가 이 업데이트의 fiber와 동일한 경우를 말하기 때문에, 렌더링 중인 것을 의미하며, 두 번째 조건의 경우에는 alternate !== null && alternate === currentlyRenderingFiber가 나타내는 것은 현재 렌더링 중인 컴포넌트가 alternate에 있을 경우인데, VDOM은 이중 버퍼구조 이기 때문에, 다른 하나의 버전의 Fiber를 나타내는 alternate에 대해, 똑같은 로직으로 수행한 것이다.

두 개의 버퍼구조에서는 실제 DOM과 VDOM의 연결은 하나의 트리에만 연결되어있기 때문에 commit 단계를 거치게 되면, VDOM내부에서 트리가 스위치 될 수 있다. 즉, 누가 current이고, 누가 workInProgress인지 고정되지 않기 때문에, 둘다 비교하는 것이다.

현재 리액트가 렌더링 중인 컴포넌트인지를 확인하고 있는 것이다. react craft에서는 허접하게 다음과 같이 매우 약식으로 적용했다.

function dispatchAction(fiber, queue, action) {
  const isRenderPhase = isRendering();

  console.log("dispatchAction 호출");
  console.log("현재 상태:", state);

  if (isRenderPhase) {
    const update = { action, next: null };
    console.log("렌더링 중, 즉시 업데이트 실행");
    applyUpdateImmediately(update);
  } else {
    const currentTime = Date.now();

reconciler dispatchAction의 나머지 역할

렌더링 중이라면, 즉시 상태를 반영하도록 처리를 했고, 약식으로 만든 로직에서는 applyUpdateImmediately함수로 상태를 적용하고 render 하도록 진행시켰다.

function applyUpdateImmediately(update) {
  console.log("즉시 업데이트 실행:", update);
  state = {
    ...state,
    ...update.action(state),
  };
  render();
}

중요한 것은 렌더링중이 아닐때 인데, 실제코드를 보면, update 객체를 생성하고, queue에 추가한다. 이때 들어가는 객체에에는 expriationTime이 있는데,

우선순위 관리에 쓰인다. 리액트는 큰 작업을 쪼개서 관리하는데, 이때 expriationTime을 통해서, 중간에 판단해서 처리하기 위해서이다. 관련 함수는 ReactFiberWorkLoop.js에서 확인할 수 있다.

마지막으로, 루트단위로 스케줄에 예약하는 역할을 하는 scheduleWork을 호출하고, 실행이 끝난다. react craft에서는 scheduleWork함수 호출 대신, 해당 함수 안에 있는 ensureRootIsScheduled를 대신 호출했고 약식으로 구현했다.


scheduleWork안의 ensureRootIsScheduled

ensureRootIsScheduled는 루트에 등록된 작업이 없으면, 스케줄러에 새로 등록하는 역할을 하는데, 약식으로 구현한 코드는 모두 초기화 상태에서 진행하려고 했기 때문에, ensureRootIsScheduled에 더 초점을 두었다.

코드가 100줄 조금 안되는데, 대충 내용을 정리하면 다음과 같은 흐름이다

1. 만료된 작업이 있다면 → 즉시 처리
2. 처리할 일이 없다면 → 기존 작업도 취소
3. 우선순위와 만료 시간 계산 → 기존 작업이 충분하면 유지
4. 부족하면 기존 작업 취소하고 새로 예약

1, 2번의 경우에는 우선순위 핵심 기능보다는 보조적인 예외라서, 약식으로 구현한 코드는 3,4번의 경우에 대해서만 구현했다.


우선순위와 만료 시간 계산

  if (
    currentCallback &&
    currentCallbackPriority !== null &&
    priority >= currentCallbackPriority
  ) {
    console.log("현재 콜백이 더 우선순위가 높아서 스케줄하지 않음");
    return;
  }

실제 코드는 currentTime과expirationTime을 바탕으로 우선순위를 계산하는 로직이 담겨있었고, 약식코드에도 우선순위 비교 로직을 추가했다.

부족하면 기존 작업 취소하고 새로 예약

  if (priority === ImmediatePriority || timeout === 0) {
    console.log("동기 콜백으로 처리");
    currentCallback = scheduleSyncCallback(() => {
      currentCallback = null;
      currentCallbackPriority = null;
      performWork();
    });
  } else {
    console.log("비동기 콜백으로 처리");
    currentCallback = scheduleCallback(
      priority,
      () => {
        currentCallback = null;
        currentCallbackPriority = null;
        performWork();
      },
      timeout
    );
  }

기존작업이 부족하면 새로 예약하는 로직에서, 즉시 처리해야할 작업은 동기로, 아닌 경우는 비동기로 처리를 해서 순서를 정의 했다. 또한 이부분에서, 더 파고 들어가, 동기 콜백, 비동기 콜백 부분을 더 알아보겠다.


scheduleSyncCallback & scheduleCallback

scheduleSyncCallback에 들어가기전에 scheduleCallback은 내부적으로 setTimeout을 걸어둔것이 전부이기 때문에, 생략을 해도 될것가탇. 중요한것은 scheduleSyncCallback인데, 실제코드를 보면

동기로 처리해야할 작업은 큐에 넣고, 다음 단계에서 실행되도록 예약을 한다. 또한 실제 순서대로 실행되게 하는 함수는 flushSyncCallbackQueueImpl

위와같이 내부적으로, syncQueue에 쌓여 있는 콜백들을 등록된 순서대로 하나씩 꺼내어 실행하는 것을 볼 수 있다. react craft에서도 더미로 채우는 것을 제외하면 실제 scheduleSyncCallback의 로직을 그대로 수행했고

  function scheduleSyncCallback(callback) {
    console.log("동기 콜백 실행");

    // 동기 콜백을 큐에 푸시
    if (syncQueue === null) {
      syncQueue = [callback];
      // 큐를 다음 틱에서 플러시하도록 예약
      immediateQueueCallbackNode = scheduleCallback(
        ImmediatePriority,
        flushSyncCallbackQueueImpl
      );
    } else {
      // 이미 큐가 존재하면 콜백만 푸시
      syncQueue.push(callback);
    }

    // 더미 콜백 반환
    return { id: "fakeCallbackNode" }; // 일단 약야깃ㄱ으로 구현
  }

flushSyncCallbackQueueImpl 내부적으로도 순서보장하며 실행하는 로직을 반복문을 통해 추가했다.

  if (syncQueue !== null) {
    while (syncQueue.length > 0) {
      const callback = syncQueue.shift();
      console.log("콜백 실행:", callback);
      callback();
    }
    
    ...

마무리

전체적인 흐름을 보면 다음과 같다.

dispatchAction 호출
현재 상태: {count: 0}
렌더링 중이 아님, 큐에 업데이트 추가

  • 렌더링 중이 아니니, 큐에 저장만 하고 넘어감. 이후 스케줄 예약
가장 시급한 업데이트 찾기 시작
가장 시급한 업데이트: {expirationTime: ..., action: ƒ}
다음 업데이트의 우선순위와 딜레이 계산: {priority: 2, timeout: 50}
비동기 콜백으로 처리
스케줄 콜백: {priority: 2}

이단 계에서 첫번째 콜백을 예약완료한다. 이후 두번째 dispatchAction을 호출한다.

dispatchAction 호출
현재 상태: {count: 0}
렌더링 중이 아님, 큐에 업데이트 추가

아직 첫 번째 액션이 렌더링되기 전이니까 상태는 여전히 count 0이다.

가장 시급한 업데이트 찾기 시작
가장 시급한 업데이트: {expirationTime: ..., action: ƒ}
다음 업데이트의 우선순위와 딜레이 계산: {priority: 2, timeout: 47}
현재 콜백이 더 우선순위가 높아서 스케줄하지 않음

이미 예약된 콜백보다 우선순위가 높지 않아서, 예약없이 큐에만 추가된다.

업데이트 처리: {expirationTime: ..., action: ƒ}
업데이트 처리: {expirationTime: ..., action: ƒ}

🖼️ 렌더링! 상태: {count: 2}

이후 쌓여있는 두개의 업데이트를 순서대로 처리하며, 모든 업데이트 처리가 끝나고 최종적으로 렌더링이 한번 발생하게된다.

현재는 reconciler에 대해서, 초점을 둔 상태이기 때문에, 다음에는 scheduler에 대해 알아보면 좋을듯 하다.

Profile Picture

CHAN

과정은 복잡하되, 결과는 단순하게

Thank You for Visiting My Blog, Have a Good Day 😆
ⓒYoonchan Cho