Tech

Diary

Lecture

개발중

About Me

개발중

React early bailout

JeongSeulho

2023년 11월 14일

준비중...
클립보드로 복사
thumbnail

0. 들어가며

우연히 유튜브에서 면접 질문을 보았다. 다음 코드의 콘솔을 맞추는 문제인데

copy
  const [state, setState] = useState(0);
  console.log('state', state);

  useEffect(() => {
    setState(1);
  }, [state]);

처음 렌더시 state 0, 이후 setState(1)이 호출되고 다시 렌더시 state 1, useEffect의 의존성 배열에 state가 있으므로 setState(1)이 호출 되지만 1 => 1로 상태가 변하였으므로 다시 렌더가 일어나지 않을 것이므로 정답을 state 0, state 1이라 생각했다.
하지만 정답은 state 0, state 1, state 1이었다.
유튜브에서의 설명도 나랑 완전히 같지만 setState(1)이 호출 되어 1 => 1로 상태가 변하지 않음에도 렌더가 일어난다고 설명한다. 나는 이해가 가지 않았다, 분명 setState가 호출되어도 상태가 변하지 않으면 최적화로 리렌더링이 일어나지 않는다고 알고 있었기 때문이다.

다음 코드를 확인해 보자.

copy
  const [state, setState] = useState(0);
  console.log('state', state);

  useEffect(() => {
    setState(0);
  }, [state]);

이 코드의 결과는 분명히 state 0이다. 분명히 리렌더링이 일어나지 않는다. 내가 생각한 것처럼 setState(0)이 호출되어도 0 => 0으로 상태가 변하지 않기 때문이다, 근데 왜 처음 문제의 코드는 리렌더링이 일어나는 것일까?

1. early bailout 조건

setState에서 이전 상태와 다음 상태를 비교하여 상태가 변하지 않으면 리렌더링을 하지 않도록 최적화 하는 것을 early bailout이라고 한다. setState를 정의하는 소스코드를 확인해 보자.

copy
    const alternate = fiber.alternate;
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher;
        try {
          const currentState: S = (queue.lastRenderedState: any);
          const eagerState = lastRenderedReducer(currentState, action);
          update.hasEagerState = true;
          update.eagerState = eagerState;
          if (is(eagerState, currentState)) {
            enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
            return;
          }
        }
      }
    }

    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);

위 코드에서

copy
if (is(eagerState, currentState)) {
  enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
  return;
}

이 부분이 early bailout을 하는 부분이다. 이전 상태와 다음 상태를 비교하여 상태가 변하지 않으면 return하여 리렌더링을 하지 않도록 한다. 만약 early bailout이 발생하지 않으면 enqueueConcurrentHookUpdate를 호출하여 리렌더링을 진행한다.

이 부분이 실행되기 위해 또 다른 조건문이 있는데 그것이

copy
if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) { 
      ...
    }

이 부분이다. early bailout이 발생하기 위한 조건을 정리하면

  1. fiber.lanes === NoLanes
  2. alternate === null || alternate.lanes === NoLanes
  3. is(eagerState, currentState)

이렇게 3가지 조건이 모두 만족해야 early bailout이 발생한다.
fiber,alternatelanes, NoLanes은 무엇인지 알아보자.

2. Virtual DOM

fiber, alternate, lanes, NoLanes 모두 Virtual DOM의 구조와 관련있는 용어이다.

(1) Virtual DOM의 구조

위 코드에서 fiberVirtual DOM을 이루는 각 노드를 지칭한다, 또한 Virtual DOM은 더블 버퍼링 구조로 2가지의 트리가 존재하는데, current V DOMworkInProgress V DOM이다.

  • current V DOM : 실제 DOM에 마운트 된 fiber 노드들로 이루어진 트리이다.
  • workInProgress V DOM : render phase에서 작업 중인 fiber노드 트리이다.

그리고 workInProgress V DOMcurrent V DOM을 복사하여 만들어 지며 이때 alternate라는 키로 서로를 참조하게 된다.

(2) render phase, commit phase

render phase는 재조정하는 단계이다.
2개의 V DOM을 비교하며 수정 사항에대해 DOM에 적용하기 위한 WORK를 스케줄러에 등록한다.

commit phaseworkInProgress V DOM을 실제 DOM에 반영하는 단계이다. 또한 이러한 DOM 조작과 useEffect와 같은 라이프사이클을 실행한다.
이러한 반영이 모두 끝나면 브라우저가 DOM을 기반으로 화면을 그리게 된다.

(3) Lanes

lanes란 다양한 이벤트에대한 업데이트의 우선 순위를 관리하는 데 사용되는 개념이다.
각 업데이트는 다른 Lane에 할당되며, 이는 React가 어떤 업데이트를 먼저 처리할지 결정하는 데 사용된다. NoLanesLane이 할당되지 않은 상태 즉, 업데이트할 내용이 없는 상태를 의미한다.

예를들어 사용자의 상호 작용 이벤트는 가장 높은 우선 순위를 가지는 Lane에 할당 된다.

(4) 정리

image
위 사진에서 Root Node와 연결되어 있는 treecurrent V DOM이 되는 것이다.

render phase에서 workInProgress V DOM에서 업데이트가 일어나며 업데이트가 완료되면 commit phase가 진행되며 Root NodeworkInProgress V DOM과 연결된다.
이렇게 연결된 순간 workInProgress V DOMcurrent V DOM로 변경되는 것이다.

  • fiber

    • Virtual DOM을 이루는 각 노드
    • alternate, lanes와 같은 여러 속성을 가지고 있다.
  • alternate

    • workInProgress V DOMcurrent V DOM을 서로 참조하는 키
  • lanes

    • 다양한 업데이트의 우선 순위를 관리하는 개념

3. lanes 코드 해석

위 개념을 기반으로 조건문을 해석해보면

  1. fiber.lanes === NoLanes이란 현재 fiber에서 업데이트할 내용이 없다는 것을 의미한다.

  2. alternate === null || alternate.lanes === NoLanes이란 현재 fiberalternate 즉, workInProgress V DOM의 대응하는 fiber가 없거나 workInProgress V DOM의 대응하는 fiber에서 업데이트할 내용이 없다는 것을 의미한다.

early bailout이 발생하려면 이전 상태와 변경된 상태를 비교하기 전에 먼저 2가지 조건문을 모두 만족해야 한다.

그러면 lanes은 언제 NoLanes이 되고 언제 NoLanes가 아닌지 알아보자.

(1) lanesdirty

lanes에 어떤 업데이트가 할당되면 lanesdirty가 된다. 즉, NoLanes가 아닌 상태가 된다.
이렇게 dirty하게 되는 순간은 enqueueUpdate가 호출되는 순간이다.
위 코드에서 early bailout이 발생하지 않을때

copy
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);

이 함수가 실행된다. 이 함수는

copy
export function enqueueConcurrentHookUpdate<S, A>(
  fiber: Fiber,
  queue: HookQueue<S, A>,
  update: HookUpdate<S, A>,
  lane: Lane,
): FiberRoot | null {

  ...

  enqueueUpdate(fiber, concurrentQueue, concurrentUpdate, lane);

  ...

}

이렇게 정의 되며 여기서 enqueueUpdate가 호출된다.

copy
function enqueueUpdate(
  fiber: Fiber,
  queue: ConcurrentQueue | null,
  update: ConcurrentUpdate | null,
  lane: Lane,
) {

  ...

  fiber.lanes = mergeLanes(fiber.lanes, lane);
  const alternate = fiber.alternate;
  if (alternate !== null) {
    alternate.lanes = mergeLanes(alternate.lanes, lane);
  }
}

이 코드를 보면 fiberalternatelanes모두 dirty하게 된다.

즉, early bailout이 발생하지 않는다면 setState호출 시 fiberalternatelanes모두 dirty하게 된다.

(2) lanesclean

lanesNoLanes가 되는 코드는

copy
function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  ...
  workInProgress.lanes = NoLanes;
  ...
}

이 코드이다, beginWork라는 작업을 하기 전에 해당 workInProgress트리의 fiber에대한 lanesNoLanes로 초기화한다.
여기서 위에서 언급하던 현재 fiberlanesdirty상태가 되면서 workInProgress가 된 것을 알 수 있다.

beginWorkrender phase에서 workInProgress V DOM을 만들거나 수정하는 작업을 하는 함수이다.

image

즉, render phase에서 workInProgresslanesclean하게 된다.

(3) 정리

위의 내용을 정리하면

  1. setState 호출 시 fiberalternatelanesdirty하게 된다.(단, early bailout이 발생하지 않는다면)
  2. setState 호출 이후 render phase에서 workInProgress(1번에서의 fiber)의 lanesclean하게 된다.

이제 처음 의문이 생긴 코드를 해석해보자.

3. 문제 코드 해석

(1) early bailout이 발생하는 코드

copy
  const [state, setState] = useState(0);
  console.log('state', state);

  useEffect(() => {
    setState(0);
  }, [state]);

처음 state가 0이고 마운트 이후 setState(0)실행 하면 이 시점에서

  1. 첫 렌더링이므로 fiber.lanes === NoLanestrue이다.
  2. workInProgress V DOM은 없으므로 alternate === nulltrue이다.
  3. is(eagerState, currentState)true이다.

즉, early bailout이 발생한다.

workInProgress V DOM은 최소한 1번 이상의 업데이트가 발생하여야 생성된다.
업데이트가 발생되어 workInProgress V DOM이 필요한 순간에야 current V DOM을 복사한 workInProgress V DOM이 생성되고 업데이트가 진행된다.

(2) early bailout이 발생하지 않는 코드

copy
  const [state, setState] = useState(0);
  console.log('state', state);

  useEffect(() => {
    setState(1);
  }, [state]);

이 코드의 과정을 2개의 V DOM을 기준으로 설명해보자.
2개의 V DOM은 swap이 되면서 역할이 바뀌는 것이기 때문에 fiber1, fiber2로 표기하겠다.

  1. 첫 렌더링 이후
    • fiber1 : current, clean
    • fiber2 : null
  2. setState(1) 호출 이후 enqueueUpdate로 인해 workInProgress생성 및 2개의 V DOM을 모두 dirty
    • fiber1 : current, dirty
    • fiber2 : workInProgress, dirty
  3. render phase에서 beginWork가 호출되면서 workInProgressclean
    • fiber1 : current, dirty
    • fiber2 : workInProgress, clean
  4. commit phase이후 2개의 V DOM이 swap
    • fiber1 : workInProgress, dirty
    • fiber2 : current, clean
  5. 의존성 배열에 state로 인한 setState(1)이 호출
    • fiber1 : workInProgress, dirty
    • fiber2 : current, dirty
  6. beginWork 호출

5번에서 setState(1)이 호출되어 1 => 1로 상태가 변하지 않았지만 early bailout으로 빠지지 않았고 6번에서 render phase가 진행되면서 컴포넌트가 다시 실행되는 것이다.

4. 마치며

굉장히 Low한 레벨의 내용이라 이해하는데 굉장히 오래걸리고 어려웠다.
중요한 점은 state가 변하지 않는 setState 호출에도 리렌더링 최적화가 보장되지 않는다는 것이다.

출처
JSer.dev naver D2Boaz youtubebumkeyytnghgks