본문 바로가기

개발/React

React Event Loop Deep Dive

 

 

 

해당 글은 이전 글의 부록으로 React 18에서 해결한 React Event Loop 가 나오기까지 어떤 고민이 있었고, 어떤 방식으로 구현이 되었는지를 코드와 함께 확인해 보겠습니다.

 

목차

  1. 이벤트 루프
  2. 이벤트 루프를 활용한 기본 함수 실행
  3. React의 Scheduler
  4. Lane Model에 따른 이벤트 처리순서
  5. 정리

 

1. 이벤트 루프

리액트 역시 JS가 기반이기 때문에 싱글 스레드의 특성에서 벗어날 수 없습니다. 따라서 먼저 JS 환경에서 Event Loop에 대해 리마인드 해보겠습니다.

 

 이벤트 루프JS의 싱글 스레드라는 한계를 극복하기 위해 오랜 시간이 걸리는 작업은 일반적으로 비동기적으로 작업을 처리함으로써 블로킹을 피하고 다양한 작업을 수행할 수 있도록 하여 JS가 여러 가지 작업을 어떻게 처리할지에 대해 정하고 이를 순서에 맞게 동작시키는 메커니즘입니다.

 이는 들어온 작업을 순서대로 쌓아두는 Call Stack, 비동기 작업에 대한 결과를 알맞은 때에 수행할 수 있도록 작업을 담아두는 Task Queue (Callback Queue)로 이루어져 있습니다. 

 기본적으로는 Call Stack에 쌓여있는 작업들을 수행하고, 수행한 작업은 Stack에서 제거합니다. 이러한 작업이 반복된 이후 Call Stack이 비어있다면 비동기 작업에 대한 수행 결과를 가지고 있는 Task Queue에서 Task를 꺼내 Call Stack으로 옮겨 해당 Task를 실행할 수 있도록 합니다.

 

다시 한번 정리해 보면

  • 작업이 Call Stack에 쌓이고, 작업은 수행되면서 Stack에서 제거됩니다.
  • Event Loop는 작업 수행 관리자로서, 수행되는 작업이 쌓여있는 Call Stack이 비어있는지를 확인하고 비어있다면 비동기 작업으로부터 받아온 Task가 담겨있는 Task Queue에서 작업을 꺼내 Call Stack으로 옮겨줍니다.
  • Call Stack에 새로운 Task가 추가됨에 따라 작업을 수행합니다.

 

위 작업을 반복하도록 돕는 작업 수행 관리자가 Event Loop이고, 이벤트를 모두 수행할 때까지 루프를 돌기 때문에 이를 Event Loop라고 부릅니다.

 

조금만 더 깊게 들어가 보겠습니다. 

 

비동기 작업으로부터 받아온 Task 가 담겨있는 Task Queue는 사실 2가지 Queue로 나누어져 있습니다.

 

  • MacroTask Queue - 일반적인 비동기 작업으로부터 받아온 Task가 담기는 Queue입니다. 일반적인 비동기 작업의 예시는 다음과 같습니다.
    • script 태그 로드 후 실행
    • 사용자 이벤트
    • setTimeout과 같은 일반적인 비동기 함수로부터의 콜백
  • MicroTask Queue - Promise로부터 받아온 Task가 담기는 Queue입니다. 이는 Call Stack이 비어 Task Queue로부터 수행할 작업을 Call Stack으로 이동시킬 때, 일반적인 비동기 작업으로부터 받아온 Task가 담기는 MacroTask Queue의 작업보다 먼저 수행됩니다.

 

2. 이벤트 루프를 활용한 기본 함수 실행

 

 그렇다면 브라우저에서 일반적으로 작업을 수행할 때에 어떤 일이 발생하는지 알아보겠습니다.

 

 만약 script 실행, click event, setTimeout 3가지 Task가 순차적으로 들어왔다면, 이는 모두 비동기 작업으로 이에 대한 수행작업은 모두 MacroTask Queue에 쌓이고, Call Stack이 비어있을 때 순차적으로 작업이 MacroTask Queue에서 Call Stack으로 이동되어 수행됩니다. 이때 하나씩 Task가 수행되는 과정에서 JS는 싱글 스레드이기 때문에 Task를 처리하는 동안은 렌더링이 일어나지 않습니다. 즉, 해당 Task를 처리하는 데에 긴 시간이 걸린다면 브라우저는 다음 동작을 수행할 수 없기 때문에 Blocking에 걸리게 됩니다.

 

아래와 같은 함수를 실행하는 경우에도 해당 Task를 처리하는 데에 긴 시간이 걸리기 때문에 브라우저는 Blocking에 걸리게 됩니다.

 

// 출처: https://ko.javascript.info/event-loop
let i = 0;

function count() {
  for (let j = 0; j < 1e9; j++) {
    i++;
  }
}

count();

 

 

잠시 잊으셨을 수 있겠지만 이 글은 React팀이 React 18에서 대규모 전환 작업을 처리하는 상황에서 여러 가지 이벤트를 처리하기 위해 고민한 과정을 파헤쳐보는 것입니다. 대규모 전환 작업을 수행하면서도 브라우저에게 적절한 시기에 제어권을 넘겨주는 것이 React 18에서 해결하려고 했던 문제인데요, 그렇다면 React 팀이 문제를 해결하기 위해 고민했던 방법을 따라가기 위해 위와 같은 간단한 예시를 통해 Blocking 해결하는 방식을 간단히 엿보겠습니다. 이런 상황에서는 어떻게 해야 해당 루프를 진행함에 있어서 다른 이벤트가 들어왔을 때 처리해 줄 수 있을까요?

 

가장 간단하고 권장하는 방법으로는 대규모 작업을 쪼개고, 이를 Task Queue를 활용하여 스케줄링하는 방법입니다. 

 

 

// 출처는 위와 동일합니다.
let i = 0;

function count() {
  do {
    i++;
  } while (i % 1e6 != 0);
  
  if (i == 1e9) {
    alert("작업이 끝났습니다.");
  } else {
    setTimeout(count);
  }
}


count();

 

 

위와 같이 변경하게 된다면 어떤 것이 달라질까요?

 

  • 먼저 1e9까지 돌던 루프를 1e6으로 줄여 태스크를 쪼갰습니다. 따라서 렌더링을 Blocking 하면서 한 번에 수행하는 작업이 줄었습니다.
  • 하나의 작업을 모두 수행한 다음, setTimeout을 통해 다음에 수행할 작업을 스케줄링합니다. 
  • 이때 처음 루프를 도는 동안 추가된 User Event가 있다면 이는 Task Queue에 쌓여 수행가능한 시점을 기다립니다.
  • 1e6까지의 루프가 수행된 후 루프를 빠져나오며 스케줄링되는 setTimeout(count)도 Task Queue에 쌓이게 되고, 이 setTimeout이 수행되기 전 먼저 예약된 User Event를 수행합니다.

 

따라서 위와 같이 처리하게 된다면, 해당 작업을 수행하면서도 중간에 발생한 User Event에 대해 Blocking 하지 않게 됩니다.

 

위 방식의 아이디어는 훌륭하지만 우리는 이벤트를 다룰 때 이러한 방식을 사용하지 않습니다. 왜일까요?

 

위 방식은 기본적으로 setTimeout을 통해 여러 가지 동작을 연속적으로 스케줄링할 경우, 2번째 인자로 넘겨주는 delay를 0으로 하더라도 task가 5회 이상 중첩된다면 최소 4ms의 delay가 발생하게 됩니다. (4ms clamping issue라고 부릅니다.) 따라서 이 방식은 완벽하고 빠르게 이벤트에 대응하기 위해 사용하기에는 올바르지 않고, 작업이 언제 끝나는지 예측하기 어려워 사용을 지양합니다.

 

(4ms clamping issue는 해당 스펙에서 확인하실 수 있습니다. https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers:concept-task-2)

 

 

3. React 팀의 고민

 

 

그렇다면 위 Rendering Blocking을 간단하게나마 개선해 본 경험을 토대로 React팀이 마주했던 문제를 같이 들여다보겠습니다.

 

결국 React에서 수행하는 모든 로직도 이벤트를 기반으로 동작하며, 이를 어떻게 스케줄링하여 메인 스레드를 Block 하지 않을지에 대한 것이 관건이었습니다. 이벤트 루프를 수행하면서 높은 응답성을 제공하기 위해 React팀이 고민한 부분은 동시성을 도입한 것이었는데요 이때 가장 중요하게 생각한 포인트는 다음과 같았습니다.

수행 중인 작업이 있다면 해당 작업을 중지하고 브라우저에게 메인 스레드를 넘겨줄 수 있어야 한다.

 

그렇다면 수행 중인 작업을 멈추기만 하면 될까요? 멈춘 작업을 이후에 다시 수행할 수도 있어야합니다. 그러려면 어느 작업을 수행하다가 중단했는지를 알아야겠지요.

 

이를 위해서 React팀은 Reconciliation이 일어나는 과정 속에서 각 Node를 Fiber객체로 매핑하여 각 작업마다 중지 및 재실행이 가능한 구조로 만들어두어 기반을 다졌습니다.

 

또한 수행중인 작업이 중지할 수 있다면 언제 중지할지에 대한 것이 중요한데요, React의 Renderer로 넘어간다면 렌더링이 수행되어 작업을 멈출 수 없기 때문에 렌더링을 수행하기 전에 이를 판단하기 위해 개입해야 했습니다. 따라서 React팀은 렌더링 파이프라인 내에 개입할 수 있는 방법을 고민했습니다.

 

 

일반적으로 렌더링 파이프라인에 개입할 수 있는 방법은 두 가지 방법이 있는데, 이는 다음과 같습니다.

  1. requestAnimationFrame ( Draw Callback )
  2. requestIdleFrame ( Idle Callback )

 

requestAnimationFrame

 

requestAnimationFrame은 기본적으로 레이아웃을 계산하기 전 DOM을 수정할 때 주로 사용하는 메서드입니다. 하지만 이는 layout까지 남은 시간을 알 수 없기 때문에 연산을 중지시키는 데에는 한계가 있습니다. 또한 이 단계에서 많은 시간을 소요하게 된다면 이 역시도 렌더링을 Blocking 할 수 있게 됩니다.

 

requestIdleCallback

 

requestIdleCallbackPaint단계까지 진행한 후 다음 프레임을 수행하기 전까지 남은 시간(Idle Time) 동안 작업을 수행할 수 있도록 해줍니다. 이로 인해 렌더링은 Block 하지 않고 남은 시간에 작업을 수행한다는 점에서 위의 문제를 해결하는 데에 적합한 역할을 가지고 있습니다.

 

일반적으로 requestIdleCallback을 사용할 때에는 작업을 scheduling 하여 rendering이 완료되고 유휴시간이 있는 경우에 수행할 수 있도록 합니다. 이는 인자로 들어오는 deadLine 정보의 timeRemaining 메서드를 활용하면 가능합니다. 또한 이를 활용하게 된다면 렌더링 작업을 작은 단위로 나누어 여러 Time Frame에 분산하여 Rendering을 진행할 수 있도록 합니다.

 

// 출처: https://tv.naver.com/v/23652451
function jobLoop(deadLine) {
  while (job && deadline.timeRemaining() > 0) {
    job = performJob(job)
  }
	
  requestIdleCallback(jobLoop)
}

requestIdleCallback(jobLoop)

 

 

하지만 역할로서는 완벽하던 이 메서드 역시 문제가 있었습니다.

 

먼저 해당 메서드는 기본적으로 Safari, IE에서는 지원하지 않습니다. (https://developer.mozilla.org/ko/docs/Web/API/Window/requestIdleCallback#%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80_%ED%98%B8%ED%99%98%EC%84%B1)

또한 이는 유휴시간에만 동작하기 때문에 렌더링 과정 내 한 프레임 안에서 시간이 남지 않으면 수행될 수 없는 문제가 있었습니다. 따라서 호출 주기가 일정하지 않고 불안정하다는 점이 문제가 되었습니다.

 

따라서 React는 requestIdleCallback과 유사한 Schduler를 만들게 되었습니다.

 

 

4. React의 Scheduler

 

React가 어떤 Scheduler를 만들었는지 알기 위해서는 직접 소스코드를 보는 것이 가장 빠릅니다. github react repository에 존재하는 소스코드를 보면서 필요한 부분을 분석하고, 이에 대해 파악해보겠습니다.

(https://github.com/facebook/react/blob/1d5667a1273386f84e416059af7b6aba069e068e/packages/scheduler/src/forks/Scheduler.js#L620)

 

 

 

실제로 React의 Scheduler가 동작하는 순서는 React에서 render가 발생한 뒤에는 아래와 같은 함수 실행이 진행됩니다.

 

  1. requestHostCallback
  2. schedulePerformWorkUntilDeadline
  3. performWorkUntilDeadline
  4. flushWork
  5. workLoop

 

이를 하나씩 순서대로 살펴보겠습니다.

 

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

 

 

위 함수의 내용을 보면 현재 루프가 실행중인지에 대한 여부(isMessageLoopRunning)를 체크하고, 루프가 돌고있지 않다면 이에 대한 플래그를 변경해주고 루프를 실행합니다. (schedulePerformWorkUntilDeadline)

 

let getCurrentTime: () => number | DOMHighResTimeStamp;
const hasPerformanceNow =
  typeof performance === 'object' && typeof performance.now === 'function';

if (hasPerformanceNow) {
  const localPerformance = performance;
  getCurrentTime = () => localPerformance.now();
} else {
  const localDate = Date;
  const initialTime = localDate.now();
  getCurrentTime = () => localDate.now() - initialTime;
}


const performWorkUntilDeadline = () => {
  if (isMessageLoopRunning) {
    const currentTime = getCurrentTime();
    startTime = currentTime;

    let hasMoreWork = true;
    try {
      hasMoreWork = flushWork(currentTime);
    } finally {
      if (hasMoreWork) {
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
      }
    }
  }
  
  needsPaint = false;
};

 

performWorkUntilDeadline 함수는 현재 루프가 실행중인지에 대한 여부를 확인하고, 루프가 진행되고있다면 함수를 실행함에 따라 시작 시간을 갱신해줍니다. getCurrentTime은 조건문으로 나뉘지만 실제로는 performance.now 메서드를 통해 수행 시간에 대해 ms단위로 받아볼 수 있도록 합니다.

 

또한 flushWork에 getCurrentTime으로부터 받은 수행 시간을 인자로 넘겨 더 작업할 수 있는지에 대한 여부를 확인하고, 있다면 schedulePerformWorkUntilDeadline를 실행, 아니라면 루프가 종료되었다는 것을 플래그 변경을 통해 전달합니다.

 

그렇다면 일단 어떤 조건으로 인해 스케줄링 여부가 정해지는지 flushWork를 살펴보겠습니다.

 

function flushWork(initialTime: number) {
  // ...
  try {
    if (enableProfiling) {
      try {
        return workLoop(initialTime);
      } catch (error) {
       // ...
      }
    } else {
      return workLoop(initialTime);
    }
  } finally {
    // ...
  }
}

 

코드는 길지만 부가적인 부분이 많고, 실제로 schedulePerformWorkUntilDeadline를 실행하기 위한 조건을 return하는 부분은 workLoop를 실행하는 부분입니다. 따라서 다시 workLoop로 넘어가보겠습니다.

 

function workLoop(initialTime: number) {
  // ...
  
  while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
      break;
    }

  // ...
  }

  // ...
}

 

workLoop는 코드가 길어 나누어 확인해보겠습니다. 먼저 루프에 진입하면 현재 작업이 존재하고, 디버깅이 비활성화되었거나 스케줄러가 일시 중단되어 있지 않은 한 계속해서 루프가 실행됩니다. 현재 작업의 만료 시간이 현재 시간보다 크고, shouldYieldToHost 함수가 true를 반환하면 현재 작업을 양보하고 루프를 중단합니다. 여기서 shouldYieldToHost가 실제로 브라우저에게 제어권을 넘겨주는 조건을 확인하는 부분입니다.

 

function shouldYieldToHost(): boolean {
  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;
}

 

 

shouldYieldToHost에서는 먼저 작업이 진행된 시간 측정합니다. getCurrentTime() 함수를 사용하여 performance.now 메서드를 통해 현재 시간을 가지고오고, 현재 시간과 시작 시간의 차이를 저장합니다. 만약 실행시간이 frameInterval인 5ms보다 작다면, 매우 짧은 시간 동안 브라우저 메인 스레드가 차단되었다고 판단하고 양보하지 않습니다.

 

또한 RAIL 모델에 따라 연속적인 이벤트의 유효 시간을 체크하는 등 다른 조건도 확인하여 메인 스레드에 제어권을 넘겨줄지 여부를 확인합니다.

 

여러 함수를 통해 돌아왔지만 결국 위의 함수 실행으로부터 deadline까지 Task를 실행할지 여부를 예약하는 플래그를 정의하고자 했습니다. 이로부터 일부 케이스에서 실행을 보장받게된 schedulePerformWorkUntilDeadline는 실제로 네이밍에서와 같이 진행할 작업을 어떤 방식으로 스케줄링 할지 정합니다. 

 

우리는 스케줄링이 어떤 방식으로 예약되는지에 대해 알기로 했으니 schedulePerformWorkUntilDeadline 이 어떻게 이루어져있는지 확인해보겠습니다.

 

const localSetTimeout = typeof setTimeout === 'function' ? setTimeout : null;
const localSetImmediate =
  typeof setImmediate !== 'undefined' ? setImmediate : null;

// ...

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);
  };
}

 

 

먼저 schedulePerformWorkUntilDeadline 이 정의되는 첫번째 조건문을 확인해보겠습니다. localSetImmediate의 type이 함수인 경우에는 이를 수행합니다. 이는 setImmediate 함수가 존재하는 환경에서만 사용이 가능합니다. 실제로 해당 함수는 구버전의 Node.js 에서만 사용이 가능하고, 나머지 버전에서는 모두 deprecated되어 실제 브라우저 환경에서는 실행되지 않습니다.

 

다음이 scheduler의 핵심입니다. 위에서 언급한 4ms clamping issue를 가지고있는 setTimeout은 비교적 지양하고있기때문에 이러한 이슈에서 자유로운 방식을 고민하던 중 MessageChannel API를 선택했습니다. 해당 API를 사용하게 된다면 setTimeout을 사용할때처럼 5회 이상 중첩 시 4ms의 timeout이 생기지 않고 바로 MacroTask Queue에 작업이 등록되어 setTimeout을 사용할때보다 더 세밀하게 작업을 수행할 수 있게되었습니다. 대부분의 브라우저 환경에서는 Message Channel API를 지원하지만, 지원하지 않는 환경에서는 부득이하게 setTimeout을 사용합니다.

 

(실제 두 API 사용 시 처리속도 차이가 확연함을 확인할 수 있습니다. https://stackoverflow.com/questions/61338780/is-there-a-faster-way-to-yield-to-javascript-event-loop-than-settimeout0/61339234#61339234)

 

 

따라서 React에서 특정 작업의 수행은 세밀한 스케줄링을 위해 MessageChannel API를 활용하여 루프를 진행하고, 이로 인해 유저 이벤트와 같은 외부 이벤트에도 빠른속도로 반응할 수 있는 것입니다.

 

 

 

Lane Model에 따른 이벤트 처리순서

 

그렇다면 이미 많은 내용을 다루었기 때문에 이러한 실행에 대한 이벤트 루프속에서 이전에 살펴보았던 작업의 우선순위를 나누는 Lane Model이 어떻게 각 Lane마다 우선순위를 나눌 수 있는지 간단하게 확인해보겠습니다.

 

먼저 Lane의 종류는 크게 4가지였습니다.

 

  1. Sync Lane
  2. Continuous Lane
  3. Default Lane
  4. Transition Lane

 

 일반적으로 Continuous Lane이나 Default Lane 역시도 유저가 일으킨 이벤트임에 따라 높은 응답속도를 가지고있어야하지만, 이는 스케줄링을 통해서도 충분히 처리가 가능합니다. 따라서 이러한 이벤트는 위에서 살펴본 바와 같이 이벤트가 발생하면 Scheduler에게 작업을 위임하여 알맞은 타이밍에 작업을 수행할 수 있도록 합니다. Transition Lane은 위의 두가지 이벤트보다 우선순위가 낮습니다. 따라서 Scheduler에 포함되면서도 이는 가장 나중에 처리하게 됩니다.

 그렇다면 가장 높은 우선순위를 가지고있는 Sync Lane은 어떻게 Continuous Lane이나 Default Lane보다도 더 빠르게 작업을 수행할 수 있는걸까요? 여기서 글의 앞부분에서 리마인드 차 살펴보았던 이벤트 루프 개념이 적용됩니다. 일반적으로 Scheduler에서 예약하는 작업은 MacroTask Queue에 등록됩니다. 그렇다면 이보다 더 빠르게 처리하기 위해서는 어떻게 해야할까요?

 

맞습니다. 바로 MicroTask Queue를 활용하는 것입니다. Sync Lane은 처리해야하는 작업에 대해서 MicroTask Queue에 작업을 등록하게 됩니다. 따라서 각 Lane별로 중요도에 따라 작업 수행에 대한 우선순위를 지킬 수 있게됩니다.

 

if (supportsMicrotasks) {
  scheduleMicrotask(() => {
    // ...
    flushSyncCallbacks();
  }
});

 

 

추가적으로 React 18에서 자동 배치를 해제하고, 즉각적으로 상태를 업데이트 할 수 있는 flushSync를 들어보셨나요? 이 역시도 이름에 동작의 의미가 담겨있음을 알 수 있습니다. Sync Render를 수행할 수 있도록 flush 한다는 의미로 처리해야하는 작업이 일반적으로는 Scheduler를 통하여 작업을 실행하게 되지만, 이를 Sync Lane에 포함시켜 MicroTask Queue에 등록하여 가장 빠르게 작업을 수행할 수 있도록 하는 것입니다.

 

 

5. 정리

 

이번 글에서는 이전 글에서 다루지 못했던 React 에서의 이벤트 루프(스케줄링)에 대해 알아보았습니다. React 역시 JS환경 위에서 돌아가기때문에 기본 개념인 JS의 이벤트 루프를 지키면서 높은 반응성을 가지고 많은 작업을 수행할 수 있도록 많은 부분을 고민한 것이 인상적이었습니다. 실제 일반적으로 업무를 진행하는 과정에서 깊은 이해까지는 필요하지 않을 수 있지만, 기본 원리를 제대로 알고있는 상태로 React를 활용하게 된다면 깊이있는 활용이 필요할 때 반드시 도움이 될 것으로 생각합니다.

 

내용이 방대하여 많은 부분에서 논리적 비약이 있을 수 있습니다. 이러한 부분이 아직은 위 내용에 대해 완벽하게 이해하지 못했다는 반증이라고 생각하고, 꾸준하게 복습하여 완성도있는 글로 거듭날 수 있도록 보완하겠습니다. 감사합니다 :)

 

 

 

Reference