Profile Picture

윤찬의 개발노트

2025. 4. 30.

React 만들기 - scheduler

React.jsJavaScript
"꾸준한 성장이 나중에는 큰 변화를 만든다"

schedule(Sync)Callback

잘보면, Scheduler_scheduleCallback 함수를 모두 호출하고 있다. reconciler를 만들때는 약식으로 그냥 반환만 하는 scheduleCallback 함수로 만들었지만

function scheduleCallback(priority, callback, timeout) {
  console.log("스케줄 콜백:", { priority });
  return setTimeout(callback, timeout); // 즉시 실행 (최단 시간 후)
}

실제로는 이부분에서 부터, task를 우선순위에 따라 언제 실행할지 예약한다. 코드에서는 Scheduler_scheduleCallback함수는 unstable_scheduleCallback로 정의되어있다. (아래참고)

unstable_scheduleCallback 함수를 보면, 현재 시간을 기준으로 startTimeexpirationTime을 계산한다.

  • startTime : 언제 작업을 가장빨리 시작할 수 있는지
  • expirationTime : 언제까지 해당 작업이 완료되어야 하는지

위와같이 여러 분기문에 따라, 각각의 기준으로 언제 작업을 진행할지 스케쥴링하는데, unstable_scheduleCallback는 결국 callback을 등록하고, 정해진 시간에 실행될 수 있도록 예약하는 역할을 한다. 만약에 예시로 A라는 작업이 있다고 해보자.

  • currentTime이 1000ms라고 가정
  • A 작업을 등록할 때 delay: 200ms, timeout: 5000ms 옵션을 넘긴다고 가정

이렇게 되면, 내부적으로 unstable_scheduleCallback

위와같이, startTime = currentTime + delay = 1000 + 200 = 1200 (ms), expirationTime = startTime + timeout = 1200 + 5000 = 6200 (ms)로 계산을 해서, 아래와 같은 결론을 낸다.

A 작업은 1200ms부터 실행 가능하고, 6200ms를 넘기면 만료

그러고 나서, callback을 Task에 담아서, heap에 추가하고, fulshWork를 비동기 api에 전달한다.


scheduler 내부의 핵심 루프

task queue에 있는 작업을 우선순위와 deadline 기준으로 꺼내서 실행하는 동작을 하는데, 먼저 task queue에서 가장 앞의 작업을 가져온다. 이후, 현재 task가 존재하고 디버깅으로 멈춰있지 않는다면 루프를 실행하는데, 해당 루프에서 만약, 작업이 만료되지 않았거나, 브라우저에 제어권을 넘겨야 하는 경우 중단한다. 아닌 경우에는 현재 작업의 콜백 함수를 가져와 순차적으로 실행하며, 할당된 콜백을 실행 → 결과에 따라 다시 큐에 넣거나 제거 → 타이머 업데이트 및 다음 task 준비 다음의 과정을 계속 수행하게 된다.

이 과정을 반복하며 task queue에 남은 작업이 없을 때까지 순차적으로 처리하게 되며, 만약 아직 실행되지 않은 작업이나 예약된 타이머가 존재한다면, 다음 실행 타이밍에 맞춰 requestHostTimeout을 통해 다시 스케줄링된다.

사실 workloop에서 자세히 볼 부분은, requestHostTimeout이다.

아직 실행되지 않은 작업이 존재한다면, 해당 작업의 startTime에 맞춰서, 다시 작업을 scheduling한다. 즉, 나중에 실행할 작업이 예약되어 있다면 해당 시간에 맞춰서 flushWork를 다시 호출하도록 해준다. 지금까지의 흐름을 도식화 하면 다음과 같다.

위의 도식화에서는 사실 requestHostTimeout으로 작업을 예약 하는 부분에서는 두개로 분기처리가 된다. 현재는 시간 지연이 필요한 작업 requestHostTimeout로 처리될 때만 나타냈는데, 만약 작업이 즉시 실행이 필요하다면 requestHostCallback로 인해 처리된다.


react와 브라우저의 작업 처리 방식

requestHostCallback은 작업을 다음 브라우저 tick에 실행하게 하는 일을 한다. 이때, performWorkUntilDeadline에서 실제 작업이 수행된다. performWorkUntilDeadline에서는 react가 cpu작업을 쪼개고, 브라우저의 프레임 렌더링을 방해하지 않고, 다음 이벤트 루프에서 이어서 처리하도록 설계되어있다.

작업은 5ms 단위로 쪼개어 수행한 후, needsPaint = false로 설정하여 이번 루프에서 충분히 작업했음을 표시하고 브라우저가 렌더링할 수 있도록 제어권을 넘긴다. 이후 port.postMessage(null)을 호출해 메시지 큐에 메시지를 등록하면, 메시지 채널의 onmessage 핸들러인 performWorkUntilDeadline이 다음 이벤트 루프에서 다시 호출된다. 이로써 React는 남은 작업을 브라우저 프레임 사이에 끼워 넣어 이어서 처리할 수 있게 된다. 이렇게 쪼개는 주 이유는 다음과 같다.

렌더링, 사용자 이벤트, js는 모두 같은 메인스레드에서 처리된다.

그렇기 때문에, js가 계속 CPU를 점유하면, 브라우저는 사용자 입력이나 화면 갱신을 계속 처리할 수 없게 되기 때문이다. react가 브라우저에 cpu를 양보하는 행위를 yield라고 하며, 브라우저가 잠시 여유있을때 react가 재정비하고 작업을 처리하는 것을 idle이라고 하는데 이렇게 yield, idle 환경을 번갈아가면서 하나의 메인 스레드에서 동작할 수 있는 것이다.


이후 작업 요약

이러한 모든 과정을 scheduling 작업을 마치게 되면, react는 다시 reconciliation 과정에 들어가며, 변경된 가상돔을 실제 돔에 반영할 수 있게 된다. 이 과정은 모든 작업이 끝나고 이후 실제 돔에 반영하는게 아닌, 계속 yield상태일때, 주기적으로 쪼개진 동작에서 브라우저가 계속 업데이트를 거친다. 브라우저 렌더링은 예전 학부때, 포스팅을 했었다.

브라우저 렌더링


react craft scheduler 구현 및 마무리

(performWorkUntilDeadline의 일부분)

function performWorkUntilDeadline() {
  console.log("performWorkUntilDeadline 호출");

  while (taskQueue.length > 0) {
    const currentTime = Date.now();
    const task = taskQueue[0];

    if (task.startTime <= currentTime && task.expirationTime >= currentTime) {
      console.log("작업 실행: Priority=", task.priority);

      // 작업을 5ms씩 나눠서 실행
      const workDuration = 5;
      let start = Date.now();
      while (Date.now() - start < workDuration) {
        // 실제 작업 실행
        task.callback();
      }

      // 작업이 완료되었으면 큐에서 제거
      taskQueue.shift();
      console.log("작업 실행 후 큐 :", [...taskQueue]);

      // 5ms 대기 후, 브라우저로 양보
      setTimeout(performWorkUntilDeadline, 5);
      break;
    } else if (task.expirationTime < currentTime) {
      console.log("작업 만료", task.priority);
      taskQueue.shift();
    } else {
      console.log("작업 대기:", task.priority);
      break;
    }
  }
}

react craft에서는 간단하게, performWorkUntilDeadline 를 중점으로 적용했다. 우선 우선순위에 따라, 작업을 task queue에 적용하고, performWorkUntilDeadline에서 각 작업은 5ms 동안 실행되며, 이 시간을 초과하면 setTimeout을 사용하여 브라우저에 제어권을 넘져주는 것처럼 처리를 해봤다. 즉, 일정시간마다 작업을 task queue에 담긴 순서대로 처리하며 스케쥴링하는 부분을 약식으로 구현해볼 수 있었다.

hook부터 reconciler가 scheduler를 호출하고, 콜백을 실행하기까지의 흐름 속에서 scheduler가 각 작업의 우선순위를 어떻게 처리하고, 리액트와 브라우저가 CPU를 어떻게 양보하며 협력하는지를 살펴볼 수 있었다. 비록 모든 과정을 완벽하게 구현하긴 어렵지만, 강조하고 싶었던 핵심 로직들은 약식으로나마 직접 구현해볼 수 있었고, 이를 통해 리액트 내부의 동작 원리를 좀 더 깊이 이해할 수 있었다.

Profile Picture

CHAN

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

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