(번역) React 스케줄러는 내부에서 어떻게 동작할까?

Taegyu Hwang·2024년 5월 28일
1
  • Jser.dev의 React Internals Deep Dive 시리즈How React Scheduler works internally?
    를 번역한 글입니다.
  • 원글, 본글 모두 react 18.2.0 버전 기준입니다.
  • React 코드의 주석이 아닌, 저자가 추가한 주석엔 '저자'를 앞에 붙였습니다.

1. React 스케줄러는 왜 필요할까요

첫 번째 에피소드(원문 링크, 번역)에서 이미 다뤘었던, 코드 조각(소스코드)부터 시작해봅시다.

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

React는 내부적으로 Fiber 트리의 각 Fiber에서 동작하며, workInProgress는 현재 위치를 추적하기 위해 사용됩니다. 순회 알고리즘은 다른 포스트에서 설명했습니다.

workLoopSync()는 동기로 동작하기 때문에 이해하기 매우 쉽습니다. 작업에 대한 방해가 없기 때문에 React는 while 루프에서 작업을 계속할 뿐 입니다.

동시성 모드(소스코드)에서는 상황이 다릅니다.

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

동시성 모드에서는 우선순위가 높은 작업이 낮은 우선순위의 작업을 중단시킬 수 있어야하기 때문에, 작업을 중단하고 재개할 수 있는 방법이 필요하며 그것이 바로 shouldYield()가 하는 역할입니다. 그러나 분명히 그 이상의 것이 있습니다.

2. 몇 가지 배경지식부터 시작해 봅시다.

2.1 이벤트 루프

솔직히 말해서, 저는 이것을 잘 설명할 수 없습니다. javascript.info를 읽거나, Jake Archibaid의 훌륭한 동영상을 보는 것을 추천하겠습니다.

간단히 말하면, 자바스크립트 엔진은 아래와 같은 동작을 합니다.

  1. 태스크 큐에서 태스크(매크로 태스크)를 가져와 실행합니다.
  2. 예약된 마이크로 태스트들이 있다면, 그것들을 실행합니다.
  3. 렌더링이 필요한지 확인하고, 수행합니다.
  4. 작업이 더 있으면 1을 반복하고, 아니라면 기다립니다.

loop는 매우 자명합니다. 왜냐하면, 실제로 일종의 루프가 존재하기 때문입니다.

2.2 렌더링을 차단하지 않고 새 작업을 스케줄하기 위한setImmediate()

렌더링(위의 3단계)을 막지 않고 작업을 스케줄하기 위한 방법으로 우리는 setTimeout(callback, 0) 트릭에 이미 익숙합니다. 이는 새로운 매크로 태스크를 예약합니다.

더 나은 API인 setImmediate()가 있지만, 이는 IE와 node.js에서만 사용할 수 있습니다.

setImmediate()가 더 나은 이유는 setTimeout()중첩 호출에서 최소 약 4ms의 지연을 가지는 반면, setImmediate()는 지연이 없기 때문입니다.

자, 이제 React Scheduler 소스 코드의 첫 부분을 살펴볼 준비가 되었습니다.

let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === "function") {
  // Node.js and old IE.
  // There's a few reasons for why we prefer setImmediate.
  //
  // Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
  // (Even though this is a DOM fork of the Scheduler, you could get here
  // with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
  // https://github.com/facebook/react/issues/20756
  //
  // But also, it runs earlier which is the semantic we want.
  // If other browsers ever implement it, it's better to use it.
  // Although both of these would be inferior to native scheduling.
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== "undefined") {
  // DOM and Worker environments.
  // We prefer MessageChannel because of the 4ms setTimeout clamping.
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

여기에서 우리는 setImmediate()의 두 가지 다른 fallback을 볼 수 있는데, MessageChannel과 setTimeout을 사용했다.

2.3 우선순위 큐

우선순위 큐는 스케줄링 할 떄 일반적으로 많이 사용하는 구조입니다. 자바스크립트로 우선순위 큐를 만들어보는 것을 추천합니다.

우선순위 큐는 React의 니즈에 완벽하 부합합니다. 서로 다른 우선순위의 이벤트가 들어오면, 처리할 가장 높은 우선순위를 가진 것을 빠르게 찾아야합니다.

React는 우선순위 큐를 최소 힙으로 구현합니다. 소스 코드는 여기서 볼 수 있습니다.

3.workLoopConcurrent의 콜스택

이제 workLoopConcurrent가 어떻게 호출되는지 살펴보겠습니다.

모든 코드는 ReactFiberWorkLoop.js에 있습니다. 하나씩 살펴보시죠.

ensureRootIsScheduled()는 여러번 접한 함수로, 꽤 여러 곳에서 사용됩니다. 이름에서 알 수 있듯이, ensureRootIsScheduled()는 업데이트 해야할 것이 있는 경우 React가 작업을 수행하도록 예약합니다.

주목할 점은 performConcurrentWorkOnRoot()를 직접 호출하지 않고, 이를 scheduleCallback(priority, callback)의 콜백으로 취급한다는 것입니다. scheduleCallback()스케줄러의 API입니다.

곧 스케줄러에 대해 자세히 살펴보겠지만, 지금은 스케줄러가 적절한 시기에 작업을 실행한다는 점만 기억해 두세요.

3.1 performConcurrentWorkOnRoot()는 중단되면 자신을 클로저로 반환합니다.

performConcurrentWorkOnRoot()는 진행 상황에 따라 다른 것을 반환합니다.

  1. shouldYield()가 true인 경우, workLoopConcurrent는 중단되며, 이는 update(RootInComplete)가 완료되지 않음을 의미합니다. 이 경우, performConcurrentWorkOnRoot()performConcurrentWorkOnRoot.bind(null, root)을 반환합니다. (코드 참조)
  2. 작업이 완료되면 null을 반환합니다.

shouldYield()에 의해 작업이 중단되면 어떻게 재개되는지 궁금할 것입니다. 네, 이것이 답입니다. 스케줄러는 작업 콜백의 반환 값을 보고 계속할 작업이 있는지 확인하며, 이 반환 값이 일종의 스케줄 조정입니다. 이에 대해서는 곧 다루겠습니다.

4. 스케줄러

드디어, 스케줄러의 영역에 진입했습니다. 겁내지 마세요. 저도 처음에는 겁이 났지만, 금방 그럴 필요 없다는 것을 깨달았습니다.

메세지 큐는 제어권을 넘겨주는 방식이며, 스케줄러도 정확히 이와 동일합니다.

위에서 언급한 scheduleCallback()은 스케줄러 세상에서 unstable_scheduleCallback입니다.

4.1 scheduleCallback() - 스케줄러는 만료시간(expirationTime)을 기준으로 스케줄링 합니다.

스케줄러가 작업을 스케줄링 하기 위해서 먼저 작업을 우선순위와 함께 저장해야 합니다. 이는 배경 지식으로 다뤘던 우선순위 큐에 의해서 수행됩니다.

스케줄러는 expirationTime을 이용해서 우선순위를 나타냅니다. 만료가 임박할수록 빨리 처리해야한다는 점은 공평합니다. 아래는 작업이 생성되는 scheduleCallback() 내부 코드입니다.

var currentTime = getCurrentTime();
var startTime;
if (typeof options === "object" && options !== null) {
  var delay = options.delay;
  if (typeof delay === "number" && delay > 0) {
    startTime = currentTime + delay;
  } else {
    startTime = currentTime;
  }
} else {
  startTime = currentTime;
}
var timeout;
switch (priorityLevel) {
  case ImmediatePriority:
    timeout = IMMEDIATE_PRIORITY_TIMEOUT;
    break;
  case UserBlockingPriority:
    timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
    break;
  case IdlePriority:
    timeout = IDLE_PRIORITY_TIMEOUT;
    break;
  case LowPriority:
    timeout = LOW_PRIORITY_TIMEOUT;
    break;
  case NormalPriority:
  default:
    timeout = NORMAL_PRIORITY_TIMEOUT;
    break;
}
var expirationTime = startTime + timeout;
// 저자) '작업'은 스케줄러가 처리하는 일의 단위입니다.
var newTask = {
  id: taskIdCounter++,
  callback,
  priorityLevel,
  startTime,
  expirationTime,
  sortIndex: -1,
};

코드는 매우 직관적입니다. 각 우선순위에 따라 다른 시간제한이 있으며, 여기에 정의되어 있습니다.

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
// 저자) 시간 제한의 기본값은 5초 입니다.
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

기본은 5초이며, 사용자 차단의 경우 250ms 입니다. 이 케이스에 대한 예시를 곧 볼 것입니다.

작업이 생성되었으니, 이제 우선순위 큐에 넣을 차례입니다.

if (startTime > currentTime) {
  // This is a delayed task.
  newTask.sortIndex = startTime;
  push(timerQueue, newTask);
  if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
    // All tasks are delayed, and this is the task with the earliest delay.
    if (isHostTimeoutScheduled) {
      // Cancel an existing timeout.
      cancelHostTimeout();
    } else {
      isHostTimeoutScheduled = true;
    }
    // Schedule a timeout.
    requestHostTimeout(handleTimeout, startTime - currentTime);
  }
} else {
  newTask.sortIndex = expirationTime;
  push(taskQueue, newTask);
  // Schedule a host callback, if needed. If we're already performing work,
  // wait until the next time we yield.
  if (!isHostCallbackScheduled && !isPerformingWork) {
    isHostCallbackScheduled = true;
    requestHostCallback(flushWork);
  }
}

맞습니다. 작업을 스케줄링할 때, setTimeout()과 같은 지연 옵션이 있을 수 있습니다. 이건 잠시 미뤄두고 나중에 다시 보시죠.

우선은 else 분기에만 집중해봅시다. 두 가지 중요한 호출을 볼 수 있습니다.

  1. push(taskQueue, newTask) - 작업을 큐에 추가합니다. 이는 우선순위 큐의 API일 뿐이니 넘어가겠습니다.
  2. requestHostCallback(flushWork) - 작업을 처리합니다!

requestHostCallback(flushWork)는 필수적입니다. 왜냐하면 스케줄러는 호스트에 종속되지 않고 어떤 호스트에서든 실행될 수 있는 독립적인 블랙박스이기 때문입니다. 그래서 request하는 것이 필요합니다.

4.2 requestHostCallback()

function requestHostCallback(callback) {
  scheduledHostCallback = callback;
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}

const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime();
    // Keep track of the start time so we can measure how long the main thread
    // has been blocked.
    startTime = currentTime;
    const 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.
    // 저자) 스케줄러가 다음 스케줄을 예약하며 큐의 작업을 계속 처리하는 것을 볼 수 있습니다. 여기에서 브라우저가 화면을 그릴 기회를 제공합니다.
    let 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
  // reset this.
  needsPaint = false;
};

2.2에서 언급했듯이 schedulePerformWorkUntilDeadline()은 단지 performWOrkUntilDeadline()의 래퍼일 뿐입니다.

scheduleHostCallbackrequestHostCallback()에서 설정되고 performWorkUntilDeadline()에서 바로 호출되는데, 이는 비동기적 특성으로 메인 스레드에 렌더링할 기회를 주기 위함입니다.

일부 세부 사항들을 무시하고, 여기서 가장 중요한 줄은 이것입니다.

hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime)

이것은 flushWork()(true, currentTime)과 함께 호출된다는 것을 의미합니다.

왜 여기에 true로 하드코딩되어 있는지 모르겠습니다. 아마도 리팩토링 실수 때문일 것 같습니다.

4.3 flushWork()

try {
  // No catch in prod code path.
  return workLoop(hasTimeRemaining, initialTime);
} finally {
  //
}

flushWorkworkLoop()를 감쌀 뿐 입니다.

4.4 workLoop() - 스케줄러의 핵심

재조정에서의 workLoopConcurrent()처럼, workLoop()는 스케줄러의 핵심입니다. 프로세스가 비슷하므로 이름도 비슷합니다.

if (
  currentTask.expirationTime > currentTime &&
  //                    (               )
  (!hasTimeRemaining || shouldYieldToHost())
) {
  // This currentTask hasn't expired, and we've reached the deadline.
  break;
}

workLoopConcurrent와 마찬가지로, shouldYieldToHost()를 확인합니다. 이 부분은 나중에 다루겠습니다.

const callback = currentTask.callback;
if (typeof callback === "function") {
  currentTask.callback = null;
  currentPriorityLevel = currentTask.priorityLevel;
  const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
  const continuationCallback = callback(didUserCallbackTimeout);
  currentTime = getCurrentTime();
  // 저자) 여기에서 작업의 반환값이 왜 중요한지 알 수 있습니다. 이 분기에서는 작업이 pop되지 않은 점에 주목하세요.
  if (typeof continuationCallback === "function") {
    currentTask.callback = continuationCallback;
  } else {
    if (currentTask === peek(taskQueue)) {
      pop(taskQueue);
    }
  }
  advanceTimers(currentTime);
} else {
  pop(taskQueue);
}

좀 더 파봅시다.

currentTask.callback는 이 경우 실제로는 performConcurrentWorkOnRoot()입니다.

const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
const continuationCallback = callback(didUserCallbackTimeout);

이 함수는 만료 여부를 나타내는 플래그와 함께 호출됩니다.

performConcurrentWorkOnRoot()는 타임아웃이 되면 동기모드로 돌아갑니다. (코드) 이는 이제부터는 중단 없이 작업이 계속 진행되는 것을 의미합니다.

const shouldTimeSlice =
  !includesBlockingLane(root, lanes) &&
  !includesExpiredLane(root, lanes) &&
  (disableSchedulerTimeoutInWorkLoop || !didTimeout);
let exitStatus = shouldTimeSlice
  ? renderRootConcurrent(root, lanes)
  : renderRootSync(root, lanes);

좋습니다. workLoop()로 돌아가죠.

if (typeof continuationCallback === "function") {
  currentTask.callback = continuationCallback;
} else {
  if (currentTask === peek(taskQueue)) {
    pop(taskQueue);
  }
}

여기서 중요한 점은, 콜백의 반환값이 함수가 아닐 때만 작업이 큐에서 제거된다는 것입니다. 반환값이 함수인 경우 작업의 콜백만 업데이트되며, 작업이 큐에서 제거되지 않기 때문에 다음 workLoop() 주기에서 동일한 작업이 다시 실행됩니다.

이 말은, 콜백의 반환값이 함수인 경우 해당 작업이 완료되지 않았음을 의미하며, 다시 작업을 수행해야 한다는 것을 의미합니다.

advanceTimers(currentTime)

이것은 작업을 지연시키기 위함입니다. 나중에 다시 보시죠.

4.5 shouldYield()는 어떻게 동작할까?

소스코드

function shouldYieldToHost() {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    // The main thread has only been blocked for a really short amount of time;
    // smaller than a single frame. Don't yield yet.
    return false;
  }
  // The main thread has been blocked for a non-negligible amount of time. We
  // may want to yield control of the main thread, so the browser can perform
  // high priority tasks. The main ones are painting and user input. If there's
  // a pending paint or a pending input, then we should yield. But if there's
  // neither, then we can yield less often while remaining responsive. We'll
  // eventually yield regardless, since there could be a pending paint that
  // wasn't accompanied by a call to `requestPaint`, or other main thread tasks
  // like network events.
  if (enableIsInputPending) {
    if (needsPaint) {
      // There's a pending paint (signaled by `requestPaint`). Yield now.
      return true;
    }
    if (timeElapsed < continuousInputInterval) {
      // We haven't blocked the thread for that long. Only yield if there's a
      // pending discrete input (e.g. click). It's OK if there's pending
      // continuous input (e.g. mouseover).
      if (isInputPending !== null) {
        return isInputPending();
      }
    } else if (timeElapsed < maxInterval) {
      // Yield if there's either a pending discrete or continuous input.
      if (isInputPending !== null) {
        return isInputPending(continuousOptions);
      }
    } else {
      // We've blocked the thread for a long time. Even if there's no pending
      // input, there may be some other scheduled work that we don't know about,
      // like a network event. Yield now.
      return true;
    }
  }
  // `isInputPending` isn't available. Yield now.
  return true;
}

사실 별로 복잡하지 않습니다. 주석에 모든 것이 설명되어있어요. 가장 기본적인 코드는 아래와 같습니다.

const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
  // The main thread has only been blocked for a really short amount of time;
  // smaller than a single frame. Don't yield yet.
  return false;
}
return true;

각 작업은 5ms(frameInterval)을 부여받으며, 시간이 다 되면 양보해야합니다.

이것은 task를 스케줄러에서 실행하기 위함이며, 각 performUnitOfWork()를 위한 것이 아님을 주목하세요. startTimeperformWorkUntilDeadline()에서만 설정되는 것을 볼 수 있는데, 이것은 각 flushWork() 마다 리셋된다는 것을 의미합니다. 그리고 여러 작업이 flushWork()에서 처리될 수 있는 경우, 그 사이에는 중단이 없습니다.

React 퀴즈를 이해하는 데 도움이 될 것입니다.

5. 요약

휴, 많았습니다. 전반적인 과정을 그렸습니다.

아직 몇 가지 누락된 부분이 있지만, 큰 진전을 이뤘습니다. 이 내용이 React 내부 구조를 더 잘 이해하는데 도움이 되기를 바랍니다. 이미 큰 다이어그램이 되었으니 다른 내용은 다음 에피소드 들에서 다뤄보도록 하겠습니다.

0개의 댓글