React 18 톺아보기 - 04. Concurrent Render

리액트의 동시성을 다음과 같이 설명했습니다.
“대규모 화면 전환에서도 높은 응답성을 유지할 수 있다.”
“화면 전환”은 Transition Lane에서 알아보았고 이번에는 “높은 응답성을 유지할 수 있다.”을 알아볼 차례입니다.

이를 이해하기 위해서는 리액트의 환경을 생각해보아야 합니다. 리액트는 Javascript로 구현되었고 대게 브라우저 위에서 동작하게 됩니다. 이 둘은 하나의 콜 스택을 공유하고 있기 때문에 리액트가 콜 스택을 오래 점유할수록 브라우저는 다른 일을 못하게 됩니다. 높은 응답성을 위해서는 렌더링을 진행함과 동시에 브라우저가 사용자의 인풋을 처리할 수 있어야 합니다. 여기에 더불어 사용자 액션에 대한 즉각적인 반응을 해주어야 합니다. Concurrent Render는 이를 위한 구현 사항이며, 아이디어는 다음과 같습니다.

리액트는 렌더링을 진행할 때 메인 스레드를 주기적으로 비워줍니다. 브라우저는 이때 사용자 인풋을 처리하거나 렌더링 작업을 진행할 수 있습니다. 필요에 의해서는 진행 중이던 렌더링을 중단하고 신규로 생성된 업데이트를 먼저 처리합니다.

concurrent

리액트는 렌더링이 동시성을 가지기 위해 다음의 요구사항을 만족해야 했습니다.

  1. 렌더링 작업을 중지, 재개할 수 있어야 한다. 필요에 의해서는 이전 렌더링 작업을 버리고 다시 시작할 수 있어야 한다.
  2. 렌더링 간의 의존성이 없으며 멱등성을 보장해야 한다.
  3. 리액트가 브라우저를 차단하지 않도록 적절히 스위칭 할 줄 알아야 한다.

1. 렌더링 작업을 중지, 재개할 수 있어야 한다. 필요에 의해서는 이전 렌더링 작업을 버리고 다시 시작할 수 있어야 한다.

16이전에는 콜스택 기반의 재귀호출 방식이었기 때문에 한번 렌더링을 시작하면 완료되기까지 중단할 수 없었습니다. 가령 다음과 같은 코드가 있습니다.

const App = () => {
  const [input, setInput] = useState(“리액트”);

  return (
    <div>
      <input
        value={input}
        onChange={(e) => {
          const { value } = e.target;
          setInput(value);
        }}
      />
      {input.split("").map((t) => (
        <BusyText text={t} />
      ))}
    </div>
  );
};

DOM에 반영된 모습은 다음과 같습니다.

react dom

사용자가 “앵귤러”를 입력했을 때의 렌더링 과정은 다음과 같습니다.

callstack render
  1. Reconciler는 현재 컴포넌트를 호출합니다.
  2. 반환한 요소와 이전 요소와의 차이점을 찾습니다.
  3. 변경 사항을 Renderer에게 전달합니다.
  4. Renderer는 변경 사항을 반영합니다.
  5. Reconciler는 하위 자식 또는 형제 노드를 대상으로 1번을 진행합니다.

여기서 1의 요구사항대로 중간에 렌더링을 중단할 수 있다고 해봅시다.

pending callstack render

“앵귤러”의 렌더링이 <input/>과 첫 번째 <BusyText/>까지만 진행되고 나머지는 진행되지 않는 채로 브라우저가 페인트를 진행해버립니다. 여기서 리액트는 트리의 일관성을 잃어버리고 사용자는 기대하던 UI와는 어긋난 UI를 확인하게 됩니다.

리액트는 이 문제를 해결하기 위해 16에서 콜스택을 버리고 Fiber Architecture를 도입했습니다.

virtual dom

리액트는 DOM에 바로 작업하는 게 아닌 Virtual DOM을 따로 구성했습니다. 그리고 이 노드 하나가 Fiber라고 부릅니다. 이 Fiber에는 트리 정보뿐만 아니라 컴포넌트, 컴포넌트의 상태, 라이프 사이클 등이 모두 담겨 있습니다.

callstack to func
이어서 콜 스택 기반의 재귀호출 방식을 수정했습니다. 콜 스택 프레임 하나를 함수로 대체하고 재귀 호출이 아닌 참조 포인터 기반으로 트리를 탐색합니다.

Sync Render와는 다르게 Concurrent Render는 메인 스레드를 비워주기 위해 추가 조건을 하나 더 확인합니다.

reconciler > ReactFiberWorkLoop.js

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

shouldYield()는 Scheduler에게 현재 메인 스레드를 비워주어야 하는지 물어보는 거라고 이해하시면 됩니다.

Scheduler는 리액트 16에서 리액트 - 브라우저 스위칭을 담당하기 위해 추가된 패키지입니다.

Concurrent Render에서 Reconciler는 현재의 렌더링을 중단해야 한다면 workInProgress와 같은 포인터들을 그대로 유지한 채 아직 렌더링이 끝나지 않았다는 것을 Scheduler에게 콜백(렌더링 시작 기능을 하는)을 반환하는 방식으로 알려 줍니다.

reconciler > ReactFiberWorkLoop.js

performConcurrentWorkOnRoot() {
  /*...*/
  if (root.callbackNode === originalCallbackNode) {
    return performConcurrentWorkOnRoot.bind(null, root);
  }
  /*...*/
}

여기서의 callbackNode는 Scheduler에게 콜백을 등록하면 반환되는 객체입니다. Reconciler는 업데이트가 발생하면 렌더링 작업을 예약하기 위해 ensureRootIsScheduled()를 통해 Scheduler에게 콜백을 등록합니다. 그리고 해당 정보를 root에 기입합니다.

newCallbackNode = scheduleCallback(
  schedulerPriorityLevel,
  performConcurrentWorkOnRoot.bind(null, root)
)

root.callbackPriority = newCallbackPriority
root.callbackNode = newCallbackNode

이때의 정보는 추후에 해당 VDOM 내에서 새로운 업데이트가 발생하여 추가 렌더링 작업이 필요한 경우, 기존 예약된 렌더링과 우선순위를 비교하여 예약된 렌더링을 취소해야 할지 결정하는 데에 쓰입니다.

Concurrent Loop의 과정은 다음과 같습니다.

concurrent loop

이제 돔에 바로 반영하지 않고 콜스택도 쓰지 않기 때문에 렌더링을 <BusyText text=“액” />까지만 진행했다가 다시 중단한 위치에서 참조 포인터만 가지고 재개할 수 있습니다.

이런 기능을 가진 Concurrent Render를 모든 Lane을 대상으로 적용하지 않습니다. Lane에는 우선순위가 있고 이 중에는 사용자 인풋을 차단하면서까지 빠르게 반영해야 하는 Lane들도 있습니다.

Sync Lane

Sync Lane은 얼마나 빠르게 처리해야 할까요?
Sync Lane은 Discrete Event에서 생성된 업데이트에 할당되는 Lane이며 Discrete Event에는 click, input 등이 있습니다. 대부분의 개발자는 input 이벤트에 다음과 같은 형태의 핸들러를 작성할 겁니다.

<input value={input} onChange={e => setInput(e.target.value)} />

해당 핸들러에서 발생하는 업데이트를 다음과 같이 배치처리하려고 하면 비정상적으로 상태가 반영됩니다.

sync batch

이와 같이 개별적으로 처리해야 하는 이벤트가 있습니다. 모두 사용자 액션으로부터 발생하는 이벤트이기 때문에 개별적인 처리와 함께 높은 UI 응답성도 가져야 합니다. 이를 위해 Discrete Event에서 발생하는 업데이트에는 Sync Lane을 할당하고 Scheduler에게 렌더링 콜백(performSyncWorkOnRoot())을 등록하는 게 아닌 Reconciler의 내부 큐(syncQueue)에 콜백을 추가합니다.

reconciler > ReactFiberWorkLoop.js

function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
  /*...*/
  const nextLanes = getNextLanes(...);
  /*...*/
  const newCallbackPriority = getHighestPriorityLane(nextLanes);
  /*...*/
  if (newCallbackPriority === SyncLane) {
   scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));  }
  /*...*/
}

reconciler > ReactFiberSyncTaskQueue.js

function scheduleSyncCallback(callback: SchedulerCallback) {
  if (syncQueue === null) {
    syncQueue = [callback];
  } else {
    syncQueue.push(callback);
  }
}

해당 콜백은 Scheduler의 콜백보다 먼저 실행되어야 하기 때문에 Micro Task Queue를 활용합니다.

reconciler > ReactFiberWorkLoop.js

function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
  /*...*/
  if (newCallbackPriority === SyncLane) {
   scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));

   queueMicrotask(() => {      flushSyncCallbacks();    });  }
  /*...*/
}

reconciler > ReactFiberSyncTaskQueue.js

function flushSyncCallbacks() {
  let i = 0;
  const isSync = true;
  const queue = syncQueue;
  setCurrentUpdatePriority(DiscreteEventPriority);
  for (; i < queue.length; i++) {
    let callback = queue[i];
    do {
      callback = callback(isSync); // performSyncWorkOnRoot
    } while (callback !== null);
  }
  syncQueue = null;
}

내부 큐에서 콜백을 꺼내 실행시킵니다. 이때의 콜백은 performSyncWorkOnRoot()가 됩니다. performSyncWorkOnRoot()는 동기식 렌더링(Sync Render)을 시작하는 함수입니다. 해당 함수에서 렌더링 루프를 실행시키는데, 이때의 루프는 위에서 살펴봤던 workLoopSync()가 됩니다.

function performSyncWorkOnRoot(...) {
  /*...*/
 let lanes = getNextLanes(root, NoLanes);
  /*...*/
 let exitStatus = renderRootSync(root, lanes);
  /*...*/
}

function renderRootSync(...) {
  // Render Phase 설정
  const prevExecutionContext = executionContext;
  executionContext |= RenderContext;
  /*...*/
  workLoopSync();
  /*...*/
}

이를 바탕으로 연속적인 “리액트” 입력의 개별 렌더링 반영 과정을 살펴보면 다음과 같습니다.

sync update

Input Continuous Lane, Default Lane

Input Continuous Lane은 Continuous Event에 해당하는 drag, scroll 이벤트 등에서 생성된 업데이트에 할당됩니다. Default Lane은 리액트 컨트롤 영역 밖에서 발생한, 대부분 비동기 함수에서 생성되는 업데이트에 할당됩니다. 이 둘을 같이 설명하는 이유는 렌더링 방식이 같아서입니다.

개별적으로 처리해야 하는 Sync Lane과 다르게 이 둘은 일련의 업데이트를 일괄처리할 수 있습니다. 다만 Sync Lane과 마찬가지로 랜더링이 진행된다면 빠르게 UI 응답을 해야하기 때문에 Sync Render로 진행됩니다.

Continuous Event는 사용자 액션이기 때문에 해당 업데이트의 렌더링 작업이 다른 요소로 인해 지연되면 안 됩니다. Default Lane 또한 앞의 두 Lane보다 우선순위가 떨어지고 비동기라 할지라도 여전히 해당 업데이트의 UI는 사용자가 관심 있어 하는 영역이기 때문에 렌더링을 시작했다면 빠르게 완료하고 UI를 반영하기 위해 Sync Render로 진행하는 것입니다.

하지만 콜백은 Sync Render와는 다르게 Scheduler에게 등록합니다.

reconciler > ReactFiberWorkLoop.js

function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
  /*...*/
  const newCallbackPriority = getHighestPriorityLane(nextLanes);
  /*...*/
  if (newCallbackPriority === SyncLane) {
    /*...*/
  } else {
    let schedulerPriorityLevel;    switch (lanesToEventPriority(nextLanes)) {      case DiscreteEventPriority:        schedulerPriorityLevel = ImmediateSchedulerPriority;        break;      case ContinuousEventPriority:        schedulerPriorityLevel = UserBlockingSchedulerPriority;        break;      case DefaultEventPriority:        schedulerPriorityLevel = NormalSchedulerPriority;        break;      case IdleEventPriority:        schedulerPriorityLevel = IdleSchedulerPriority;        break;      default:        schedulerPriorityLevel = NormalSchedulerPriority;        break;    }     newCallbackNode = scheduleCallback(      schedulerPriorityLevel,      performConcurrentWorkOnRoot.bind(null, root),    );  }

  // 콜백(Concurrent or Sync Render) 예약 정보를 root에 기입한다.
  root.callbackPriority = newCallbackPriority;
  root.callbackNode = newCallbackNode;
}

schedulerPriorityLevellanesToEventPriority()를 통해 Lane의 우선순위를 Scheduler의 우선순위로 변환한 것입니다. 해당 우선순위와 함께 콜백을 Scheduler에게 등록하면 Scheduler는 적절한 시점에 콜백을 실행합니다. 등록과 실행 사이에는 간격이 존재하기 때문에 이 간격 사이에 생성되는 업데이트는 모두 일괄처리될 것입니다.

렌더링 방식은 말씀드렸지만, Sync Render로 진행됩니다. 다만 렌더링 시작 함수가 Sync Lane은 performSyncWorkOnRoot()였지만, 이 두 Lane은 performConcurrentWorkOnRoot()입니다. 그 이유는 개별 처리와 일괄 처리 구분하기 위함이며 실제 구현 코드는 다음 Transition Lane에서 같이 확인하도록 하겠습니다.

이 부분이 리액트 18의 개선 사항 중 하나인 Auto Batching의 구현사항입니다. Auto Batching 섹션에서 추가 내용을 확인하세요.

Transition Lane

렌더링 콜백을 등록하는 코드는 Input Continuous Lane, Default Lane과 똑같기 때문에 생략하고 이전에 확인하지 않았던 렌더링 방식을 결정하는 부분을 바로 확인해보겠습니다.

reconciler > ReactFiberWorkLoop.js

function performConcurrentWorkOnRoot(root, didTimeout) {
  /*...*/
  let lanes = getNextLanes();
  const shouldTimeSlice =
    !includesBlockingLane(root, lanes) &&
    !includesExpiredLane(root, lanes);
  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    : renderRootSync(root, lanes);
    /*...*/
}

reconciler > ReactFiberWorkLoop.js

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
  /*...*/
  workLoopConcurrent()
  /*...*/
}

렌더링 방식은 shouldTimeSlice 플래그를 기준으로 Sync와 Concurrent로 분기9,10되는 것을 확인할 수 있습니다. Concurrent Render는 렌더링 대상(lanes)에 Blocking Lane과 Expired Lane이 포함되지 않은 경우6,7에만 선택됩니다.

ExpiredLane
Lane이 만료되는 것은 IO Bound인 Lane(Transition, Retry)에만 해당합니다. Concurrent Render에서 특정 경로가 보류된다면 해당 Lane은 suspendedLanes으로 관리되고 추후에 요청이 완료되면 pingedLanes로 관리됩니다. 보류되었던 모든 경로의 요청이 완료되면 이때부터는 CPU-Bound입니다. 더 이상 네트워크에 의존적인 부분이 없기 때문입니다.
하지만 Concurrent Render는 렌더링 중에도 우선순위가 더 높은 Lane에 Interrupt 될 수 있기 때문에 완료되지 못하고 지속해서 처리가 밀릴 수 있습니다. 이런 기아 현상을 방지하고자 Pinged 상태인 Lane에 만료 시간을 두고 만료가 된다면 더 이상 Interrupt 되지 않도록 Sync Render 방식으로 진행해버립니다.

다음은 Blocking Lane에 해당하는 Lane입니다.

reconciler > ReactFiberLane.js

function includesBlockingLane(root: FiberRoot, lanes: Lanes) {
  const SyncDefaultLanes = InputContinuousLane | DefaultLane
  return (lanes & SyncDefaultLanes) !== NoLanes
}

Sync Lane은 렌더링 콜백으로 performSyncWorkOnRoot()을 사용하였고 Input Continuous Lane과 Default Lane은 performConcurrentWorkOnRoot()를 사용했지만, 여기서 Blocking Lane으로 취급되면서 결국, Sync Lane과 같은 방식인 renderRootSync()를 사용하게 됩니다. 결국 Concurrent Render를 사용하는 Lane은 Transition Lane과 Retry Lane밖에 없습니다.

2. 렌더링 간의 의존성이 없으며 멱등성을 보장해야 한다.

다음과 같은 Concurrent Render의 상황을 고려해봅시다.

concurrent render inconsistent

렌더링을 <BusyText text=“앵” />까지 완료한 후에 <Slider/>와 같이 우선순위가 더 높은 업데이트가 발생하게 된다면 현재의 위치에서 렌더링을 중단하고 <Slider/>의 업데이트를 먼저 처리할 것입니다. 여기서 문제는 이대로 Virtual DOM을 DOM에 반영해버리면 <Slider/> 트리는 렌더링이 완료되었지만, 이전에 진행하던 트리는 아직 작업 중인 상태로 반영돼버립니다. 이렇게 되면 결국 15 버전과 마찬가지로 UI의 일관성을 잃어버리게 됩니다.

그래서 리액트는 Virtual DOM을 더블 버퍼링 형태로 관리합니다.

workInProgress tree

한쪽은 이미 DOM에 반영된 트리이고 root가 이를 가리키고 있습니다. 반대쪽은 렌더링 중인 작업용 트리입니다. 작업용 트리는 언제든지 진행 중이던 내용을 폐기 처리하고 다시 작업할 수 있습니다. 왜냐하면, 이미 DOM에 반영된 트리가 반대쪽에 더럽혀지지 않고 존재하고 있기 때문입니다.

렌더링 과정에서 작업용 트리를 완성해가는 단계를 Render Phase라고 합니다. Concurrent Render는 이 Render Phase가 비동기 점진적으로 진행(Concurrent Loop)되고 Sync Render는 메인 스레드를 비우지 않고 동기적으로 진행(Sync Loop)됩니다.

이전 렌더링 작업 정리하기
진행 중이던 렌더링이 현재의 렌더링과 다르다면 이전 작업을 정리하고 신규 작업용 트리를 만들어 진행합니다. reconciler > ReactFiberWorkLoop.js

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
  /*...*/
  if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
    prepareFreshStack(root, lanes);
  }

  /*...*/
  workLoopConcurrent()
  /*...*/
}

작업용 트리의 렌더링이 완료되면 해당 트리를 기준으로 DOM에 반영하는 과정을 거칩니다. 이 과정을 Commit Phase라고 하고 이때는 Concurrent Render나 Sync Render나 모두 동기로 동작합니다. 이 단계에서는 이미 돔에 반영하고 있기 때문에 동기적으로 처리하지 않으면 브라우저가 페인트를 진행해버리면서 UI의 일관성이 깨질 수 있기 때문입니다. 이 단계 까지 완료되면 roo가 가리키고 있는 current 포인터도 작업용 트리로 수정합니다.

렌더링은 이런 단계별 Phase와 함께 Lane을 기준으로 동작하기 때문에 이전 렌더링의 상황이 어떻든 아무런 의존성을 갖지 않습니다.

Concurrent Render의 Render Phase에서는 상황에 따라 하나의 컴포넌트가 여러 번 마운트 - 언마운트될 수 있기 때문에 멱등성 보장이 무엇보다 중요해졌습니다. 멱등성 섹션에서 추가 내용을 확인하세요.

3. 리액트가 브라우저를 차단하지 않도록 적절히 스위칭 할 줄 알아야 한다.

Scheduler의 중요한 기능 중 하나는 메인 스레드를 비워주어야 하는지 알고 있는 것입니다.

shouldYieldToHost

브라우저를 차단하지 않는다는 의미는 브라우저가 메인 스레드를 점유해야 할 때를 알 수 있다는 의미이고 반대로 브라우저가 이를 필요로 하지 않다면 평소보다 조금 더 메인 스레드를 점유하고 대기중인 콜백들을 실행해도 된다는 의미가 됩니다. Scheduler는 어떤 방식으로 이와 같은 동작을 구현했는지 shouldYieldToHost()를 통해 알아보겠습니다.

scheduler > Scheduler.js

let frameInterval = frameYieldMs; // 5ms

function shouldYieldToHost() {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    return false;
  }

  /*...*/

  return true;
}

Scheduler에는 등록된 콜백을 반복해서 실행하는 동작이 있으며, 해당 동작을 시작하기 전에 시작 시점을 startTime에 기록합니다. startTime는 현재까지 메인 스레드를 얼마나 점유 했는지(timeElapsed) 확인5할 때 쓰입니다.

먼저 점유한 시간이 frameInterval 보다 크지 않다면6 메인 스레드를 양보하지 않습니다. 여기서 frameInterval의 5ms는 그냥 설정된 시간은 아닙니다. Google의 RAIL 모델에서 응답 시간에 따른 사용자의 인식을 설명한 섹션이 있습니다(참고).

초당 60프레임을 그리기 위해서는 하나의 프레임을 16ms 안에 그려야 합니다. 여기에는 Javascript의 실행 시간 외에도 해당 프레임을 그리기 위한 브라우저, 기기 동작 시간도 포함됩니다. 이를 기반으로 리액트의 Scheduler는 주기적으로 하나의 프레임 시간 안에 여러 번 양보하도록 합니다.

하지만 양보하기에 앞서 유저 인풋이 발생하지 않았거나 페인트할 필요가 없는 경우에는 굳이 메인 스레드 비워줄 필요는 없습니다.

scheduler > Scheduler.js

function shouldYieldToHost() {
  /*...*/
  if (timeElapsed < frameInterval) {
    return false;
  }

  if (enableIsInputPending) {    /*...*/  }
  return true;
}

Javascript에서 유저 인풋이 발생했는지 확인하기 위해서 Facebook에서 제안한 isInputPending() API를 사용합니다. enableIsInputPending()은 호스트 환경이 isInputPending() API를 지원하는지 나타냅니다.

scheduler > Scheduler.js

function shouldYieldToHost() {
  /*...*/
  if (timeElapsed < frameInterval) {
    return false;
  }

  if (enableIsInputPending) {
    if (needsPaint) {      return true;    }    /*...*/
  }

  return true;
}

유저 인풋을 확인하기에 앞서 먼저 대기 중인 페인트 작업이 있는지 확인합니다. 여기서의 페인트는 렌더링이 Commit Phase까지 완료되어 DOM에 반영하였지만, 브라우저가 아직 페인트를 진행하지 않은 상태를 뜻합니다. 해당 상태는 리액트가 컨트롤 하는 영역이기 때문에 needsPaint 내부 변수를 통해 페인트가 대기 중인지 확인할 수 있습니다(참고).

대기 중인 페인트가 없다면 이번에는 유저 이벤트가 발생했는지 확인합니다.

scheduler > Scheduler.js

const continuousInputInterval = continuousYieldMs; // 50ms
const maxInterval = maxYieldMs; // 300ms

function shouldYieldToHost() {
  /*...*/
  if (timeElapsed < frameInterval) {
    return false;
  }

  if (enableIsInputPending) {
    if (needsPaint) {
      return true;
    }
    if (timeElapsed < continuousInputInterval) {        return isInputPending();    } else if (timeElapsed < maxInterval) {        return isInputPending(continuousOptions); // options으로 continuous input도 포함되도록 할 수 있다.    } else {      return true;    }  }

  return true;
}

continuousInputInterval이 50ms인 이유도 마찬가지로 RAIL 모델에서 확인할 수 있습니다(참고). 사용자는 동작으로부터 0~100ms 안에 응답이 온다면 즉각적으로 반응했다고 인지한다고 합니다. 다만 여기서도 마찬가지로 여러 요소들이 포함되기 때문에 50ms 안에 유저 인풋을 처리하도록 권장하고 있습니다.

shouldYieldToHost()에서도 Lane과 마찬가지로 유저 이벤트에 우선순위를 두고 있습니다. 50ms 이내의 처리 대상 이벤트는 유저의 Discrete input만 해당합니다15. Continuous input은 300ms(maxYieldMs)안에서 처리하도록 하고 있습니다17. 다르게 처리하는 이유는 RAIL 설명을 참고하면 “사용자 입력에 즉시 응답하는 것이 항상 올바르지는 않다”에 해당하는 것 같습니다.

마지막으로 페인트, 유저 인풋이 없어도 메인 스레드를 무한정 점유하지 않고 300ms 이상을 점유했다면 양보하도록 합니다20.

shouldYieldToHost()을 활용하여 Reconciler는 Concurrent Render에서 브라우저를 차단하지 않고 점진적으로 렌더링 작업을 수행할 수 있습니다. shouldYieldToHost()는 Reconciler 말고도 Scheduler에서도 사용됩니다. Scheduler는 예약된 콜백을 관리합니다. 그리고 이 콜백을 우선순위에 맞게 실행시켜야 하는 책임도 있습니다. 여기서 Reconciler와 마찬가지로 콜백의 실행을 계속 이어가도 되는지 shouldYieldToHost()를 통해 확인합니다.

이 외에도 브라우저를 차단하지 않고 스위칭할 수 있도록 등록된 콜백을 관리하고 처리할 줄 알아야 합니다.

Scheduler가 콜백을 관리하는 방법

Scheduler는 콜백을 두개의 최소 힙 큐로 나누어 관리합니다. 하나는 실행되어야 하는 콜백이 등록되는 taskQueue와 다른 하나는 실행 대기 중인 콜백을 등록하는 timerQueue입니다. 콜백은 Scheduler 내부에서 Task라는 모델로 관리됩니다. Task에는 다음과 같은 콜백의 정보가 함께 저장됩니다.

콜백을 등록할 때 scheduleCallback()의 옵션으로 delay를 설정한 경우에만 타이머 Task로 관리됩니다. 이를 활용한 코드를 찾을 수 없어 어떠한 경우에 사용하는지 알기 어려워 이 이후부터 타이머와 관련된 부분은 생략하도록 하겠습니다.

type Task = {
  id: number,
  callback: Callback | null,
  priorityLevel: PriorityLevel, // 콜백의 우선순위. 만료까지 여유가 있다면 지연시킬 수 있는 정도를 결정한다.
  startTime: number, // 콜백을 실행해야하는 시점을 나타낸다.
  expirationTime: number, // 만료 시간이 지나면 더 이상 실행을 지연하지 않는다.
  sortIndex: number, // 큐에서의 정렬 기준, 큐 내 요소들의 우선순위
}

이 Task는 Reconciler가 렌더링 콜백을 Scheduler에게 등록할 때 생성됩니다. 이때 콜백과 함께 넘겨준 것이 콜백의 우선순위였습니다(scheduleCallback).

scheduler > Scheduler.js

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

function unstable_scheduleCallback(priorityLevel, callback, options) {
  var startTime = getCurrentTime();
  var timeout;
  switch (priorityLevel) {
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT; // -1ms
      break;
    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT; // 250ms
      break;
    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT; // 1073741823ms
      break;
    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT; // 10000ms
      break;
    case NormalPriority:
    default:
      timeout = NORMAL_PRIORITY_TIMEOUT; // 5000ms
      break;
  }

  var expirationTime = startTime + timeout;

  var newTask = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };
  /*...*/
}

콜백의 실행 시점을 나타내는 데이터에는 startTimeexpirationTime이 있습니다.

startTime은 콜백이 실행되어야 하는 시점을 나타냅니다. 코드를 보면 startTime은 현재 시간이 됩니다12. 이 말인 즉슨 등록되는 콜백은 모두 바로 실행되어야 하는 대상이라는 의미입니다.
expirationTime은 바로 실행했으면 좋겠지만, 여유가 안된다면 어느 정도의 지연은 허용하는데, 이때의 지연 정도를 나타내며 콜백의 우선순위에 따라 결정됩니다15~30.

scheduler > Scheduler.js

function unstable_scheduleCallback(priorityLevel, callback, options) {
  /*..*/
  var newTask = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  }

  // taskQueue의 정렬 기준은 지연 정도를 나타내는 expirationTime이다.
  newTask.sortIndex = expirationTime  push(taskQueue, newTask)
  /*..*/
}

taskQueue에 등록된 모든 Task는 실행 대상이므로 정렬 기준은 startTime이 아닌 만료 시간이 임박한 콜백이 되어야 하므로 expirationTime이 됩니다14.

리액트는 최소힙 구현을 동작과 데이터를 분리하여 구현하였습니다(참고).

이제 taskQueue에 있는 Task를 실행해주어야 합니다.

scheduler > Scheduler.js

function unstable_scheduleCallback(priorityLevel, callback, options) {
  /*..*/
  newTask.sortIndex = expirationTime
  push(taskQueue, newTask)

  if (!isHostCallbackScheduled && !isPerformingWork) {    isHostCallbackScheduled = true;    requestHostCallback(flushWork); // MessageChannel  }  return newTask;}

Scheduler는 taskQueue를 비우는 반복문 역할을 하는 flushWork()을 비동기로 실행(MessageChannel을 활용)시킵니다9. 마지막으로 등록된 콜백을 반환11하면서 Reconciler에서 root에 해당 콜백을 callbackNode에 기록할 수 있도록 합니다.

이제 Scheduler는 브라우저를 최대한 차단하지 않는 선에서 taskQueue를 모두 비우면 됩니다.

flushWork()는 내부적으로 taskQueue을 비울 반복문인 workLoop()을 호출합니다.

scheduler > Scheduler.js

function flushWork(hasTimeRemaining: boolean, initialTime: number) {
  return workLoop(hasTimeRemaining, initialTime)
}

function workLoop(hasTimeRemaining: boolean, initialTime: number) {
  let currentTime = initialTime
  currentTask = peek(taskQueue) // 등록된 Task 중 만료 시간이 가장 임박한 Task를 선택한다.

  while (currentTask !== null) {
    /*...*/

    currentTask = peek(taskQueue)
  }
  /*...*/
}

반복은 브라우저를 차단하지 않는 선에서 진행됩니다. 다만, 브라우저가 항상 1순위는 아닙니다. 브라우저에게 메인 스레드를 매번 양보한다면 리액트 앱의 응답성은 현저히 떨어질 것입니다. 그렇기 때문에 차단을 감안하고 콜백을 실행시켜야 하는 상황도 있습니다.

scheduler > Scheduler.js

function workLoop(hasTimeRemaining: boolean, initialTime: number) {
  let currentTime = initialTime
  currentTask = peek(taskQueue)

  while (currentTask !== null) {
     if (      currentTask.expirationTime > currentTime &&      (!hasTimeRemaining || shouldYieldToHost())    ) {      break;    }
    /*...*/

    currentTask = peek(taskQueue)
  }

  if (currentTask !== null) {    return true;  } else {    return false;  }}

브라우저에 양보를 해야 할 때9 아직 Task가 만료되지 않았다면8 잠시 실행을 보류하고 반복문을 종료합니다. 반복문을 종료할 때 중요한 점은 아직 남아있는 Task가 있음을 알려19~23 반복문이 다시 실행될 수 있게 하는 것입니다.

아직 브라우저에 양보할 때가 아니거나 Task가 만료되었다면 콜백을 실행시킵니다.

scheduler > Scheduler.js

function workLoop(hasTimeRemaining: boolean, initialTime: number) {
  let currentTime = initialTime
  currentTask = peek(taskQueue)

  while (currentTask !== null) {
     if (...) {
      break;
    }

    const callback = currentTask.callback;    if (typeof callback === 'function') {      currentTask.callback = null; // 콜백을 실행할 예정이므로 Task를 초기화한다.      const continuationCallback = callback();      if (typeof continuationCallback === 'function') {        currentTask.callback = continuationCallback; // 콜백이 완료되지 않았기 때문에 다시 추가한다.      } else {        // Task Queue의 최상단 콜백이 현재 실행이 완료된 콜백이라면 제거한다.        if (currentTask === peek(taskQueue)) {          pop(taskQueue);        }      }    } else {      // Task의 콜백이 비어있다는 것은 이미 실행이 완료된 Task를 의미함으로 제거한다.      pop(taskQueue);    }
    currentTask = peek(taskQueue)
  }

  if (...) {...}
  else {...}
}

기억을 더듬어 Concurrent Render에서 렌더링이 아직 완료되지 않고 중단했을 때 이를 알리기 위한 렌더링 함수를 반환하는 부분이 있었습니다. 이때 반환받는 곳이 여기14입니다.

마지막으로 현재의 반복문이 완료된 상태로 종료된 게 아니라면 메인 스레드를 브라우저에 잠시 양보한 후 다시 시작해야 합니다.

scheduler > Scheduler.js

const performWorkUntilDeadline = () => {
  let hasMoreWork = true;
  try {
    hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime); // return flushWork() > workLoop()
  } finally {
    if (hasMoreWork) {
      schedulePerformWorkUntilDeadline(); // MessageChanne
    }
  }
}

이를 바탕으로 Concurrent Render가 중간에 중단되었어도 이제 자연스레 다음 틱에 렌더링을 다시 시작할 수 있습니다.

Auto Batching

리액트 18 이전에는 React 이벤트 시스템 안에서 발생한 업데이트만 일괄처리할 수 있었지만, 지금은 Lane을 통해 모든 업데이트를 구분할 수 있게 되었고 개별 처리와 일괄 처리 렌더링 시작을 나누면서 기존 문제점을 개선할 수 있게 되었습니다. 여기에서 Auto Batching을 비활성화하고 개별적인 업데이트 렌더링을 가져가고 싶다면 Reconciler의 flushSync()을 사용하면 됩니다. case 10에셔 확인해보세요.

flushSync()의 내부 구현사항은 다음과 같습니다.

reconciler > ReactFiberWorkLoop.js

function flushSync(fn) {
  /*...*/
  try {
    setCurrentUpdatePriority(DiscreteEventPriority);
    fn();
  } finally {
    if ((executionContext & (RenderContext | CommitContext)) === NoContext) {
      flushSyncCallbacks();
    }
  }
}

fn 콜백을 실행하기 전에 업데이트의 우선순위를 DiscreteEventPriority로 설정해5 놓습니다. 이렇게 설정하면 콜백에서 생성되는 업데이트6는 모두 Sync Lane이 할당되고 렌더링 콜백은 Reconciler의 내부 큐에 추가됩니다. 그리고 나서 동기적으로 바로 flushSyncCallbacks()를 실행9시켜 렌더링을 시작합니다.

멱등성

멱등성이란 연산을 여러 번 적용하여도 결과는 달라지지 않는 성질을 뜻합니다. 이 성질이 리액트와 어떤 연관이 있을까요? 리액트 18로 넘어오면서 Render Phase는 렌더링이 완료되지 않아도 여러 번 진행될 수 있고 진행 중이던 작업물을 폐기할 수도 있게 되었습니다. Reconciler는 VDOM을 만들어 가는 과정을 여러 번 반복하여도 반드시 결과가 달라지지 말아야 합니다. 그리고 리액트는 이 과정에서 멱등성을 보장합니다.

다만, 이런 책임이 이제는 리액트에만 있는 것이 아닌 개발자도 이를 보장해주어야 합니다. Sync Render만을 사용했던 18 이전에는 컴포넌트가 렌더링 되면 반드시 Commit 되었습니다. 하지만 이제는 상황이 달라졌습니다. 컴포넌트가 렌더링 되었지만 마운트되지 않을 수도 있고 또는 제거되었지만 같은 상태를 가진 트리를 빠르게 재구축하려 할 수도 있습니다(탭 전환과 같은 상황). 이런 과정에서 useEffect와 같은 함수를 잘못 사용한다면 렌더링 횟수에 따라 결과가 매번 달라질 수 있게 됩니다.

그래서 리액트 측은 이런 개발자의 실수를 미연에 방지하고자 Strict Mode의 동작 방식을 다음과 같이 수정하였습니다.

Before

* React mounts the component.
  * Layout effects are created.
  * Effects are created.

After

* React mounts the component.
  * Layout effects are created.
  * Effects are created.
* React simulates unmounting the component.
  * Layout effects are destroyed.
  * Effects are destroyed.
* React simulates mounting the component with the previous state.
  * Layout effects are created.
  * Effects are created.

개발 과정에서 미리 Mount - UnMount - Mount를 진행하여 멱등성이 보장되는지 표면적으로 표시될 수 있도록 하기 위함입니다.


지금까지 렌더링이 동시성을 가지기 위한 요구사항을 확인해 보았는데, 조금 긴 내용이라 전환 업데이트의 심플한 상황으로 흐름을 다시 되짚어 보면서 마무리하겠습니다.

  1. 업데이트가 발생하면 Transition Lane을 할당하고 Scheduler에게 performConcurrentWorkOnRoot 콜백을 등록합니다.
  2. Scheduler는 적절한 타이밍에 콜백을 실행시킵니다.
  3. Reconciler는 렌더링 대상이 Blocking이거나 Expired Lane이 아니기 때문에 Concurrent Render를 진행합니다.
  4. Reconciler는 렌더링을 점진적으로 진행하다 Scheduler로 부터 양보해야 함을 확인하고 렌더링을 중단합니다.
  5. Scheduler는 실행되어야 하는 Task들이 남아 있기 때문에 양보함과 동시에 반복문을 다음 틱으로 넘깁니다.
  6. 다음 틱에 반복문을 다시 실행합니다.
  7. Reconciler는 현재 렌더링 대상인 Lane과 이전 진행 중이던 렌더링 Lane이 다르지 않다면 기존 포인터를 기반으로 Concurrent Render를 이어서 진행합니다.

마무리

리액트 18로 넘어오면서 앱에서 발생하는 업데이트와 렌더링을 바라보는 관점이 많이 확장되었기 때문에 이를 이해하는 게 더욱 중요해졌습니다. 앞으로 Data fetch API와 같이 이와 관련된 기능들이 추가될 예정이기도 합니다.

아마 이번 주제와 관련해서 제가 이해하지 못한 이론들이 있을 것으로 생각됩니다. 다만. 리액트 18 기저에 깔린 내용은 이와 크게 다르지는 않습니다. 여기에 부족한 부분은 여러분들이 채워 나가리라 생각하고 이번 주제는 여기서 마무리하도록 하겠습니다. 길고 불친절한 글을 끝까지 읽어 주셔서 감사합니다🙏.


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