영문 블로그 글을 번역했습니다. 허가를 받으면 시리즈를 이어갈 예정입니다.
원문링크:https://jser.dev/react/2022/03/16/how-react-scheduler-works
ℹ️React Internals Deep Dive에피소드 20,유튜브에서 제가 설명하는 것을 시청해주세요.
⚠React@18.2.0기준, 최신 버전에서는 구현이 변경되었을 수 있습니다.
💬 역자 주석: JSer의 코멘트는 ❗❗로 표시 해뒀습니다.
그 외 주석은 리액트 소스 코드 자체의 주석입니다.
... 은 생략된 코드입니다.
1. 리액트 스케쥴러가 필요한 이유
이 시리즈의 첫 번째 에피소드에서 이미 다룬 바 있는 다음 코드(소스)부터 시작하겠습니다.
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
한마디로 React는 내부적으로 파이버 트리의 각 파이버에서 작동하며, workInProgress는 현재 위치를 추적하는 것이고, 순회 알고리즘은 이전 포스트에서 이미 설명했습니다.
workLoopSync()는 동기식이기 때문에 작업을 중단할 수 없으므로 React는 잠시 동안 루프 내에서 계속 작업하기만 하면 됩니다.
동시 모드에서는 상황이 달라집니다(소스).
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
동시 모드에서는, 우선순위가 높은 작업이 우선순위가 낮은 작업을 중단할 수 있으므로 작업을 중단하고 다시 시작할 수 있는 방법이 필요하며, 이를 위해 shouldYield() 가 트릭을 수행하지만, 분명히 그 이상의 기능이 있습니다.
2. 먼저 몇 가지 배경지식에서부터 시작하겠습니다
2.1 이벤트 루프
솔직히 설명이 잘 안 되니 javascript.info에서 설명을 읽어보시거나 Jake Archibald의 멋진 동영상을 시청하시기 바랍니다.
간단히 말해, 자바스크립트 엔진은 다음과 같은 작업을 수행합니다.
태스크 큐(Task Queue)에서 작업(매크로 작업)을 가져와 실행합니다.
예약된 마이크로 태스크가 있으면, 실행합니다.
렌더링이 필요한지 확인하고 수행합니다.
작업이 더 있으면 1을 반복하거나 더 많은 작업을 기다립니다.
실제로 일종의 루프가 있기 때문에 loop라는 용어는 매우 명확합니다.
렌더링을 차단하지 않고 일부 작업을 예약하기 위해(위의 3번째 단계), 우리는 이미 setTimeout(callback, 0)의 트릭에 익숙해져 있으며, 이는 새로운 매크로 작업을 예약합니다.
이벤트 개선 API인 setImmediate()가 있지만 IE와 node.js에서만 사용할 수 있습니다.
setTimeout()은 중첩 호출에서 실제로 최소 약 4ms의 지연이 있는 반면, setImmediate()는 지연이 없으므로 더 좋습니다.
이제 React Scheduler(소스)의 첫 번째 코드를 만질 준비가 되었습니다.
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === "function") {
schedulePerformWorkUntilDeadline = () => {
localSetImmediate(performWorkUntilDeadline);
};
} else if (typeof MessageChannel !== "undefined") {
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
schedulePerformWorkUntilDeadline = () => {
localSetTimeout(performWorkUntilDeadline, 0);
};
}
여기에서는 두 가지 다른 setImmediate()의 폴백(fallback)을 볼 수 있는데, MessageChannel과 setTimeout이 있습니다.
2.3 Priority Queue
우선순위 큐는 스케줄링을 위한 일반적인 데이터 구조입니다. 직접 자바스크립트로 우선순위 큐를 직접 만들어 보시기 바랍니다.
이는 React의 요구사항에 완벽하게 부합합니다. 우선순위가 다른 이벤트가 들어오기 때문에 처리할 우선순위가 가장 높은 이벤트를 빠르게 찾아야 합니다.
React는 min-heap으로 우선순위 큐를 구현하며, 소스 코드는 여기에서 확인할 수 있습니다.
3. workLoopConcurrent의 콜 스택
이제, workLoopConcurrent가 어떻게 호출되는지 한번 보도록 하죠.

모든 코드는 ReactFiberWorkLoop.js에 있으며, 이를 분석해 보겠습니다.
우리는 ensureRootIsScheduled()를 여러 번 만났고, 꽤 많은 곳에서 사용하고 있습니다. 이름에서 알 수 있듯이 ensureRootIsScheduled()는 업데이트가 있는 경우 React가 작업을 수행하도록 예약합니다.
performConcurrentWorkOnRoot()를 직접 호출하지 않고 scheduleCallback(priority, callback)을 통해 콜백으로 처리한다는 점에 유의하세요. scheduleCallback()은 스케줄러의 API입니다.
곧 스케줄러에 대해 자세히 살펴보겠지만, 지금은 스케줄러가 적절한 시간에 작업을 실행한다는 점만 기억하세요.
진행 상황에 따라 performConcurrentWorkOnRoot()가 다르게 반환 되는 것을 보셨나요?
shouldYield()가 참이면 workLoopConcurrent가 중단되어 불완전한 update(RootInComplete)가 발생하고, performConcurrentWorkOnRoot()는 performConcurrentWorkOnRoot.bind(null, root)를 반환합니다. (코드)
완료되면, null 을 반환합니다.
작업이 shouldYield()에 의해 중단된 경우 어떻게 다시 시작될 수 있는지 궁금할 수 있습니다. 네, 이것이 답변입니다. 스케줄러는 작업 콜백의 반환값을 보고 작업이 계속되는지 확인하며, 반환 값은 일종의 리스케쥴링입니다. 이에 대해서는 곧 다룰 예정입니다.
4. Scheduler
마지막으로, 스케줄러의 영역에 들어섰습니다. 처음에는 겁이 났지만 곧 불필요하다는 것을 깨달았으니 부담스러워하지 마세요.
메시지 큐(Message Queue)는 제어권을 전달하는 방법이고, 스케줄러는 정확히 이와 같은 역할을 합니다.
위에서 언급한 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,
};
코드는 매우 간단하며, 각 우선순위에 따라 다른 시간 제한이 있으며 여기에 정의되어 있습니다.
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;
따라서 기본적으로 5초의 시간 제한이 설정되어 있으며 사용자 차단에 대해서는 250ms가 설정되어 있습니다. 곧 이러한 우선순위에 대한 몇 가지 예를 살펴보겠습니다.
작업이 생성되었으니 이제 우선순위 큐에 넣을 차례입니다.
if (startTime > currentTime) {
newTask.sortIndex = startTime;
push(timerQueue, newTask);
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
if (isHostTimeoutScheduled) {
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else {
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
}
}
아 맞다, 작업을 예약할 때 setTimeout() 과 같은 지연 옵션이 있을 수 있습니다. 이 부분은 따로 보관해 두었다가 나중에 다시 살펴보겠습니다.
else 브랜치에만 집중하세요. 두 가지 중요한 호출을 볼 수 있습니다.
push(taskQueue, newTask) - 큐에 작업을 추가합니다. 이것은 우선순위 큐 API일 뿐이므로 그냥 건너뛰겠습니다.
requestHostcallback(flushWork) - 작업들을 처리합니다!
requestHostCallback(flushWork)는 필수인데, 왜냐하면 스케줄러는 호스트에 구애받지 않고 모든 호스트에서 실행될 수 있는 독립적인 블랙박스에 불과하므로, 요청해야 합니다.
4.2 requestHostCallback()
function requestHostCallback(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
startTime = currentTime;
const hasTimeRemaining = true;
let hasMoreWork = true;
try {
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
} finally {
if (hasMoreWork) {
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
scheduledHostCallback = null;
}
}
} else {
isMessageLoopRunning = false;
}
needsPaint = false;
};
2.2에서 언급했듯이 schedulePerformWorkUntilDeadline()은 performWorkUntilDeadline()의 래퍼일 뿐입니다.
scheduledHostCallback은 requestHostCallback()에서 설정되고 performWorkUntilDeadline()에서 바로 호출되는데, 이는 비동기 특성 때문에 메인 스레드가 렌더링할 기회를 주기 위한 것입니다.
몇 가지 세부 사항은 무시하고 가장 중요한 대목을 소개합니다.
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime)
즉, flushWork()는 (true, currentTime)과 함께 호출됩니다.
왜 여기에 true로 하드코딩되어 있는지 모르겠습니다. 아마도 리팩토링 실수 때문일 수 있습니다.
4.3 flushWork()
try {
return workLoop(hasTimeRemaining, initialTime);
} finally {
}
flushWork 는 workLoop()를 감쌌을 뿐입니다.
4.4 workLoop() - 스케쥴러의 핵심
조정의 workLoopConcurrent() 와 마찬가지로 스케줄러의 핵심은 workLoop()입니다. 프로세스가 비슷하기 때문에 이름이 비슷합니다.
if (
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
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();
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) {
return false;
}
if (enableIsInputPending) {
if (needsPaint) {
return true;
}
if (timeElapsed < continuousInputInterval) {
if (isInputPending !== null) {
return isInputPending();
}
} else if (timeElapsed < maxInterval) {
if (isInputPending !== null) {
return isInputPending(continuousOptions);
}
} else {
return true;
}
}
return true;
}
사실 복잡하지 않고 주석에 모든 것이 설명되어 있습니다. 가장 기본적인 라인은 다음과 같습니다.
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
return false;
}
return true;
따라서 각 작업에는 5ms(frameInterval)가 주어지며, 시간이 다 되면 양도해야 합니다.
이것은 스케줄러에서 task를 실행하기 위한 것이지 각 performUnitOfWork()에 대한 것이 아니라는 점에 유의하세요. startTime은 performWorkUntilDeadline()에서만 설정되므로 각 flushWork()에 대해 재설정되며, 여러 작업이 flushWork()에서 처리될 수 있는 경우 그 사이에는 양도가 없다는 것을 알 수 있습니다.
이것은 아래의 리액트 퀴즈를 이해하는 데 도움이 될 것입니다.
💬 퀴즈는 직접 JSer의 블로그 글에서 풀어보세요! 링크
5. 요약
휴, 이건 많았네요. 전체 다이어그램을 그려 보겠습니다.

아직 몇 가지 누락된 부분이 있지만 큰 진전이 있었습니다. React 내부를 더 잘 이해하는 데 도움이 되었기를 바랍니다. 이미 소화하기에는 너무 큰 다이어그램이니, 다른 내용은 다음 에피소드에서 다루도록 하겠습니다.
(원본 게시일: 2022-03-16)