React Lane을 알아보자!

이은서·2023년 1월 26일
16

개요

자바스크립트는 싱글쓰레드 환경에서 돌아가기 때문에 Render하는 부분이 모두 Javascript Stack 프레임에서 Sync 상태로 동작을 하였습니다. 그에 따라 리액트 초창기에는 DOM 트리가 복잡해 질 수록 Jank 현상이나, 반응성 저하 문제가 발생하였습니다. 이러한 문제를 해결하기 위해 16 버전 이전 부터도 이러한 비동기 렌더링에 대한 언급도 있었지만 16 버전에서 Fiber를 통해 Reconciler를 개편하고 17에서부터 Concurrent Mode라는 이름으로 비동기 렌더링을 지원 하였고 React 18 버전부터 useTransition(startTransition), useDeferredValue를 통해 Concurrency features를 정식으로 지원하게 되었습니다. 이러한 Concurrency feature를 리액트에서는 Lane을 통해 구현하였습니다. 그럼 Lane이 무엇인지 알아보도록 하겠습니다.

Lane 이란?

Lane은 실제 차선을 본따 만든 용어입니다. 자동차를 운전할 때에는 속도에 따라 운전하는 것이 규칙입니다. 1차선이 추월차선으로 제일 빠르고, 2차선이 그 다음으로 빠르고, 그리고 3차선이 그 다음.. 이런식으로 진행됩니다. (한국에서는 아닐 수도..)

Lane 뜯어보기

React Lane에서도 마찬가지로 차선이 작을수록 즉, 우선 순위가 높아집니다. React에서는 현재까지 총 31개의 레인이 있고 이는 Bit로 이루어져 있습니다. 밑에 소스코드를 보면 어떤 Lane들이 있는지 알 수 있습니다.

export const TotalLanes = 31;

export const NoLanes: Lanes = /*                        */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /*                          */ 0b0000000000000000000000000000000;

export const SyncHydrationLane: Lane = /*               */ 0b0000000000000000000000000000001;
export const SyncLane: Lane = /*                        */ 0b0000000000000000000000000000010;

export const InputContinuousHydrationLane: Lane = /*    */ 0b0000000000000000000000000000100;
export const InputContinuousLane: Lane = /*             */ 0b0000000000000000000000000001000;

export const DefaultHydrationLane: Lane = /*            */ 0b0000000000000000000000000010000;
export const DefaultLane: Lane = /*                     */ 0b0000000000000000000000000100000;

export const SyncUpdateLanes: Lane = /*                */ 0b0000000000000000000000000101010;

const TransitionHydrationLane: Lane = /*                */ 0b0000000000000000000000001000000;
const TransitionLanes: Lanes = /*                       */ 0b0000000011111111111111110000000;
const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000010000000;
const TransitionLane2: Lane = /*                        */ 0b0000000000000000000000100000000;
const TransitionLane3: Lane = /*                        */ 0b0000000000000000000001000000000;
const TransitionLane4: Lane = /*                        */ 0b0000000000000000000010000000000;
const TransitionLane5: Lane = /*                        */ 0b0000000000000000000100000000000;
const TransitionLane6: Lane = /*                        */ 0b0000000000000000001000000000000;
const TransitionLane7: Lane = /*                        */ 0b0000000000000000010000000000000;
const TransitionLane8: Lane = /*                        */ 0b0000000000000000100000000000000;
const TransitionLane9: Lane = /*                        */ 0b0000000000000001000000000000000;
const TransitionLane10: Lane = /*                       */ 0b0000000000000010000000000000000;
const TransitionLane11: Lane = /*                       */ 0b0000000000000100000000000000000;
const TransitionLane12: Lane = /*                       */ 0b0000000000001000000000000000000;
const TransitionLane13: Lane = /*                       */ 0b0000000000010000000000000000000;
const TransitionLane14: Lane = /*                       */ 0b0000000000100000000000000000000;
const TransitionLane15: Lane = /*                       */ 0b0000000001000000000000000000000;
const TransitionLane16: Lane = /*                       */ 0b0000000010000000000000000000000;

const RetryLanes: Lanes = /*                            */ 0b0000111100000000000000000000000;
const RetryLane1: Lane = /*                             */ 0b0000000100000000000000000000000;
const RetryLane2: Lane = /*                             */ 0b0000001000000000000000000000000;
const RetryLane3: Lane = /*                             */ 0b0000010000000000000000000000000;
const RetryLane4: Lane = /*                             */ 0b0000100000000000000000000000000;

export const SomeRetryLane: Lane = RetryLane1;

export const SelectiveHydrationLane: Lane = /*          */ 0b0001000000000000000000000000000;

const NonIdleLanes: Lanes = /*                          */ 0b0001111111111111111111111111111;

export const IdleHydrationLane: Lane = /*               */ 0b0010000000000000000000000000000;
export const IdleLane: Lane = /*                        */ 0b0100000000000000000000000000000;

export const OffscreenLane: Lane = /*                   */ 0b1000000000000000000000000000000;

또한 이런 Lane들을 연산하기 위해 Bit 연산을 하게 되는데 React 내부에서 주로 쓰는 함수들은 React에서 구현을 해놓았습니다.

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

export function pickArbitraryLane(lanes: Lanes): Lane {
  return getHighestPriorityLane(lanes);
}

function pickArbitraryLaneIndex(lanes: Lanes) {
  return 31 - clz32(lanes);
}

function laneToIndex(lane: Lane) {
  return pickArbitraryLaneIndex(lane);
}

export function includesSomeLane(a: Lanes | Lane, b: Lanes | Lane): boolean {
  return (a & b) !== NoLanes;
}

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

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

export function removeLanes(set: Lanes, subset: Lanes | Lane): Lanes {
  return set & ~subset;
}

export function intersectLanes(a: Lanes | Lane, b: Lanes | Lane): Lanes {
  return a & b;
}

해당 코드는 Bitwise operation을 잘 이해하고 있지 않다면 이해하기가 어려울 수도 있습니다.
자 그럼 이런 Lane을 React에서 어떻게 활용하고 있을까요?

Lane은 어떻게 쓰이고 있을까?

Fiber

export type Fiber = {
  ...
 
  lanes: Lanes,
  childLanes: Lanes,
 
  ...

Fiber에서는 lanes와 childLanes를 가지고 있습니다. 이름에서 알 수 있듯이 lanes는 해당 Fiber가 현재 처리해야 할 lanes들을 저장하고 있고, childLanes는 자식 Fiber들이 어떤 lanes를 가지고 있는지를 저장하고 있습니다.
예를 들어서 설명해 보도록 하겠습니다.

Sync Lane

function Component () {
	const [value, setValue] = useState(0)
	return <Parent>
    			<Child1>
                	<GrandChild>
                    	<button onClick = () => { setValue(v => v + 1) } >
                  	</GrandChild>
              	</Child1>
                <Child2>
                	HI
                </Child2>
           </Parent>
}
       

다음과 같이 구현 되어있다고 했을 때, button을 클릭하게 되면 setValue를 통해 값이 value의 값이 변하게 됩니다. setState를 통해 값을 변경하게 된다면 GrandChild는 rerender가 필요한 상태가 됩니다. rerender를 하기 위해서는 해당 Update가 어떤 lane 위에서 동작한지 기록을 하게 됩니다. 해당 예제에서는 Sync Lane으로 rerender가 진행이 되기 때문에 즉, GrandChild의 lanes이 2이 되고 Child1, Parent는 childLanes이 2인 상태가 됩니다.(위에서 봤듯이 Sync Lane은 2의 값을 가집니다) 반면 Child2는 해당 변화와 관계가 없기 때문에 lanes와 childLanes가 모두 0입니다. 아래 그림을 통해 보겠습니다.

클릭하기 전의 lanes와 childLanes의 상태입니다.

이처럼 클릭 후 setValue가 호출되면 React 내부적으로 dispatchSetState 함수가 호출되고 이 함수는 enqueueConcurrentHookUpdate를 호출합니다. 그리고 enqueueConcurrentHookUpdate에서 enqueueUpdate를 호출하는데 이 함수에서 업데이트 정보를 저장 해 놓습니다.

// react-reconciler/src/ReactFiberConcurrentUpdates.js

function enqueueUpdate(
  fiber: Fiber,
  queue: ConcurrentQueue | null,
  update: ConcurrentUpdate | null,
  lane: Lane,
) {
   // 현재 Reconcile을 진행하고 있는 상태 일 수도 있기 때문에 우선 concurrentQueues에 해당 변경에 대한
   // 정보를 담아두고 나중에 reconcile 될 때 해당 정보를 통해 lanes, childLanes를 업데이트 해줍니다.
  concurrentQueues[concurrentQueuesIndex++] = fiber;
  concurrentQueues[concurrentQueuesIndex++] = queue;
  concurrentQueues[concurrentQueuesIndex++] = update;
  concurrentQueues[concurrentQueuesIndex++] = lane;

  // 현재 lane을 concurrentlyUpdatedLanes에 merge해서 저장해 놓고 다음 렌더를 기다림. 이것은 나중에 root.pendingLanes로 저장된다.
  concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
  fiber.lanes = mergeLanes(fiber.lanes, lane);
  ...
  
}

그리고 scheduleUpdateOnFiber을 호출하면서 해당 변화를 스케줄러에 등록하여 다음 render를 기다립니다.

새로 render를 할 때 prepareFreshStack 함수를 호출하고 finishQueueingConcurrentUpdates에서 방금 업데이트 된 정보를 가지고 markUpdateLaneFromFiberToRoot함수를 호출하는데 그때 lanes를 부모의 childLanes로 끌어올려주게 됩니다.

function markUpdateLaneFromFiberToRoot(
  sourceFiber: Fiber,
  update: ConcurrentUpdate | null,
  lane: Lane,
): void {
  
  ...
    
  let parent = sourceFiber.return;
  let node = sourceFiber;
  while (parent !== null) {
  	// setValue 호출 했을 때의 lane과 부모의 childLanes를 계속 머지함
    parent.childLanes = mergeLanes(parent.childLanes, lane);
    alternate = parent.alternate;
    if (alternate !== null) {
      alternate.childLanes = mergeLanes(alternate.childLanes, lane);
    }

    node = parent;
    // parent.return은 부모의 리턴 Fiber, 즉 부모의 부모 Fiber이기 때문에 merge를 반복하면서 
    // root 까지 lane을 끌어올려줌
    parent = parent.return;
  }

  ...
}

클릭한 후 lanes와 childLanes 상태입니다.

이제 reconcile을 진행하면서 childLanes를 따라 update(setValue)가 일어난 곳을 찾아낼 수 있습니다. Child2에서는 bailout이 일어나 작업을 진행하지 않습니다.

이렇듯 Lane은 Sync 상태에서도 활용 되어집니다. 그러면 concurrent로 Render가 될 때에는 어떻게 처리하는지 알아보겠습니다.

useTransition

우선 간단한 예제를 준비 해 보았습니다. 입력한 텍스트에 따라 Cell의 color가 변하는 예제입니다.

    const COLORS = ["red", "blue", "green", "aliceblue", "beige",
        "brown", "snow", "hotpink", "indigo", "honeydew"]

    function _Cells({colors}) {
        return (
            <div className="cells">
                {colors.map((color, index) => <span key={index} className="cell" style={{backgroundColor: color}}/>)}
            </div>
        )
    }

    const Cells = React.memo(_Cells)

    function App() {
        const [text, setText] = useState("")
        const [colors, setColors] = useState([])

        const onChangeText = (e) => {
            setText(e.target.value)
            const pendingColors = []
            const textArr = [...text]
            for (let i = 0; i < text.length * 10; i++) {
                textArr.forEach(ch => {
                    pendingColors.push(COLORS[ch.charCodeAt(0) % 10])
                })
            }

            setColors(pendingColors)
        }

        return (
            <div className="app">
                <div>Input your text</div>
                <input
                    type="text"
                    value={text}
                    onChange={onChangeText}
                />

                <p className="text">{text}</p>

                <Cells colors={colors}/>
            </div>
        )
    }


(급조해서 만든 예제라 눈이 아프네요.. 죄송합니다..)

단순히 setState로 Colors값을 변경했기 때문에 값이 많아질수록 입력도 버벅이고 렌더도 상당히 느려집니다.이제 이 문제를 해결하기 위해 useTransition을 이용해서 동시성 렌더를 처리해보도록 하겠습니다.


    const COLORS = ["red", "blue", "green", "aliceblue", "beige",
        "brown", "snow", "hotpink", "indigo", "honeydew"]

    function _Cells({colors}) {
        return (
            <div className="cells">
                {colors.map((color, index) => <span key={index} className="cell" style={{backgroundColor: color}}/>)}
            </div>
        )
    }

    const Cells = React.memo(_Cells)

    function App() {
        const [text, setText] = useState("")
        const [colors, setColors] = useState([])
        const [isPending, startTransition] = React.useTransition()

        const onChangeText = (e) => {
            setText(e.target.value)
            startTransition(() => {
                const pendingColors = []
                const textArr = [...text]
                for(let i = 0; i < text.length * 10; i++){
                    textArr.forEach(ch => {
                        pendingColors.push(COLORS[ch.charCodeAt(0) % 10])
                    })
                }

                setColors(pendingColors)
            })
        }

        return (
            <div className="app">
                <div>Input your text</div>
                <input
                    type="text"
                    value={text}
                    onChange={onChangeText}
                />

                <p className="text">{text}</p>

                {
                    isPending ? <div>Loading...</div> : <Cells colors={colors}/>
                }
            </div>
        )
    }

이처럼 startTransition으로 setColors 작업의 우선순위를 뒤로 밀 수 있습니다. 그로 인해 다른 작업들을 먼저 처리를 해줄 수 있고 isPending을 통해서 해당 작업이 완료될 때 까지 로딩처리를 하여 사용자 경험을 높일 수 있습니다.

악! 내눈

Concurrency Feature로 render을 하면 해당 렌더에 대한 처리가 일정 시간이 넘어갈 경우 Host(browser)에 제어권을 넘기게 됩니다. 그렇기 때문에 렌더가 길어져도 이것에 의해 다른 모든 동작이 Block 되는 것을 막습니다.

setText에 대한 Render는 Sync Lane으로 저장이 되고, startTransition안에 setColors에 대한 처리는 Transition Lane으로 저장이 됩니다.

dispatchSetState(setState의 구현체)에서 확인 해 보면 해당 업데이트가 어떤 레인으로 가야할 지 가져오고 업데이트 정보에 담습니다.

function dispatchSetState(fiber, queue, action) {
    var lane = requestUpdateLane(fiber);
    var update = {
      lane: lane,
      action: action,
      hasEagerState: false,
      eagerState: null,
      next: null
    };
    
    ...
    
    var root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);

    if (root !== null) {
    	// 해당 Update를 scheduler에 올려 현재 렌더가 끝난 후 이번 레인으로 다시 렌더하게 함
        scheduleUpdateOnFiber(root, fiber, lane, eventTime);
    }

(일부 코드는 생략하였습니다.)

startTransition을 통해 setState를 호출하면 위에 코드에서 requestUpdateLane에서 lane이 Transition Lane이 반환 됩니다.

스케줄러에 새로운 업데이트(setText, setColors)가 등록 되었기 때문에 rerender가 이루어져야 하는데 우선순위에 따라 Sync 먼저 rerender를 합니다. Sync로 rerender를 하기 위해서는 React 내부에서 performSyncWorkOnRoot를 호출하게 됩니다. 그리고 getNextLanes 함수를 호출하고 getNextLanes에서 가져온 lane에 대한 렌더를 진행합니다. 실제로는 Lane이 Bit로 되어있기 때문에 root.pendingLanes의 값은 0b0000000000000000000000010000010이 되는데 가장 빠른 Lane먼저 가져오기 때문에 Sync Lane을 가져오게 되는 것입니다.

다시 정리를 해보자면
pendingLanes 가 0b0000000000000000000000010000010 인 상황에
performSyncWorkOnRoot() -> getNextLanes() -> Lane이 0b0000000000000000000000000000010 인 상태로 렌더 -> 렌더 마친 후 pendingLanes = 0b0000000000000000000000010000000

performSyncWorkOnRoot, performConcurrentWorkOnRoot가 호출될 때 찍어놓은 로그와 거기서 getNextLanes를 호출 했을 때 로그를 찍어보면 다음과 같이 나옵니다.

마지막으로 hooks에 값을 주입해주기 위해 updateReducer(updateState)를 호출해 주게 되는데 여기서도 해당 Lane으로 렌더가 진행되기 때문에 그 Lane에 대한 업데이트만 적용하게 됩니다.

function updateReducer(reducer, initialArg, init) {
	var first = baseQueue.next; // setState를 통해 update에 대한 정보를 가지고 있는 queue
    ...
    var update = first;
    
    do{
    	...
        
    	var shouldSkipUpdate = !isSubsetOfLanes(renderLanes, update.lane);
        // 현재 렌더중인 레인이 해당 업데이트의 레인에 속해있지 않을 경우 해당 업데이트는 일단 스킵 
        if (shouldSkipUpdate) {
		  ...
          
          // 이후 root.pendingLanes와 merge됩니다.
          currentlyRenderingFiber.lanes = mergeLanes(currentlyRenderingFiber.lanes, updateLane); 
          markSkippedUpdateLanes(updateLane);
        } 
    	...
        update = update.next;
    }while(update !== null && update !== first)
}

(실제 코드를 생략 및 변형했습니다)

Summary

지금까지 React Lane에 대해 살펴 보았습니다. 간단하게 총 정리를 하자면 setState를 호출하면 변화가 감지되고 리액트에서 rerender를 진행합니다. 그냥 setState를 호출한다면 Sync Lane으로 등록이 되고 다음 렌더는 Sync 상태로 이루어 질 것입니다. 하지만 useTransition 혹은 useDeferredValue를 통해 Transition Lane으로 rerender를 진행할 수 있습니다. 한 렌더에서는 하나의 lane을 처리합니다. 그렇기 때문에 Sync Lane 렌더 중에 Transition Lane의 변화를 반영하지 못한 것이죠.
Lane은 리액트가 동시성 처리하는 방법이기 때문에 알아보면 좋을 것 같아서 이렇게 정리를 해보았습니다.

처음으로 블로그 글을 작성해서 부족한 점이 많네요. 문제점이나 글에 오류가 있다면 댓글 남겨주시면 감사하겠습니다!
긴 글 읽어주셔서 감사합니다.

Image reference

https://dev.to/pagepro_agency/what-is-react-concurrent-mode-and-why-you-will-love-it-1j23

https://www.facebook.com/iloilodrivinglesson/posts/driving-on-a-6-lanes-highway-3-on-your-side-when-changing-lanes-or-overtaking-a-/1142156089198079/

profile
취준 프론트엔드 개발자

0개의 댓글