React 톺아보기 - 04. Scheduler_1

모든 설명은 v16.12.0 버전 함수형 컴포넌트와 브라우저 환경을 기준으로 합니다. 버전에 따라 코드는 변경될 수 있으며 클래스 컴포넌트는 설명에서 제외됨을 알려 드립니다.

포스트별 주제

  1. 훅을 통해 컴포넌트 상태를 업데이트한다.
  2. 업데이트를 반영할 Workscheduler에게 전달하고 scheduler는 스케줄링된 Task를 적절한 시기에 실행한다.

  3. Work을 통해 VDOM 재조정 작업을 진행한다.
  4. Work를 진행하며 발생한 변경점을 적용한다.
  5. 사용자의 상호작용으로 이벤트가 발생하고 등록된 핸들러가 실행되면서 다시 1번으로 되돌아간다.

reconciler는 비동기 호출이 필요한 Work의 실행 제어권을 해당 분야의 전문가인 scheduler에게 위임합니다. reconciler가 스스로 판단해서 실행하는 게 아닌 scheduler가 브라우저의 상태와 여러 조건을 기반으로 적절한 시기를 판단해서 실행하고 있다는 말입니다.

아래는 reconcilerWork를 스케줄링하고 scheduler에 의해 실행되기까지의 흐름입니다.

scheduling flow

위 흐름에서 각 패키지 별로 하는 일은 명확합니다.
reconciler는 VDOM 재조정 작업 전에 설정해줘야 하는 부분들을 처리하며 scheduler는 스케줄링 된 Task에 우선순위 반영과 실행하기 적절한 때를 판단하고 작업의 실행과 중단을 담당합니다.

scheduler에는 host config라는 모듈이 존재하는데, 호스트 환경에 의존적인 api를 사용하는 모듈입니다.
여기에는 비동기 api, performance, isInputPending 등이 있습니다.

isInputPending

Facebook이 처음으로 기여한 브라우저 API로, 이름 그대로 JS에서는 알 수 없었던 input event의 존재 여부를 알 수 있으며 이를 이용하여 JS가 메인 스레드를 계속해서 점유하고 있어도 되는지 판단할 수 있습니다.

리액트는 언제 isInputPending이 필요했느냐면 concurrent mode에서 Render phase가 진행 중 일 때 혹시 있을지 모를 사용자의 event나 브라우저의 렌더링 작업을 방해하지 않기 위해 특정 주기로 콜 스택을 비워주었습니다. 굳이 필요하지 않은 상황일 때도 말이죠.

이제 리액트는 isInputPending api를 이용하여 브라우저가 메인 스레드의 점유가 필요할 때만 작업을 잠시 중단하고 이를 양보할 수 있게 되었습니다. 만약 그럴 필요가 없다면 리액트는 계속해서 재조정 작업을 동기적으로 진행합니다.

1. dispatchAction

해당 함수는 Hooks에서 이미 자세히 알아보았으므로 생략합니다.

reconciler > ReactFiberHooks.js

function dispatchAction(...) {
  if (...) {
    /* Render phase update... */
  } else {
    /* idle update... */
    scheduleWork(fiber, expirationTime);
  }
}

2. scheduleWork

Work를 스케줄링하기 전에 reconciler에서 해야 할 일은 다음과 같습니다.

  1. 해당 컴포넌트에서 이벤트가 발생했음을 알리는 expirationTime을 새긴다.
  2. 이벤트가 발생한 컴포넌트의 VDOM root을 가지고 온다.
  3. root에 스케줄 정보를 기록한다.

현 상황에서 root의 의미를 생각해 볼 필요가 있습니다.
리액트를 레거시 프로젝트에 도입한다고 했을 때 ReactDOM.render()를 통해 컴포넌트를 삽입한다면 영역별로 root가 생성됩니다. 그리고 root마다 VDOM도 같이 생성되겠죠. root는 VDOM을 대표하는 변하지 않는 객체입니다. 그래서 root에는 많은 정보가 기입되는데 그 중 하나가 스케줄정보입니다.

만약 단일 VDOM에 여러 업데이트가 발생하여 복수의 Work 스케줄링 요청이 들어온다고 생각해봅시다. 이때 요청이 들어온 Work와 이미 스케줄링된 Work 사이에 교통정리가 필요합니다. 이 역할을 reconciler가 해주며 교통정리의 기준이 되는 게 바로 root에 새겨진 스케줄 정보입니다.

2 - 1 expirationTime 새기기

expirationTime을 어떻게 새기는지 알아보겠습니다.

reconciler > ReactFiberWorkLoop.js

export function scheduleUpdateOnFiber(fiber, expirationTime) {
  const root = markUpdateTimeFromFiberToRoot(fiber, expirationTime)
}

export const scheduleWork = scheduleUpdateOnFiber

먼저 업데이트가 발생한 fiber에 expirationTime을 새깁니다. 그리고 root를 찾기 위해 위로 올라가는데, 거쳐 가는 fiber에도 시간을 새겨줍니다. 이때는 expirationTime가 아닌 자손에서 이벤트가 발생했음을 나타내는 childExpirationTime를 새깁니다. 이 부분은 재조정 작업에서 매우 중요하게 사용됩니다.

reconciler > ReactFiberWorkLoop.js

function markUpdateTimeFromFiberToRoot(fiber, expirationTime) {
  // expirationTime을 새긴다.
  if (fiber.expirationTime < expirationTime) {
    fiber.expirationTime = expirationTime
  }
  let alternate = fiber.alternate
  if (alternate !== null && alternate.expirationTime < expirationTime) {
    alternate.expirationTime = expirationTime
  }

  // root를 찾는다.
  let node = fiber.return
  let root = null
  if (node === null && fiber.tag === HostRoot) {
    root = fiber.stateNode // Host root의 stateNode가 root이다.
  } else {
    while (node !== null) {
      alternate = node.alternate
      // childExpirationTime를 새긴다.
      if (node.childExpirationTime < expirationTime) {
        node.childExpirationTime = expirationTime
        if (
          alternate !== null &&
          alternate.childExpirationTime < expirationTime
        ) {
          alternate.childExpirationTime = expirationTime
        }
      } else if (
        alternate !== null &&
        alternate.childExpirationTime < expirationTime
      ) {
        alternate.childExpirationTime = expirationTime
      }

      if (node.return === null && node.tag === HostRoot) {
        root = node.stateNode
        break
      }
      node = node.return
    }
  }

  return root
}

current와 workInprogress 모두 처리해주고 있는 이유를 우리는 알고 있습니다.

2 - 2 스케줄링 요청 전 Work를 동기로 처리해야 하는지 확인하기

reconciler > ReactFiberWorkLoop.js

export function scheduleUpdateOnFiber(fiber, expirationTime) {
  // scheduleWork()
  // const root = markUpdateTimeFromFiberToRoot(fiber, expirationTime);

  if (expirationTime === Sync) {
    // VDOM 첫 생성 판단
    if (
      (executionContext & LegacyUnbatchedContext) !== NoContext &&
      (executionContext & (RenderContext | CommitContext)) === NoContext
    ) {
      performSyncWorkOnRoot(root)
    } else {
      ensureRootIsScheduled(root)
      if (executionContext === NoContext) {
        flushSyncCallbackQueue()
      }
    }
  } else {
    ensureRootIsScheduled(root)
  }

  /*...*/
}
  • 5: Work를 동기적으로 호출해야 하는지 판단합니다. lagacy mode에서는 대게 expirationTime는 Sync입니다.
  • 8 ~ 9: 해당 조건은 VDOM이 처음 생성될 때 충족합니다. 이때는 Work를 바로 진행하여 VDOM을 빠르게 완성해줍니다. 동기로 재조정 작업을 수행해줄 함수가 바로 performSyncWorkOnRoot()12입니다.
  • 13 ~ 16: ensureRootIsScheduled()를 통해 재조정 작업이 필요한 root에 Work을 스케줄링 요청하고 정보를 새깁니다. reconciler가 NoContext라면14 flushSyncCallbackQueue()를 통해 sync queue에 담겨 있는 Work를 바로 실행시켜 줍니다. 여기서의 NoContext란, 리액트 관리 밖에서 발생한 업데이트를 뜻합니다. 예를 들어 사용자 클릭에서 발생한 업데이트라면 executionContext14에는 EventContext, DiscreteEventContext 가 새겨져 있습니다. 이는 리액트가 구성한 SyntheticEvent에서 사용자 이벤트 처리 과정 중에 설정됩니다. 즉 이벤트에서 발생한 업데이트는 flushSyncCallbackQueue()가 호출되지 않기 때문에 배치 처리되고(아래 Sync Work 섹션에서 다시 다룹니다.), NoContext(setTimeout이나 Promise 등)일 때는 업데이트가 발생(dispatchAction())할 때마다 flushSyncCallbackQueue()로 인해 ensureRootIsScheduled()에서 스케줄링된 Work가 진행됩니다. 즉 업데이트 마다 렌더링이 발생한다는 의미입니다.
  • 19: Work 스케줄링 요청을 합니다. expirationTime이 Sync가 아니므로 비동기 전용 Work가 스케줄링 될 것입니다.

scheduleUpdateOnFiber()scheduleWork()는 스케줄 요청 전과 후에 reconciler 입장에서의 추가 작업들이 위치할 수 있는 함수입니다. 스케줄링과 관련된 직접적인 코드는 존재하지 않으며 이와 관련된 코드들은 함수 이름에서 느껴지듯이 ensureRootIsShceduled()가 모두 가지고 있습니다.

3. ensureRootIsScheduled

3 - 1 scheduler에게 Work를 전달하기 전 사전 준비하기

Work를 스케줄링하기 전 필요한 정보들이 몇 가지 있습니다. 새로 요청할 작업의 우선순위와 expirationTime, 교통 정리에 필요한 스케줄 정보입니다.

reconciler > ReactFiberWorkLoop.js

function ensureRootIsScheduled(root) {
  /*...*/
  const existingCallbackNode = root.callbackNode // 스케줄링되어 있는 Task 객체
  const expirationTime = getNextRootExpirationTimeToWorkOn(root)
  const currentTime = requestCurrentTimeForUpdate()
  const priorityLevel = inferPriorityFromExpirationTime(
    currentTime,
    expirationTime
  )
  /*...*/
}

시간, 우선순위와 관련된 내용은 깊게 들어가지 않겠습니다.

getNextRootExpirationTimeToWorkOn()은 root가 가지고 있는 정보들을 기반으로(만료된 작업이 남아있음을 나타내는 lastExpiredTime, suspense와 관련된 ***PendingTime…) 현재 처리해야 할 expirationTime을 가지고 옵니다.

currentTime은 이전 포스트의 expirationTime 섹션에서 확인했던 msToExpirationTime() 함수를 이용하여 구합니다.

우선순위는 코드를 직접 보는 게 이해하기가 더 편합니다.

reconciler > ReactFiberExpirationTime.js

const HIGH_PRIORITY_EXPIRATION = __DEV__ ? 500 : 150
const HIGH_PRIORITY_BATCH_SIZE = 100
const LOW_PRIORITY_EXPIRATION = 5000
const LOW_PRIORITY_BATCH_SIZE = 250

function inferPriorityFromExpirationTime(
  currentTime: ExpirationTime,
  expirationTime: ExpirationTime
): ReactPriorityLevel {
  if (expirationTime === Sync) {
    return ImmediatePriority
  }
  if (expirationTime === Never || expirationTime === Idle) {
    return IdlePriority
  }

  const msUntil =
    expirationTimeToMs(expirationTime) - expirationTimeToMs(currentTime)

  if (msUntil <= 0) {
    return ImmediatePriority
  }

  if (msUntil <= HIGH_PRIORITY_EXPIRATION + HIGH_PRIORITY_BATCH_SIZE) {
    return UserBlockingPriority
  }
  if (msUntil <= LOW_PRIORITY_EXPIRATION + LOW_PRIORITY_BATCH_SIZE) {
    return NormalPriority
  }

  return IdlePriority
}

msUntil은 expirationTime에서 ms를 구한 시간으로 expirationTime은 큰 수가 더 이전 시점을 나타내는 데 반해 ms는 우리가 통상 생각하는 시간으로 큰 수가 이후 시점을 나타냅니다.

expirationTime이 currentTime을 넘기게 되면21 해당 작업은 이미 만료되었기 때문에 빠르게 처리해줘야 하므로 ImmediatePriority을 반환해주고 나머지 우선순위는 expirationTime이 얼마나 더 여유 있는지25, 28에 따라 우선순위를 반환합니다.

다시 말씀드리지만 expirationTime, 우선순위는 주로 concurrent mode에서 사용됩니다.

3 - 2 교통정리 하기

스케줄링에 필요한 정보를 모두 구했으니 root에 Work가 예약되어 있는지 확인합니다. 예약되어 있다면 우선순위를 따져 기존 Work를 취소할지 결정해야 합니다.

reconciler > ReactFiberWorkLoop.js

function ensureRootIsScheduled(root) {
  /*...*/
  /* const priorityLevel = ...; */

  if (existingCallbackNode !== null) {
    const existingCallbackPriority = root.callbackPriority
    const existingCallbackExpirationTime = root.callbackExpirationTime
    if (
      existingCallbackExpirationTime === expirationTime &&
      existingCallbackPriority >= priorityLevel
    ) {
      // Existing callback is sufficient.
      return
    }
    cancelCallback(existingCallbackNode) // scheduler에게 취소 요청
  }
  /*...*/
}

3 - 3 root에 스케줄링 정보 새기기

Work의 스케줄을 요청하고 root에 정보를 새깁니다. 여기서도 scheduleUpdateOnFiber()와 마찬가지로 Sync 여부를 따지는데 scheduleUpdateOnFiber()는 Work를 동기 호출하기 위한 판단이었다면 ensureRootIsScheduled()는 Work의 진행을 동기적으로 처리해야 하는지에 대한 판단입니다.

reconciler > ReactFiberWorkLoop.js

function ensureRootIsScheduled(root) {
  /*...*/
  /* if (existingCallbackNode !== null) { ... } */

  root.callbackExpirationTime = expirationTime
  root.callbackPriority = priorityLevel

  let callbackNode
  if (expirationTime === Sync) {
    // Sync React callbacks are scheduled on a special internal queue
    callbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root))
  } else {
    callbackNode = scheduleCallback(
      priorityLevel,
      performConcurrentWorkOnRoot.bind(null, root),
      // Compute a task timeout based on the expiration time. This also affects
      // ordering because tasks are processed in timeout order.
      { timeout: expirationTimeToMs(expirationTime) - now() }
    )
  }

  root.callbackNode = callbackNode // Task를 root에 저장한다.
}

performSyncWorkOnRoot()performConcurrentWorkOnRoot()가 동기, 비동기로 Work를 진행하는 함수입니다. 이 함수들은 scheduler가 스케줄링 후 반환하는 TaskcallbackNode를 반환합니다.

아직까지도 scheduler와 직접적으로 연관된 코드들이 등장하지 않았습니다. ReactFiberWorkLoop.js에서 scheduler의 모듈을 import 하여 사용할 수도 있지만 리액트는 reconcilerscheduler 사이의 의존도를 낮추기 위해 SchedulerWithReactIntegration.js라는 모듈을 따로 두었습니다. 이 모듈에 속한 함수가 scheduleSyncCallback()scheduleCallback()입니다.

4. scheduleSyncCallback, scheduleCallback

4 - 1 Async Work

reconciler > SchedulerWithReactIntegration.js

import * as Scheduler from 'scheduler'
const { unstable_scheduleCallback: Scheduler_scheduleCallback } = Scheduler

function scheduleCallback(
  reactPriorityLevel: ReactPriorityLevel,
  callback: SchedulerCallback,
  options: SchedulerCallbackOptions | void | null
) {
  const priorityLevel = reactPriorityToSchedulerPriority(reactPriorityLevel)
  return Scheduler_scheduleCallback(priorityLevel, callback, options)
}

reconcilerscheduler의 우선순위 내부 값이 다르기 때문에(ImmediatePriority는 각각 99, 1) scheduler에게 넘기기 전에 변환해야 합니다.

이 우선순위와 callbackperformConcurrentWorkOnRoot(), options를 넘겨주며 scheduler에게 스케줄링을 요청합니다. 이제 이 callback은 scheduler가 알아서 실행시킬 겁니다. reconciler는 더는 여기에 대해 신경 쓰지 않습니다.

options에는 Task의 만료 시간을 나타내는 timeout과 시작 시간을 미룰수 있는 delay가 있습니다.

4 - 2 Sync Work

async Work와는 다르게 sync Work의 처리는 조금 다릅니다.
Work을 동기적으로 호출해야 한다면 scheduler의 힘을 빌릴 필요 없이 reconciler가 내부적으로 처리하면 됩니다. Work가 곧 reconciler가 행하는 행위이기 때문입니다.

그래서 sync 작업들은 내부 queue에 따로 저장해 놓습니다. 그리고 실행하기 적당한 때를 reconciler가 판단해서 queue에 있는 작업을 실행합니다. 이 행위를 하는 함수가 flushSyncCallbackQueue()이며 scheduleWork()에서 사용되었습니다.

다음의 코드를 주석으로 너무 잘 설명하고 있어서 한번 읽어 보시길 바랍니다.

reconciler > SchedulerWithReactIntegration.js

function scheduleSyncCallback(callback: SchedulerCallback) {
  // Push this callback into an internal queue. We'll flush these either in
  // the next tick, or earlier if something calls `flushSyncCallbackQueue`.
  if (syncQueue === null) {
    syncQueue = [callback]
    // Flush the queue in the next tick, at the earliest.
    immediateQueueCallbackNode = Scheduler_scheduleCallback(
      Scheduler_ImmediatePriority,
      flushSyncCallbackQueueImpl
    )
  } else {
    // Push onto existing queue. Don't need to schedule a callback because
    // we already scheduled one when we created the queue.
    syncQueue.push(callback)
  }
  return fakeCallbackNode // Task는 없으니 가짜를 반환
}

async Work와는 다르게 callback을 shceulder에게 넘기지 않고 내부 큐에 넣습니다. 그리고 대신 이 큐를 소비하는 flushSyncCallbackQueueImpl()를 스케줄링하여 다음 틱에 실행되도록 합니다. 만약 scheduleWork()에서 executionContext가 NoContext가 아니라서 syncQueue를 비우지 않았다면 다음 틱에 이벤트(예. 클릭 이벤트)에서 동시에 발생한 업데이트는 배치 처리됩니다. 해당 이벤트의 업데이트는 같은 우선순위를 가지므로 하나의 Work만 스케줄링될 것이고 업데이트는 Linked List로 이미 연결되어 있기 때문에 한번의 렌더링(Work)으로 처리됩니다.

scheduleWork()에서 사용한 flushSyncCallbackQueue() 또한 내부적으로 flushSyncCallbackQueueImpl()를 사용하고 있습니다.

reconciler > SchedulerWithReactIntegration.js

function flushSyncCallbackQueue() {
  if (immediateQueueCallbackNode !== null) {
    const node = immediateQueueCallbackNode
    immediateQueueCallbackNode = null
    Scheduler_cancelCallback(node)  }
  flushSyncCallbackQueueImpl()
}

만약 리액트가 놀고 있어서 scheduleWork()에서 flushSyncCallbackQueue()를 호출했다면 반드시 스케줄링된 flushSyncCallbackQueueImpl()를 취소시켜 준 다음에 실행해야 합니다.

마지막으로 sync queue를 소비하는 함수인 flushSyncCallbackQueueImpl()만 확인하면 우리는 reconciler가 하는 일을 모두 확인하게 되는 것입니다.

11. flushSyncCallbackQueueImpl

reconciler > SchedulerWithReactIntegration.js

function flushSyncCallbackQueueImpl() {
  if (!isFlushingSyncQueue && syncQueue !== null) {
    // 중복 실행 방지 플래그
    isFlushingSyncQueue = true
    let i = 0
    try {
      const isSync = true
      const queue = syncQueue
      runWithPriority(ImmediatePriority, () => {
        for (; i < queue.length; i++) {
          let callback = queue[i]
          do {
            callback = callback(isSync) // performSyncWorkOnRoot()
          } while (callback !== null)
        }
      })
      syncQueue = null
    } catch (error) {
      // 에러가 발생한 callback만 버린다.
      if (syncQueue !== null) {
        syncQueue = syncQueue.slice(i + 1)
      }
      // Resume flushing in the next tick
      Scheduler_scheduleCallback(
        Scheduler_ImmediatePriority,
        flushSyncCallbackQueue
      )
      throw error
    } finally {
      isFlushingSyncQueue = false
    }
  }
}

로직은 특별한 게 없으므로 생소한 runWithPriority()만 짚고 넘어가겠습니다.

runWithPriority()scheduler에게 콜백 함수의 우선순위를 알려주고 실행을 요청하는 함수입니다. 해당 우선순위는 shceduler의 컨텍스트 변수인 currentPriorityLevel에 저장됩니다.

scheduler > Scheduler.js

function unstable_runWithPriority(priorityLevel, eventHandler) {
  switch (priorityLevel) {
    case ImmediatePriority:
    case UserBlockingPriority:
    case NormalPriority:
    case LowPriority:
    case IdlePriority:
      break
    default:
      priorityLevel = NormalPriority
  }

  var previousPriorityLevel = currentPriorityLevel
  currentPriorityLevel = priorityLevel

  try {
    return eventHandler()
  } finally {
    currentPriorityLevel = previousPriorityLevel
  }
}

이렇게 함으로써 Work와 관련된 작업의 실행과 우선순위를 모두 scheduler가 관리하게 되면서 reconcilerscheduler 사이에 서로 혼재될 수 있는 부분을 확실히 나누게 되었습니다.

이제 reconciler는 현재 진행되는 Work와 관련된 추가 작업이 필요할 때 scheduler의 컨텍스트 우선순위만 참고하면 됩니다. Work는 스케줄링된 순서대로 실행되는 것이 아니고 언제든지 중지되고 재실행 될 수 있기 때문에 재조정 작업만 할 줄 아는 reconciler는 더욱이 이 작업들의 우선순위를 가지고 있을 수 없습니다.

다음 포스트에서는 이렇게 넘겨준 Workscheduler가 받아 본격적으로 스케줄링하는 동작을 확인하게 됩니다.


오픈소스를 톺아보며 매직 코드라 생각했던 부분들의 동작 원리와 의미, 의도를 파악해보고 서로의 생각을 나누기 위한 블로그