[React] 레이아웃 훅 수정이 결제버튼 테스트에 영향을 미치는 이유
공부내용 공유하기

[React] 레이아웃 훅 수정이 결제버튼 테스트에 영향을 미치는 이유

얼마 전, 회사에서 흥미로운 디버깅 이슈가 있었다.

 

우리 팀에서는 프로젝트 내부에서 사용하기 위해 여러 커스텀 훅을 만들어 재사용하고 있는데, 그중 레이아웃을 감지해서 특정 로직을 실행시킬 수 있는 훅이 존재한다.

 

이 훅은 useEffect훅을 사용하여 유사한 인터페이스를 확장한 커스텀 훅으로, window.innerWidth 감지를 통해 레이아웃을 알려주는 역할을 한다.

 

간소화해서 윤곽만 나타내면 대충 아래와 같은 형태로 구현되어 있다. (throttle 등은 생략)

 

const useDisplayDetectEffect = (effect: Effect, deps: Deps) => {
  const callback = useCallback(effect, deps);

  useEffect(() => {
    const resizeHandler = () => {
      const detectedDisplay: DetectedDisplay = getDetectedDisplay(window.innerWidth);
      effect(detectedDisplay);
    };
    resizeHandler();

    window.addEventListener('resize', resizeHandler);

    return () => {
      window.removeEventListener('resize', resizeHandler);
    };
  }, [callback]);
};

 

문제는 이 커스텀 훅에서 사용하는 useEffect 훅을 useLayoutEffect 훅으로 교체하는 상황에서 발생하였다.

 

변경사항에 영향을 받은 컴포넌트 중, 테스트 하나가 실패한 것이다.

 

뜬금없이 실패하는 테스트

 

의아하게도 실패한 테스트는 결제 폼 컴포넌트 내부의 결제 버튼 렌더링에 대한 테스트였다.

아니 레이아웃 컴포넌트를 건드렸는데 왜 결제버튼 렌더링 테스트가 실패하는 것이지...?

 

일단 해당 컴포넌트 자체가 만든 지 오래된 컴포넌트인 관계로 디버깅이 힘들었다.

역할에 따른 컴포넌트 분리 및 레이어 분리가 제대로 되어 있지 않은 상태, 쉽게 말해 레거시 코드에 가까워서 더욱 그랬던 것 같다.

다행히 팀원분의 재빠른 디버깅으로 문제가 발생하는 코드를 정확히 찾을 수 있었다.

 

실패하는 테스트는 다름아닌 결제 폼 컴포넌트에 대한 테스트였는데, 이 컴포넌트의 레이아웃 컴포넌트 > GNB 컴포넌트 > 도시 필터 컴포넌트에서 해당 훅을 사용하고 있었다;

 

구조가 대체 왜 이렇지...? 하는 생각이 들었지만 그 문제는 차치하고 일단 필터 컴포넌트에 선언된 해당 훅의 쓰임새를 살펴보았다.

 

const [isDesktop, setIsDesktop] = useState<boolean>(false);

useDisplayDetectEffect((detectedDisplay) => {
    switch (detectedDisplay) {
      case 'desktop':
        setIsDesktop(true);
        break;
      default:
        setIsDesktop(false);
    }
  }, []);

 

훅 내부에 감지된 레이아웃에 따라 state를 변경하는 로직이 존재했다.

 

setIsDescktop(true)인 부분을 주석으로 처리해보니 테스트를 잘 통과했다.

따라서 훅 내부에서의 state의 변경이 테스트를 깨지게 했다는 것은 알 수 있었다.

 

하지만 이 시점에서는 도무지 이유를 알 수가 없었다.

해당 상태 변경으로 인한 컴포넌트의 추가 마운트는 없었고, 더군다나 GNB 쪽에 존재하는 로직이 결제 버튼에 어떤 영향을 미치는지 도저히 예측이 안 되었기 때문이다.

 

그다음으로는 결제 버튼의 테스트가 실패하는 이유를 알아보기 위해, 테스트 코드 내부에서 screen.debug()를 사용했다.

screen.debug는 RTL에서 제공하는 디버깅 메소드로, 호출 시점의 jsdom snapshot을 제공해준다.

 

 

About Queries | Testing Library

Overview

testing-library.com

 

스냅샷을 살펴보니 놀라운 사실이 발견되었다.

 

버튼이 로딩 상태로 변경되면서, getBy 쿼리를 통한 버튼 찾기에 실패했던 것이었다.

버튼이 보이긴 했으나 로딩 상태로 유지되면서 버튼 텍스트가 아닌 로딩 아이콘이 보이고 있었고, 그 때문에 테스트가 실패했던 것이다.

 

그렇다면 레이아웃 감지 훅 내부의 구현을 useEffect > useLayoutEffect로 변경하기 전에는 통과하던 테스트가, 어째서 변경 후에는 버튼의 loading 상태로 인해 실패하는 것일까?

 

그 부분을 알기 위해 결제 버튼의 내부 구현을 따라가 보았다.

 

실마리는 결제 버튼 컴포넌트의 로딩 상태 조건에서 찾을 수 있었다.

 

const { loadingForScript } = usePayment();

return (
    <Button
      ...
      loading={loadingForScript}
    />
);

 

결제 버튼은 대강 위와 같이 구현되어 있었는데, 로딩 상태가 되며 버튼의 텍스트가 가려지는 이유는 바로 결제를 위한 스크립트를 불러오기 때문임을 알 수 있었다.

 

자세한 도메인 히스토리를 공개하긴 어렵지만 결제 페이지에서 사용자가 선택한 결제 수단에 따라 로드해야 하는 외부 스크립트가 다르다.

때문에 동적으로 해당 스크립트를 로드하고 그 과정에서 버튼의 로딩 상태를 바꿔주는 것이다.

 

결제 훅 내부에서는 스크립트를 로드하는 다른 커스텀 훅을 사용하고 있었는데 해당 훅의 구현은 대략적으로 다음과 같다.

 

const useLoadScript: UseLoadScript = (src) => {
  const [isLoaded, setIsLoaded] = useState<boolean>(() => {
    const isAlreadyExistScript: boolean = getIsAlreadyExistScript(src);
    return isAlreadyExistScript;
  });
  
  const [loading, setLoading] = useState<boolean>(false);

  useEffect(() => {
    if (isLoaded) {
      return;
    }

    async function _loadScript() {
      setLoading(true);
      try {
        await loadScript(src);
        setIsLoaded(true);
      } finally {
        setLoading(false);
      }
    }
    void _loadScript();
  }, [isLoaded]);

  //...
};

 

로딩 상태를 나타내는 변수인 loading을 보자.

초기 상태에서는 false 였다가, 컴포넌트가 마운트 된 이후 useEffect 훅을 통해 true로 변경되는 모습을 알 수 있다.

 

그렇다면? 브라우저의 paint 직후 버튼 컴포넌트는 기본적으로 loading 상태가 false이기 때문에 정상적으로 노출될 것이고, paint 이후 실행되는 useEffect 내부 로직으로 인해 loading 상태로 변할 것이라고 추측할 수 있다.

 

기존 테스트 코드가 통과했던 이유는 paint 직후의 버튼 컴포넌트를 확인했기 때문에 통과가 되었으리라는 것도 미루어 짐작할 수 있을 것이다. (정확히는 jsDom 내부에서의 동작이기 때문에 브라우저의 paint와는 다름)

 

그렇다면 왜? 전혀 상관이 없는 다른 훅을 useEffect에서 useLayoutEffect로 바꿨을 때 이 테스트가 실패하게 되는 것일까?

다시 말하자면, 왜 갑자기 버튼 컴포넌트의 로딩 상태가 false인 경우를 테스트 코드에서 감지하지 못하게 된 것일까?

 

여기까지 읽고 어떤 이유 때문에 이런 현상이 나타났는지 짐작이 가능하다면, 평소에 React가 어떻게 동작하는지 관심을 가지고 소스 코드를 살펴본 적이 있는 사람일 것이다.


* 아래의 모든 소스코드는 현재 사내 프로젝트에 적용된 React 17.0.2를 기준으로 참고하였다.

왜 이런 현상이 일어났는지 더 설명하기 전에 useEffect와 useLayoutEffect의 실행 시점에 대해 알아볼 필요가 있다.

 

 

github.com/donavon/hook-flow

 

useLayoutEffect는 화면 갱신으로 인한 layout shift 방지를 위해 사용되며, 이 때문에 useEffect와는 다르게 paint 전에 동기적으로 실행된다.

Updates scheduled inside useLayoutEffect will be flushed synchronously, before the browser has a chance to paint.

그리고 useLayoutEffect 내부의 업데이트를 일으키는 변경사항 역시 브라우저의 paint 이전에 동기적으로 반영된다.

 

 

 

조금 더 깊게 들어가보자.

 

React는 크게 렌더 페이즈(Render Phase)와 커밋 페이즈(Commit Phase) 2개의 페이즈로 나눠서 동작한다.

 

렌더 페이즈는 React의 가상 돔에서 개별 노드의 역할을 하는 Fiber를 생성하고 Fiber Tree를 만드는 과정이다.

렌더 페이즈에서 우리가 익히 알고 있는 재조정(reconciliation)이 동작하며 깊이 우선 탐색(DFS) 형태로 트리를 탐색한 후 탐색이 끝나면 root element(시작 Node이자 트리의 최상위 꼭짓점)를 반환하며 종료된다.

 

커밋 페이즈는 렌더 페이즈에서 호출된 컴포넌트들을 바탕으로 만들어진 VDOM을 실제 DOM으로 만드는 과정이며 커밋 페이즈를 거쳐 브라우저의 paint를 거치게 되면 비로소 눈으로 볼 수 있게 된다.

 

이 과정에서 useEffect와 useLayoutEffect가 등장하니 자세히 살펴보자.

 

커밋 페이즈는 commitRoot 메소드를 통해 실행되며 세부 구현은 commitRootImpl 구현체에 자세히 나와 있다.

commitRootImpl의 시작 부분은 다음과 같다.

 

function commitRootImpl(root, renderPriorityLevel) {
  do {
    // `flushPassiveEffects` will call `flushSyncUpdateQueue` at the end, which
    // means `flushPassiveEffects` will sometimes result in additional
    // passive effects. So we need to keep flushing in a loop until there are
    // no more pending effects.
    // TODO: Might be better if `flushPassiveEffects` did not automatically
    // flush synchronous work at the end, to avoid factoring hazards like this.
    flushPassiveEffects();
  } while (rootWithPendingPassiveEffects !== null);
  
  //...
  
  }

https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L1888

 

커밋 페이즈 시작과 동시에 pending상태의 passiveEffect(useEffect)들을 모두 flush 처리하게 되는데, 이 부분은 useEffect에 대한 React 공식문서 설명에도 기재되어 있다. (effect 내부의 로직이 실행되면서 다시 effect가 호출될 수 있기 때문에 loop 사용)

즉 "React will always flush a previous render's effects before starting a new update" 요 부분에 해당되는 코드라고 보면 되겠다.

 

 

커밋 페이즈는 다시 3개의 서브 페이즈로 나뉘게 된다.

 

 

1. commitBeforeMutationEffects

 

DOM에 직접적인 변형을 가하기 전의 작업들을 수행하는 페이즈이다.

이 단계에서 useEffect 훅이 소비(호출이 아니라 소비)된다.

 

앞서 렌더 페이즈에서 reconciliation이 동작하며 트리를 탐색하고 최종적으로는 root element를 반환한다고 했는데, 이 과정에서 각 Fiber 노드 내부에 있는 effect(useEffect + useLayoutEffect)들을 참조하여 root element로 끌고 오게 된다. 쉽게 말해 전체 트리를 순회하면서 각 컴포넌트의 effect를 모두 취합해서 가져온다는 이야기이다. (연결 리스트로 참조해서 가져옴)

 

useEffect의 소비는 다음과 같이 이루어진다.

 

function commitBeforeMutationEffects() {
  while (nextEffect !== null) {
    const current = nextEffect.alternate;
    //...

    const flags = nextEffect.flags;
    //...
    
    if ((flags & Passive) !== NoFlags) {
      // If there are passive effects, schedule a callback to flush at
      // the earliest opportunity.
      if (!rootDoesHavePassiveEffects) {
        rootDoesHavePassiveEffects = true;
        
        scheduleCallback(NormalSchedulerPriority, () => {
          flushPassiveEffects(); // <--- 요기
          return null;
        });
      }
    }
    nextEffect = nextEffect.nextEffect;
  }
}

https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L2292

 

이때, 소비하는 effect가 useEffect임을 나타내는 Passive 태그는 React 훅의 구현체에서 달아두게 된다. (useLayoutEffect의 경우 UpdateEffect flag만 달림)

ReactFiberHooks

이렇게 passive effect에 대한 처리가 commitBeforeMutationEffects에서 이뤄지게 된다.

유의할 점은 바로 호출이 아니라 스케줄러를 통한 작업 예약이 되었다는 점이다.

 

 

2. commitMutationEffects

 

Dom에 실질적인 변경을 가하는 페이즈이다.

여기서는 useLayoutEffect 실행 전 clean-up이 일어난다. 자세히 살펴보면 아래와 같다.

 

function commitMutationEffects(
  root: FiberRoot,
  renderPriorityLevel: ReactPriorityLevel,
) {
  // text content 리셋, ref detach/attach 등의 동작

  const primaryFlags = flags & (Placement | Update | Deletion | Hydrating);
  // SSR의 Hydration 과정도 Commit Phase에서 일어남을 알 수 있다.
    
  switch (primaryFlags) {
    //...
    case Update: { // useLayoutEffect의 Flag 확인
      const current = nextEffect.alternate;
      commitWork(current, nextEffect);
      break;
    }
    //...
  }
}

https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L2302

 

commitWork를 따라가 보면 useLayoutEffect와 관련된 메소드를 만날 수 있다.

 

function commitHookEffectListUnmount(
  flags: HookFlags,
  finishedWork: Fiber,
  nearestMountedAncestor: Fiber | null,
) {
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & flags) === flags) {
        // Unmount
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {
          safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

//...

export function safelyCallDestroy(
  current: Fiber,
  nearestMountedAncestor: Fiber | null,
  destroy: () => void,
) {
  try {
    destroy();
  } catch (error) {
    captureCommitPhaseError(current, nearestMountedAncestor, error);
  }
}

https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberCommitWork.new.js#L329

 

뭐야? 갑자기 어디서 나온 destroy를 호출하는 거지?라는 생각이 들 수도 있다.

정답을 찾기 위해 effect 구현체에 대해 보...기 전에 hook을 mount 하는 함수를 먼저 살펴보자.

 

function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) {
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & flags) === flags) {
        // Mount
        
        const create = effect.create;
        // 아아... 파괴는 곧 새로운 창조이니...
        effect.destroy = create();
        
        //...

 

https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberCommitWork.new.js#L363

 

마운트 시점에 effect 훅은 자신이 가지고 있던 create 메소드를 호출해 destroy에 할당한다.

뭔가 감이 오지 않는가?

안 오는가?

 

effect 훅의 구현체를 보면 이해가 되면서 이마를 를 탁 치게 될 것이다.

 

function updateEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return updateEffectImpl(
    UpdateEffect | PassiveEffect,
    HookPassive,
    create,
    deps,
  );
}

https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberHooks.old.js#L1250

 

그렇다. create는 바로 우리가 effect에 실행하고자 하는 로직을 넣은 부분이다.

그렇다면 destroy는 create를 실행한 결과. 다시 말해 return에 해당하는 것이다.

 

useEffect(() => {
  // 하고싶은 것 (create)

  return () => {
    // 리소스 정리 (destory)
  }
},[])

 

우리가 왜 effect 내부에서 clean-up을 위한 로직을 함수로 만들어서 반환하는지, 그 이유가 여기에 있었던 것이다.

 

뭔가 중간에 삼천포로 빠졌는데, 이 페이즈에서는 결론적으로 useLayoutEffect의 clean-up을 하게 된다.

 

 

3. commitLayoutEffects

 

Dom에 변경을 가한 후의 작업을 처리하는 페이즈이자 대망의 useLayoutEffect를 실행하는 곳이다.

 

필요한 부분만 보자.

function commitLayoutEffects(root: FiberRoot, committedLanes: Lanes) {
  while (nextEffect !== null) {
    const flags = nextEffect.flags;

    if (flags & (Update | Callback)) {
      const current = nextEffect.alternate;
      commitLayoutEffectOnFiber(root, current, nextEffect, committedLanes);
    }
    //...

https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L2385

 

flag가 Update인걸 보니 useLayoutEffect를 실행하는 부분이 맞다.

 

commitLayoutEffectOnFiber는 commitLifeCycles의 alias이다.

function commitLifeCycles(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
): void {
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent:
    case Block: {
      if (
        enableProfilerTimer &&
        enableProfilerCommitHooks &&
        finishedWork.mode & ProfileMode
      ) {
        try {
          startLayoutEffectTimer();
          commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
        } finally {
          recordLayoutEffectDuration(finishedWork);
        }
      } else {
        commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
      }

      schedulePassiveEffects(finishedWork);
      return;
    }

https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberCommitWork.old.js#L454

 

아까 살포시 봤던 commitHookEffectListMount를 호출하는 모습이 보인다.

그 이야기는? 훅의 create() 즉, 훅의 실행 로직이 트리거 된다는 이야기이다.

 

// Mount
const create = effect.create;
effect.destroy = create();

 

바로 여기가 useLayoutEffect 훅의 실행 지점이라고 할 수 있겠다.

여기서 우리가 호출한 상태의 변경(setIsDesktop(true))이 일어난다면 어떻게 될까?

 

ReactFiberHooks에 구현되어 있는 dispatchAction을 호출하게 된다.

 

function dispatchAction<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
) {
  //...
  // 엄청나게 많은 코드들이 있지만 DEV용 지우고, 이전 state와 확인하는 코드 지우고, 
  // 렌더 페이즈 관련 상태 변경 등을 처리하면 제일 중요한 아래 메소드가 남는다.
  
if (
    fiber === currentlyRenderingFiber ||
    (alternate !== null && alternate === currentlyRenderingFiber)
  ) {
    // 렌더 페이즈 중 업데이트 발생
  } else {
    // ...이전 상태와의 비교를 통해 update skip 여부를 확인
    
    // 렌더 페이즈가 아닌 커밋 페이즈이기 때문에 우리가 봐야 할 것은 아래 메소드이다
    scheduleUpdateOnFiber(fiber, lane, eventTime);
  }
 }

https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/react-reconciler/src/ReactFiberHooks.old.js#L1645

 

React에 업데이트가 필요하다고 알려주는 scheduleUpdateOnFiber는 다음과 같다.

 

export function scheduleUpdateOnFiber(
  fiber: Fiber,
  lane: Lane,
  eventTime: number,
) {
  // nestedUpdate(effect내에서 다시 effect를 호출)가 50회 이상 일어나면 에러를 발생시킨다.
  // react16에서는 25회였던걸로 기억하는데 react17에서는 50회가 되었다.
  checkForNestedUpdates(); 
  
  //...

  if (root === workInProgressRoot) {
    //...
    // 렌더페이즈의 처리
  }

  if (lane === SyncLane) {
    if (
      // Check if we're inside unbatchedUpdates
      (executionContext & LegacyUnbatchedContext) !== NoContext &&
      // Check if we're not already rendering
      (executionContext & (RenderContext | CommitContext)) === NoContext
    ) {
      //...
      // 첫 렌더시 처리
    } else {
      // * root에 작업을 스케줄링
      ensureRootIsScheduled(root, eventTime);
      schedulePendingInteractions(root, lane);
      
      if (executionContext === NoContext) {
        // 이 시점의 executionContext는 commitPhase이다
        // ...
      }
    }
  } else {
    // Schedule a discrete update but only if it's not Sync.
    // 개별 업데이트 처리를 비동기로 하는걸 보니 concurrent mode용 처리같다...? 추측중
  }
  
  //...
}

https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L517

 

업데이트가 발생했기 때문에 root의 스케줄링과 관련된 ensureRootIsScheduled 호출이 필요하다.

 

function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
  const existingCallbackNode = root.callbackNode;
  //...
  
  if (existingCallbackNode !== null) {
    const existingCallbackPriority = root.callbackPriority;
    if (existingCallbackPriority === newCallbackPriority) {
      // 이미 작업이 진행된 경우 스킵
      return;
    }
    
    cancelCallback(existingCallbackNode);
  }

  // Schedule a new callback.
  let newCallbackNode;
  if (newCallbackPriority === SyncLanePriority) {
    
    // performSyncWorkOnRoot를 syncCallback으로 스케줄링한다.
    newCallbackNode = scheduleSyncCallback(
      performSyncWorkOnRoot.bind(null, root),
    );
  }
  //...

  root.callbackPriority = newCallbackPriority;
  root.callbackNode = newCallbackNode;
}

https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L674

 

이곳에서 performSyncWorkOnRoot를 스케줄링한다. performSyncWorkOnRoot는 React로 하여금 새로 렌더링을 하게끔 만드는 메소드이며, 호출시 다시 한 번 렌더 페이즈-커밋 페이즈 사이클이 동작한다.

 

performSyncWorkOnRoot는 시작과 동시에 passiveEffect들을 flush 하는 flushPassiveEffects메소드를 호출한다. 사실상 React에서 말하는 '업데이트 전 기존의 모든 effect 호출 보장'이 이 부분을 통해 구현되는 것이기도 하다.

 

이렇게 commitLayoutEffects까지 서브 페이즈가 모두 끝났지만 아직 커밋 페이즈 전체가 끝나서 렌더링이 마무리된 것은 아니다.

 

서브 페이즈 종료 이후에는 requestPaint()를 호출해 브라우저에게 painting이 필요하다는 사실을 알린다.

 

requestPaint를 호출했다고 바로 paint가 되는 것은 아니고, 정확히는 스케줄러로 하여금 작업이 종료된 후 브라우저로 하여금 paint를 할 시간을 줄 수 있게끔 call stack을 비워달라는 요청을 하는 것이다. 그래서 메소드 명도 paint가 아니라 requestPaint인 것이다.

 

그러므로 아직 React에게는 paint 전 dom을 수정할 수 있는 시간이 있다.

 

이후에는 브라우저가 paint를 할 수 있게끔, layout을 최종적으로 결정하게 된다.

 

 if ((executionContext & LegacyUnbatchedContext) !== NoContext) {
    // ...
    // This is a legacy edge case. We just committed the initial mount of
    // a ReactDOM.render-ed root inside of batchedUpdates. The commit fired
    // synchronously, but layout updates should be deferred until the end
    // of the batch.
    return null;
  }
  
  // If layout work was scheduled, flush it now.
  flushSyncCallbackQueue();

https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L2222

 

LegacyUnbatchedContext의 경우 unbatchedUpdates 호출 시 설정되는 컨텍스트이다.

unbatchedUpdates 메소드는 ReactDOM에서 사용하고 있으며 VDOM이 첫 생성될 경우, 즉 initial rendering 단계에서 호출된다.

 

우리의 경우에는 VDOM 첫 생성 케이스이기 때문에 unbatchedUpdates가 호출된다.

 

export function unbatchedUpdates<A, R>(fn: (a: A) => R, a: A): R {
  const prevExecutionContext = executionContext;
  executionContext &= ~BatchedContext;
  executionContext |= LegacyUnbatchedContext;
  try {
    return fn(a);
  } finally {
    // 실행 컨텍스트 원복
    executionContext = prevExecutionContext;
    if (executionContext === NoContext) {
      // Flush the immediate callbacks that were scheduled during this batch
      resetRenderTimer();
      flushSyncCallbackQueue();
    }
  }
}

https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L1191

 

그리고 finally 구문을 통해 기존의 실행 컨텍스트를 돌려받고 flushSyncCallbackQueue를 실행하게 된다. 이 과정에서 스케줄링되어있던 preformSyncWorkOnRoot가 실행되는데, 위에서 말한 대로 이때 flushPassiveEffects를 통해 passiveEffect가 모두 동기적으로 실행된다.

 

만약 이때 실행되는 useEffect 내부에서 state의 변경 등이 또 일어난다면, 다시 한번 DispatchAction -> scheduleUpdateOnFiber -> ensureRootIsScheduled 등이 호출되는 것이다. 아마 nestedUpdateCount도 ++ 될 것이다.

 

 

만약 layoutEffect 내부에서 상태의 변경이 없었다면 어떻게 되었을까?

 

커밋 페이즈에서 preformSyncWorkOnRoot가 스케줄링될 일이 없었을 것이기 때문에 useEffect의 flush는 일어나지 않고, 스케줄러에 등록된 상태로 DOM이 그려진 이후 실행되었을 것이다.

 

...

 

이렇게 실제 코드 분석을 통해 테스트가 깨졌는지 알 수 있었다.

 

useEffect 훅을 useLayoutEffect 훅으로 교체하려던 이유는 해당 훅이 유저에게 보이는 요소에 영향을 주기 때문이었다.

 

단순히 생명주기상 조금이라도 앞서 실행시키는 게 UX에 좋지 않을까 하는 생각으로 진행한 수정사항이었는데, 테스트가 깨지면서 React의 effect 동작과 렌더링 싸이클에 대해 보다 깊게 이해할 수 있는 기회가 되지 않았나 싶다.

 

또한 무엇보다 내가 사용하는 프레임워크의 소스 코드의 동작을 한 줄 한 줄 이해하려고 한 노력 자체가 좋은 경험이었다고 생각한다.


본문의 내용 중, 짧은 배움과 잘못된 이해로 비롯된 부분이 있을 거라 생각합니다.

만약 그런 부분을 발견하게 된다면, 수고스럽더라도 댓글로 남겨주시면 큰 도움이 될 것 같습니다.

감사합니다.

 

참고자료

 

https://github.com/facebook/react

https://blog.thoughtspile.tech/2021/11/15/unintentional-layout-effect/

https://dev.to/okmttdhr/what-is-lane-in-react-4np7

https://www.sobyte.net/post/2022-01/react-lane/

https://jser.dev/

https://goidle.github.io/