React 18 톺아보기 - 02. Lane 모델

Lane은 렌더링에서 동시성을 가져감에 있어 기존의 한계를 해결하기 위해 새롭게 도입된 모델입니다. Lane 이전에는 Expiration Time1 모델을 통해서 관리가 되었는데, 해당 모델에 어떠한 한계가 있었는지 먼저 짚어보고 Lane 모델로 넘어가겠습니다.

Expiration Time

Expiration Time의 우선순위에는 상수인 Sync, Idle, Batched와 중요도에 따라 계산된 값이 있습니다. 숫자가 클수록 우선순위가 높으며, Sync 또는 만료된 Expiration Time이 가장 우선순위가 높습니다. 그래서 Sync는 부호 있는 31 비트에서 가장 큰 수를 사용합니다(참고).

reconciler > ReactFiberExpirationTime.js

const NoWork = 0
const Never = 1
const Idle = 2
const Sync = MAX_SIGNED_31_BIT_INT
const Batched = Sync - 1

우선순위를 기반으로 Expiration Time을 계산하는 방법은 렌더링 모드에 따라 다음과 같이 결정됩니다.

reconciler > ReactFiberWorkLoop.js > computeExpirationForFiber()

function computeExpirationForFiber(currentTime, fiber) {
  const mode = fiber.mode;
  if ((mode & BlockingMode) === NoMode) {   return Sync;  }
  const priorityLevel = getCurrentPriorityLevel(); // 스케줄러에서 이벤트의 우선순위를 참조한다.
  if ((mode & ConcurrentMode) === NoMode) {  return priorityLevel === ImmediatePriority ? Sync : Batched;  }
  switch (priorityLevel) {
   case ImmediatePriority:
    expirationTime = Sync;
    break;
   case UserBlockingPriority:
    expirationTime = computeInteractiveExpiration(currentTime);
    break;
   case NormalPriority:
   case LowPriority:
    expirationTime = computeAsyncExpiration(currentTime);
    break;
   case IdlePriority:
    expirationTime = Idle;
    break;
   default:
    invariant(false, 'Expected a valid priority level');
  }
}

리액트 17의 Adopting Concurrent Mode 문서를 참고하면 모드에는 Legacy Mode, Blocking Mode, Concurrent Mode2가 있으며, 코드를 보면 아시겠지만, 정식 버전으로는 switch문에 도달할 수 없습니다. 우선순위 기반 렌더링이나 동시성 렌더링 기능을 사용하기 위해서는 실험적 버전을 통해 리액트를 설치해야 합니다.

npm install react@experimental react-dom@experimental

아무튼 대부분의 개발자는 의도적이든 아니든 LagacyMode(ReactDOM.render)를 사용하고 있었고 모든 업데이트가 Sync의 우선순위를 가졌습니다.

마지막으로 Expiration Time에서는 렌더링 대상의 우선순위를 기준으로 같거나 큰 업데이트만 렌더링 대상에 포함되어 배치처리 됩니다. 다음은 렌더링 과정에서 현재 위치의 서브 트리가 렌더링 대상에 포함되는지 확인하는 코드입니다.

reconciler > ReactFiberBeginWork.js > bailoutOnAlreadyFinishedWork()

const childExpirationTime = workInProgress.childExpirationTime
if (childExpirationTime < renderExpirationTime) {
  return null
} else {
  cloneChildFibers(current, workInProgress)
  return workInProgress.child
}

서브 트리의 우선순위(childExpirationTime)가 현재 렌더링 중인 우선순위(renderExpirationTime) 보다 낮다면3 null를 반환4하여 하위 트리로 렌더링 작업이 진행되지 않도록 합니다.

Lane 모델이 해결하고자 하는 것

업데이트 개념 분리

Expiration Time은 두 가지 개념이 하나의 시간 데이터에 존재했습니다.

  • 우선순위: 업데이트를 발생시킨 이벤트를 기준으로 우선순위를 결정하고 업데이트 간 우선순위는 대소 비교를 통해 판단
  • 배치 여부: 업데이트의 배치 여부는 값의 대소비교를 통해 판단

이런 구현사항은 상위 우선순위를 가진 업데이트를 먼저 처리하도록 설계되어 있어 문제없이 잘 동작하고 있었습니다.

A > B > C의 우선순위가 주어지면 A에 대한 작업 완료 없이는 B에 대한 작업을 수행할 수 없습니다. 마찬가지로 A와 B를 모두 완료하지 않고 C로 넘어갈 수 없습니다.

이는 Suspense가 나오기 이전에 설계되었으며, 우선순위가 아닌 다른 이유로 작업의 순서를 결정해야 할 이유가 없었습니다. 하지만 Suspense와 함께 렌더링에 IO-Bound라는 개념이 추가되면서 이야기가 달라졌습니다. CPU - IO - CPU의 순서로 렌더링 작업이 진행될 경우, 우선순위가 높은 IO-Bound 렌더링이 상대적으로 우선순위가 낮은 두 번째 CPU-Bound 렌더링 작업을 차단하는 경우가 발생하게 되었습니다.

전환을 예로 들면 네트워크 요청이 필요한 전환도 있고 아닌 전환도 있을 것입니다. 가령 화면을 전환 시키는 A, B 버튼이 있고 A 버튼은 로컬 데이터를, B 버튼은 네트워크 데이터를 사용하여 여러 UI가 전환된다고 해봅시다. 사용자도 버튼별 차이점을 알고 있다고 했을 때 네트워크 요청이 필요한 B 버튼의 전환을 사용자도 즉각적으로 반응할 것이라고 기대하지 않습니다. 그리고 상대적으로 IO-Bound는 CPU-Bound보다 느릴 수 밖에 없습니다. 그렇기 때문에 같은 우선순위를 가진 전환 렌더링이라도 IO-Bound 보다는 CPU-Bound를 먼저 처리하는 게 유리합니다.

정확히 어떠한 상황의 렌더링이 IO-Bound인지 Transition Lane 글에서 자세히 다룹니다.

Expiration Time은 이벤트 우선순위와 업데이트 발생 시점을 기준으로 시간 데이터를 계산하여 사용합니다. 짧은 간격으로 B - A의 버튼이 클릭 된 경우, 같은 전환 우선순위를 기반으로 Expiration Time이 계산될 것입니다. 여기서 IO - CPU의 시간 범위를 지정하고 구분할 수 있어야 하는데, 다음과 같이 범위를 나타낼 수는 있지만, 해당 범위에서 특정 범위(일련의 범위에서 IO-Bound에 해당하는 Expiration Time)를 제거할 수는 없습니다.

const isTaskIncludedInBatch =
  taskPriority <= highestPriorityInRange &&
  taskPriority >= lowestPriorityInRange

리액트 18의 렌더링은 여러 요소(업데이트 종류, Suspense, Hydrate)에 따라서 렌더링 방식을 다르게 가져갑니다. 여기에 맞춰서 업데이트를 보다 세부적으로 관리할 수 있어야 합니다.

이를 위해 Lane은 두 가지 개념(업데이트 간의 우선순위, 업데이트 배치 여부)을 분리하여 관리할 수 있도록 구상되었습니다.

우선순위가 더 높은 업데이트를 먼저 처리하기

같은 상태의 변경이라도 우선순위가 다를 수 있습니다. 사용자 액션으로부터 text 상태를 변경하는 것과 startTransition API를 사용하여 text 상태를 변경하는 것처럼 말이죠. 사용자 액션의 응답이 가장 중요하므로 렌더링 중이라도 우선순위가 더 높은 업데이트가 발생한다면 현재의 렌더링을 중단하고 우선순위가 더 높은 업데이트를 기준으로 다시 렌더링을 진행할 수 있어야 합니다. 이 과정에서 여러 업데이트 중에 발생 시점과 상관없이 적절히 우선순위를 판별하고 처리할 수도 있어야 합니다.

Lane은 시간에 의존적이지 않습니다. 렌더링 순서가 변경되어도 여러 업데이트를 기준에 따라 정확하게 구분할 수 있습니다.

불필요한 렌더링 건너뛰기

업데이트 종류에 따라 렌더링 방식이 다르다고 했습니다. 즉각적인 반응을 위해 업데이트 발생과 동시에 렌더링으로 이어지는 업데이트가 있고 적절히 배치 처리하는 업데이트도 있습니다. 여기서 화면 전환은 우선순위가 가장 낮지만, 렌더링에 필요한 리소스는 반대로 큽니다. 사용자의 텍스트 입력에 반응하는 것과 텍스트로부터 자동 완성 UI를 렌더링하는 것의 차이점입니다. 이런 이유로 전환의 렌더링 방식은 Time Slicing(추후 언급될 Concurrent Render의 구현 방식) 기법을 통해 잘게 쪼개 점진적으로 렌더링을 이어 갑니다. 이 때문에 렌더링 완료까지 상대적으로 다른 업데이트보다 오래걸립니다. 하지만 전환은 우선순위가 낮고 리소스가 많이 들기 때문에 앱의 높은 응답성을 위한 선택 중 하나입니다.

자세한 전환의 렌더링 방식은 Concurrent Render 글에서 자세히 다룹니다.

전환 렌더링 기준으로 A0 UI에 대한 렌더링이 진행되는 와중에 상태 변경에 따라 A1, A2의 전환이 발생할 수도 있습니다. 최종 상태의 전환 UI는 A2이기 때문에 이전 상태의 A0, A1의 렌더링은 더 이상 진행할 필요가 없으며 리소스 낭비입니다. 사용자는 중간 상태의 UI보다는 이미 최종 상태의 UI를 기대하고 있기 때문입니다. 이런 중간 상태의 불필요한 렌더링을 건너뛸 수 있어야 합니다.

리액트 18에서는 Lane을 통해 오래된 렌더링을 폐기, 중간 상태를 건너뛰고 최신 상태의 렌더링을 다시 수행할 수 있습니다.

위 내용은 이번 시리즈 전반에 걸쳐 확인하게 됩니다.

Lane

Lane은 리액트 내부에서도 단어 그대로 차선의 개념으로 이해할 수 있습니다. Lane의 종류는 다음과 같습니다.

reconciler > ReactFiberLane.js

// Lane 중 설명에 필요한 부분만 간추렸습니다.
const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000
const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000

const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000010

const InputContinuousLane: Lane = /*             */ 0b0000000000000000000000000001000

const DefaultLane: Lane = /*                     */ 0b0000000000000000000000000100000

const SyncUpdateLanes: Lane = /*                */ 0b0000000000000000000000000101010

const TransitionLanes: Lanes = /*                       */ 0b0000000011111111111111110000000
const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000010000000
/*...*/
const TransitionLane16: Lane = /*                       */ 0b0000000010000000000000000000000

const RetryLanes: Lanes = /*                            */ 0b0000111100000000000000000000000
const RetryLane1: Lane = /*                             */ 0b0000000100000000000000000000000
/*...*/
const RetryLane4: Lane = /*                             */ 0b0000100000000000000000000000000

리액트는 Lane의 자료 구조로 비트 마스크 채용했습니다. 비트 마스크의 기본 개념은 React 톺아보기 - 02.Intro에서 확인했습니다. 이때는 현재의 컨텍스트가 무엇인지 확인하는 데에만 썼다면 지금은 비트별 우선순위와 그룹과 관련한 연산이 추가로 필요합니다. 다만, 이번 주제를 이해하는데 필수 요소는 아니기 때문에 흥미가 있으신 분은 확인하고 넘어가세요.

신규 비트 연산

가장 우선순위가 높은 Lane 선택

Lane은 우측에 가까운 비트일수록 우선순위가 높습니다. Lane이 그룹 지어 있는 경우, 가장 우측의 Lane이 가장 우선순위가 높은 Lane입니다. 가장 우측의 비트를 선택하는 방법은 음수로 변경한 후 & 연산을 하면 됩니다.

7: 0111
&
-7: 0111 -> 1000 + 1 -> 1001

4: 0100
&
-4: 0100 -> 1011 + 1 -> 1100

function getHighestPriorityLane(lanes: Lanes): Lane {
  return lanes & -lanes
}

비트를 인덱스로 다루기

인덱스를 Lane으로 변환하는 방법은 시프트 연산자를 사용합니다.
1 << index

반대로 특정 비트의 인덱스를 얻기 위해서는 Math.clz32()를 사용합니다. 32비트에서 좌측의 0의 개수를 바탕으로 가장 좌측에 위치한 비트의 인덱스를 선택합니다.

// 00000000000000000000000000000100
console.log(Math.clz32(4))
// output: 29

// 0의 갯수에서 -31를 빼면 가장 좌측에 있는 lane의 인덱스를 구할 수 있다.
function pickArbitraryLaneIndex(lanes: Lanes) {
  return 31 - clz32(lanes)
}

비트 병합하기

function mergeLanes(a: Lanes | Lane, b: Lanes | Lane): Lanes {
  return a | b
}

A 그룹이 B 그룹에 포함되는지 확인하기

function isSubsetOfLanes(set: Lanes, subset: Lanes | Lane) {
  return (set & subset) === subset
}

두 비트의 우선순위 비교하기

a 비트가 b 비트 보다 우선순위가 더 높은지 확인하기
function isHigherEventPriority(a: EventPriority, b: EventPriority): boolean {
  return a !== 0 && a < b
}

0은 NoLane으로 값이 없음을 의미합니다.

a, b 중 우선순위가 더 높거나, 낮은 비트 선택하기

function higherEventPriority(
  a: EventPriority,
  b: EventPriority
): EventPriority {
  return a !== 0 && a < b ? a : b
}

function lowerEventPriority(a: EventPriority, b: EventPriority): EventPriority {
  return a === 0 || a > b ? a : b
}

Lane을 이벤트 우선순위로 변환하기

위 비트 연산들은 다음과 같이 사용됩니다.

function lanesToEventPriority(lanes: Lanes): EventPriority {
  const lane = getHighestPriorityLane(lanes)
  if (!isHigherEventPriority(DiscreteEventPriority, lane)) {
    return DiscreteEventPriority
  }
  if (!isHigherEventPriority(ContinuousEventPriority, lane)) {
    return ContinuousEventPriority
  }
  if (includesNonIdleWork(lane)) {
    return DefaultEventPriority
  }
  return IdleEventPriority
}

업데이트와 Lane

리액트에서 업데이트가 발생하면 해당 업데이트의 종류에 따라서 Lane을 할당합니다. 업데이트는 다음과 같이 차선 위에 올라가 있는 형태가 됩니다.

lanes

Reconciler(렌더링 모듈)는 현재 렌더링 대상인 차선들(renderLanes, Lane의 집합, 이하 Lanes)을 들고 있고 해당 Lane 위에 올라가 있는 업데이트들이 배치처리되는 구조입니다.

이벤트와 Lane의 우선순위

앞서 업데이트에는 종류가 있다고 말씀드렸습니다. 이런 분류의 기준은 업데이트의 시작점입니다. 이 시작점은 DOM 이벤트일 수도 있고 비동기, 전환 이벤트일 수도 있습니다. 다음과 같이 Lane은 하나의 시작점 이벤트에 대응합니다.

  • SyncLane: 사용자의 물리적 행위 중 개별적으로 처리해야 하는 이벤트(DiscreteEvent)

    • click, input, mouse down, submit…
  • InputContinuousLane: 사용자의 물리적 행위 중 연속적으로 발생하는 이벤트(ContinuousEvent)

    • drag, scroll, mouse move, wheel…
  • DefaultLane: 기타 모든 이벤트, 리액트 외부에서 발생한 업데이트

    • setTimeout, Promise..
  • TransitionLane: 개발자가 정의한 전환 이벤트

    • startTransition(), useTransition()를 통해 생성된 업데이트

시작점인 이벤트에는 대응하는 Lane과 같은 우선순위를 가지고 있습니다.

reconciler > ReactEventPriorities.js

const DiscreteEventPriority = SyncLane;
const ContinuousEventPriority = InputContinuousLane;
const DefaultEventPriority = DefaultLane;
const IdleEventPriority = IdleLane;

해당 이벤트별로 분류되는 DOM 이벤트는 다음의 링크에서 더 자세히 확인할 수 있습니다.

react-dom-bindings > events > ReactDOMEventListener.js

function getEventPriority(domEventName: DOMEventName): EventPriority {
switch (domEventName) {
    case 'click':case 'input':
      return DiscreteEventPriority;
    case 'drag':case 'wheel':
      return ContinuousEventPriorit;
    default:
      return DefaultEventPriority;
}
}

Lane은 우측에 위치할수록 우선순위가 높기 때문에 SyncLane(DiscreteEventPriority) - InputContinuousLane(ContinuousEventPriorit) - DefaultLane(DefaultEventPriority) - TransitionLane 순으로 우선순위가 높습니다.

업데이트에 Lane 할당하기

시작점 이벤트와 Lane은 대응되기 때문에 시작점을 판단할 수 있다면 이때 생성된 업데이트에 적절한 Lane을 할당할 수 있습니다. 업데이트에 Lane을 할당하는 시점은 개발자가 useState()의 setState() API를 사용하여 상태를 변경할 때 입니다.

reconciler > ReactFiberHooks.js

function dispatchSetState(fiber, queue, action) {
  /*...*/
  const lane = requestUpdateLane(fiber) // 업데이트에 할당할 Lane을 요청합니다.

  const update = {
    lane, // Lane 할당
    action,
    hasEagerState: false,
    eagerState: null,
    next: (null: any),
  }
  /*...*/
}

requestUpdateLane()은 다음의 환경을 순차적으로 탐색하며 업데이트의 우선순위를 찾습니다.

DOM 이벤트

DOM 이벤트 분류에 따라서 리액트 내부적으로 구축한 이벤트 시스템 위에 다음과 같이 핸들러가 구성되어 있습니다.

react-dom > events > ReactDOMEventListener.js

// click, input 등의 이벤트 핸들러로 등록되어 있습니다.
function dispatchDiscreteEvent(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  container: EventTarget,
  nativeEvent: AnyNativeEvent,
) {
  const previousPriority = getCurrentUpdatePriority();
  try {
    setCurrentUpdatePriority(DiscreteEventPriority);    dispatchEvent(domEventName, eventSystemFlags, container, nativeEvent);
  } finally {
    setCurrentUpdatePriority(previousPriority);
  }
}

function dispatchContinuousEvent (..) {...}
/*...*/

requestUpdateLane()에서는 setCurrentUpdatePriority()가 설정한 우선순위를 참조하게 됩니다.

reconciler > ReactFiberWorkLoop.js

function requestUpdateLane(fiber: Fiber): Lane {
  /*...*/
  const updateLane: Lane = getCurrentUpdatePriority(); // setCurrentUpdatePriority()에서 설정한 값을 가져온다
  if (updateLane !== NoLane) {
    return updateLane;
  }
  /*...*/
}

리액트 외부 이벤트

리액트 이벤트 시스템 외부에서 발생할 경우, 내부 핸들러를 통해 우선순위를 설정할 수 없습니다. 이때는 호스트 시스템을 통해 이벤트를 먼저 확인하고 여기에 해당하지 않는다면 DefaultEventPriority를 할당하게 되어있습니다.

reconciler > ReactFiberWorkLoop.js

function requestUpdateLane(fiber: Fiber): Lane {
  /*...*/
  const updateLane: Lane = getCurrentUpdatePriority()
  if (updateLane !== NoLane) {
    return updateLane
  }

  const eventLane: Lane = (getCurrentEventPriority(): any)  return eventLane}

react-dom > client > ReactDOMHostConfig.js

function getCurrentEventPriority(): EventPriority {
  const currentEvent = window.event; // 리액트 이벤트 시스템 외부에서 발생했어도 이벤트를 참조할 수 있는지 다시 확인한다.
  if (currentEvent === undefined) {
    return DefaultEventPriority; // setTimeout, Promise는 여기에 해당합니다.
  }
  return getEventPriority(currentEvent.type);
}

전환 이벤트

Transition Lane을 할당할 업데이트는 useTransition(),startTransition() API를 통해서 개발자가 결정합니다. 해당 API를 통해서 생성된 업데이트는 내부적으로 특별한 플래그를 세우게 됩니다.

react > ReactStartTransition.js

function startTransition(scope, options) {
  const prevTransition = ReactCurrentBatchConfig.transition;
  ReactCurrentBatchConfig.transition = {};

  try {
    scope();
  } finally {
    ReactCurrentBatchConfig.transition = prevTransition;
  }
}

ReactCurrentBatchConfig는 리액트 내부에서 전역적으로 사용되는 객체입니다. 해당 객체를 업데이트 생성 시점에 참조하여 업데이트가 전환 업데이트인지 판단합니다.

reconciler > ReactFiberWorkLoop.js

function requestUpdateLane(fiber: Fiber): Lane {
  const isTransition = requestCurrentTransition() !== NoTransition; // ReactCurrentBatchConfig.transition !== null  if (isTransition) {    if (currentEventTransitionLane === NoLane) {      currentEventTransitionLane = claimNextTransitionLane(); // 할당 가능한 Transition Lane을 요청한다.    }    return currentEventTransitionLane;  }
  const updateLane: Lane = getCurrentUpdatePriority()
  if (updateLane !== NoLane) {
    return updateLane
  }

  const eventLane: Lane = (getCurrentEventPriority(): any)
  return eventLane
}

Transition Lane(+ Retry Lane)은 다른 Lane들과는 다르게 Lane이 여러 개입니다. 그래서 전환 업데이트는 생성 시점에 따라 같은 전환 업데이트라도 다른 Transition Lane에 할당될 수 있습니다. 지금은 별로 중요한 내용은 아니니 다음 Transition Lane 글에서 자세히 다룹니다.

렌더링을 진행할 Lane 선택하기

리액트 18은 우선순위 기반으로 렌더링을 진행합니다. 우선순위가 낮은 업데이트와 높은 업데이트를 따로 렌더링한다는 의미입니다. 이를 위해 렌더링 대기 중인 Lanes에서 우선순위가 가장 높은 Lane을 뽑아낼 수 있어야 합니다. 이 작업은 렌더링 시작 전에 getNextLanes()에서 이루어집니다.

그리고 이 과정에서 렌더링 중이던 Lane보다 우선순위가 더 높은 Lane이 뽑힐 수도 있습니다. 이를 동시성 렌더링에서의 Interrupt라고 합니다. 이렇게 되면 자연스럽게 이전 렌더링 작업은 폐기 처리되고 신규 Lane을 기준으로 렌더링을 다시 시작하게 됩니다.

렌더링 작업은 단일 Lane일 수도 있지만 복수개의 Lanes일 수도 있습니다. 같은 종류의 업데이트는 모두 배치처리됩니다. 즉, 전환 업데이트가 다른 시점에 생성되어 다른 Transition Lane에 할당되었어도 항상 배치처리 된다는 의미입니다.

이제 렌더링 작업을 진행할 Lanes를 어떻게 뽑아내는지 확인해보겠습니다(요약한 코드입니다. 실제 코드는 여기를 확인하세요).

function getNextLanes(root, wipLanes) {
  const pendingLanes = root.pendingLanes  if (pendingLanes === NoLanes) {    return NoLanes
  }

  let nextLanes = NoLanes
  /*...*/
}

먼저 렌더링 보류 중인 Lanes를 root를 통해서 참조합니다. rootpendingLanes는 업데이트에 Lane을 할당할 때 root에도 같이 기록되며(dispatchSetState() -> scheduleUpdateOnFiber() -> markRootUpdated()), VDOM 전체에 발생한 업데이트를 참조하기 위해 사용됩니다.

root는 VDOM에서의 전역 컨텍스트라고 생각하시면 되겠습니다.

function getNextLanes(root, wipLanes) {
  /*...*/
  let nextLanes = NoLanes

  const suspendedLanes = root.suspendedLanes  const pingedLanes = root.pingedLanes  /*...*/
}

suspendedLanespingedLanes는 Suspense 컴포넌트와 연관되어 있습니다. suspendedLanes는 IO-Bound인 렌더링에서 보류 처리(네트워크 요청으로부터 데이터가 아직 도착하지 않은)된 Lanes이며, pingedLanes는 보류된 상태가 해결(네트워크 요청이 완료되어 데이터가 준비된)되었지만 아직 렌더링을 진행하지 못한 Lanes를 의미합니다.

function getNextLanes(root, wipLanes) {
  /*...*/
  const suspendedLanes = root.suspendedLanes
  const pingedLanes = root.pingedLanes

  const nonIdlePendingLanes = pendingLanes & NonIdleLanes  /*...*/
}

렌더링 작업에 포함하지 않을 Lane들을 제거합니다.

function getNextLanes(root, wipLanes) {
  /*...*/
  const nonIdlePendingLanes = pendingLanes & NonIdleLanes

  if (nonIdlePendingLanes !== NoLanes) {    const nonIdleUnblockedLanes = nonIdlePendingLanes & ~suspendedLanes    if (nonIdleUnblockedLanes !== NoLanes) {      nextLanes = getHighestPriorityLanes(nonIdleUnblockedLanes)    } else {      const nonIdlePingedLanes = nonIdlePendingLanes & pingedLanes      if (nonIdlePingedLanes !== NoLanes) {        nextLanes = getHighestPriorityLanes(nonIdlePingedLanes)      }    }  }  /*...*/
}

기본적으로 렌더링 보류 중인 suspendedLanes는 제외6합니다. 그리고 보류되었다가 데이터가 도착한(suspend -> ping) pingedLanes는 CPU-Bound Lanes보다 우선순위에 밀리는 모습7을 확인할 수 있습니다.

getHighestPriorityLanes()는 현재 보류 중인 Lanes에서 우선순위가 가장 높은 Lane과 해당 Lane 그룹에 속하는 보류 중인 Lanes도 배치처리하기 위해 포함하여 반환합니다.

reconciler > ReactFiberLane.js

function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes {
  switch (getHighestPriorityLane(lanes)) { // Lanes중 가장 우선순위가 높은 Lane을 선택한다.
    case SyncLane:
          return SyncLane;
    case InputContinuousLane:
      return InputContinuousLane;
    /*...*/
    case TransitionLane1:
    case TransitionLane2:
    case TransitionLane16:
        return lanes & TransitionLanes;
    /*...*/
  }
}

만약 lanesTransitionLane1, TransitionLane2, RetryLane1이 포함되어 있다면 가장 우선순위가 높은 TransitionLane1이 선택되고 반환될 때는 같은 그룹인 Transition Lane2도 포함하여 반환됩니다.

function getNextLanes(root, wipLanes) {
  /*...*/
  if (nonIdlePendingLanes !== NoLanes) {
    /*...*/
  }

  if (nextLanes === NoLanes) {    return NoLanes  }  /*...*/
}

여기서 nextLanesNoLanes라면 보류 중인 Lanes 중 현재 렌더링을 진행해야 할 Lane이 없다는 의미입니다. NoLanes를 반환하여 렌더링 작업을 진행하지 않도록 합니다.

function getNextLanes(root, wipLanes) {
  /*...*/
  if (nextLanes === NoLanes) {
    return NoLanes
  }

  if (wipLanes !== NoLanes && wipLanes !== nextLanes) {    const nextLane = getHighestPriorityLane(nextLanes)    const wipLane = getHighestPriorityLane(wipLanes)    if (      nextLane >= wipLane ||      (nextLane === DefaultLane && (wipLane & TransitionLanes) !== NoLanes)    ) {      return wipLanes    }  }  /*...*/
}

wipLanes는 동시성 렌더링에서 렌더링 중인 Lanes을 저장하는 변수입니다. 해당 변수가 있다는 건 렌더링이 진행 중이었다는 의미입니다7. 렌더링이 진행 중이었으니 이전 렌더링보다 우선순위가 높은 업데이트가 있는지 확인해서 Interrupt 할지 결정해야 합니다.

이전 렌더링을 계속 이어갈 것이라면 wipLanes를 반환14합니다. 만약 렌더링 중이었고7 nextLanewipLane 보다 우선순위가 더 높다면11 이전 렌더링을 Interrupt 합니다.

추가로, DefaultLaneTransitionLane 보다 우선순위가 더 높지만 이미 전환 업데이트가 렌더링 중이라면 Interrupt 하지 않는 걸 확인할 수 있습니다12.

function getNextLanes(root, wipLanes) {
  /*...*/
  if (wipLanes !== NoLanes && wipLanes !== nextLanes) {
    /*...*/
  }

  return nextLanes}

nextLanes를 반환합니다. 이제 Reconciler는 반환받은 Lanes를 기반으로 VDOM을 탐색하며 렌더링을 진행합니다. 이 과정은 root에 pendingLanes가 더 이상 없을 때까지 반복될 것입니다.

Lane 기반의 렌더링 탐색

Lane은 사실 업데이트에만 할당되지 않습니다. Lane은 업데이트의 우선순위와 배치 여부로만 해석하는 것이 아닌 업데이트 발생 여부를 판단할 때도 사용됩니다.

업데이트가 발생했다면 업데이트가 VDOM의 어느 노드에서 발생했는지 기록해야 합니다. 이를 위해 상태가 변경된 노드(fiber)에 업데이트가 발생했음을 lanes에 기록하고, 해당 노드의 부모부터 최상단 노드까지는 하위 트리에 업데이트가 발생했을음 childLanes에 기록합니다. 이것은 추후에 Reconciler가 Lanes를 기준으로 렌더링할 때 렌더링 대상 컴포넌트를 찾아가는 데에 활용됩니다.

reconciler > ReactFiberConcurrentUpdates.js

function markUpdateLaneFromFiberToRoot(sourceFiber, update, lane) {
  // 상태가 변경된 노드에 업데이트가 발생했음을 새긴다.
  fiber.lanes = mergeLanes(fiber.lanes, lane)
  const alternate = fiber.alternate
  if (alternate !== null) {
    alternate.lanes = mergeLanes(alternate.lanes, lane)
  }

  // 하위 노드에 업데이트가 발생했음을 새긴다.
  let parent = sourceFiber.return;
  let node = sourceFiber;
  while (parent !== null) {
    parent.childLanes = mergeLanes(parent.childLanes, lane);
    alternate = parent.alternate;
    if (alternate !== null) {
      alternate.childLanes = mergeLanes(alternate.childLanes, lane);
    }

    node = parent;
    parent = parent.return;
  }
}

렌더링은 항상 root부터 시작하기 때문에 위에서도 언급했지만 VDOM에서 발생한 업데이트에 할당된 렌더링 보류 중인 Lanes를 알고 있어야 합니다. 그래서 업데이트 발생 시점에 root의 pendingLanes에도 Lane을 기록합니다.

reconciler > ReactFiberLane.js

export function markRootUpdated(
  root: FiberRoot,
  updateLane: Lane,
  eventTime: number
) {
  root.pendingLanes |= updateLane
  /*...*/
}

Lane이 도입된 배경과 업데이트에 Lane을 할당하고 렌더링 대상을 선택하는 과정을 확인했습니다. 하지만 아직 Lane 기반의 렌더링 과정을 확인하지는 않았습니다. 이는 다음 Transition Lane 글에서 전환이 무엇이고 Transition Lane이 세부적으로 어떻게 처리되는지 확인하면서 같이 확인하게 될 것입니다.


  1. Expiration Time의 정의는 이전 포스터에서 확인하세요.

  2. 모드 방식은 리액트 18에서는 제거되었습니다.


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