(번역) useEffect는 React 내부에서 어떻게 동작할까?

Taegyu Hwang·2024년 5월 6일
0
  • Jser.dev의 React Internals Deep Dive 시리즈How does useEffect() work internally in React?를 번역한 글입니다.
  • 원글, 본글 모두 react 18.2.0 버전 기준입니다.
  • React 코드의 주석이 아닌, 저자가 추가한 주석엔 '저자'를 앞에 붙였습니다.

useEffect()는 React에서 useState() 다음으로 가장 많이되는 훅일 것 같습니다. 매우 강력하지만, 어떤 때는 헷갈릴 수 있으므로 내부적으로 어떻게 동작하는지 알아봅시다.

useEffect(() => {
  // ...
}, [deps])

1. initial mount 때의 useEffect()

useEffect()는 initial mount 때 mountEffect()를 사용합니다.

function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return mountEffectImpl(
    // 저자) 아 flag는 layout effect와의 차이를 구분하는 데에 중요합니다. PassiveStaticEffect는 다른 에피소드에서 중요하게 다뤄보겠습니다.
    PassiveEffect | PassiveStaticEffect,
    HookPassive,
    create,
    deps,
  );
}

function mountEffectImpl(fiberFlags, hookFlags, create, deps): void 
  // 저자) 새 훅을 만듭니다.
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  
  // 저자) pushEffect()가 만든 Effect 객체를 hook에 세팅합니다.
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps,
  );
}
function pushEffect(tag, create, destroy, deps) {
  const effect: Effect = {
    // 저자) 태그는 이 effect가 실행되어야 하는지 여부를 표시하는 중요한 역할을 합니다.
    tag,
    // 저자) 우리가 넘긴 callback입니다.
    create,
	// 저자) callback에서 return하는 cleanup 함수입니다.
    destroy,
	// 저자) 우리가 넘긴 의존성 배열입니다.
    deps,
    // Circular
    // 저자) 하나의 컴포넌트에 여러 effects가 있을 수 있습니다. 그들을 연결합니다.
    next: (null: any),
  };
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    // 저자) effect는 fiber의 updateQueue에 저장됩니다. 이것은 hook의 memorizedState에 저장했던 것과는 다르다는 것을 주의하세요.
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }
  return effect;
}

initial mount의 경우, useEffect()가 필요한 플래그와 함께 Effect 객체를 생성하는 것을 볼 수 있습니다. 이것들은 다른 시점에 처리될 것입니다.

2. re-render 때의 useEffect()

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

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  // 저자) 현재 훅을 가져옵니다.
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;

  if (currentHook !== null) {
    // 저자) effect 훅의 memoizedState는 Effect 객체라는 것을 기억하세요.
    const prevEffect = currentHook.memoizedState;
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      /* 
      	저자) 의존성이 변경되지 않으면, 아무 동작도 하지 않지만 Effect 객체는 새로 만듭니다. 
        객체를 다시 만드는 이유는 updateQueue를 다시 생성하고 갱신된 create()를 얻어야하기 때문입니다.
        destory()는 이전의 것을 사용하고 있다는 점을 유의하세요.
      */ 
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  currentlyRenderingFiber.flags |= fiberFlags;

  hook.memoizedState = pushEffect(
    // 저자) 의존성 배열의 값이 변경되면, HookHasEffect는 effect가 실행되어야 함을 표시합니다.
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps,
  );
}

위 코드에서 헷갈리는 의존성 배열이 어떻게 동작하는지 볼 수 있습니다.
re-render 할 때에는 어떤 경우든 Effect 객체를 다시 만듭니다. 다만, 의존성 배열 값이 달라졌을 때는 새로 생성된 Effect에 이전의 cleanup 함수와 함께 실행되어야 한다는 표시가 붙습니다.

3. Effects는 언제 그리고 어떻게 실행되고 정리될까?

위에서 우리는 useEffect()가 fiber 노드에 단지, 추가적인 데이터 구조를 생성할 뿐이라는 것을 알게 되었습니다. 이제는 Effect 객체가 어떻게 처리되는지 살펴보겠습니다.

3.1 passive effects의 flushing은 commitRoot()에서 트리거됩니다.

두 fiber 트리를 비교해 달라진 부분에 대한 결과를 얻은 뒤(재조정), commit 단계에서 변경점을 host DOM에 반영할 것입니다. 여기서 우리는 passive effects의 flushing을 시작하는 코드를 쉽게 찾을 수 있습니다.

function commitRootImpl(
  root: FiberRoot,
  recoverableErrors: null | Array<CapturedValue<mixed>>,
  transitions: Array<Transition> | null,
  renderPriorityLevel: EventPriority,
) {
  // If there are pending passive effects, schedule a callback to process them.
  // Do this as early as possible, so it is queued before anything else that
  // might get scheduled in the commit phase. (See #16714.)
  // TODO: Delete all other places that schedule the passive effect callback
  // They're redundant.
  if (
    (finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
    (finishedWork.flags & PassiveMask) !== NoFlags
  ) {
    if (!rootDoesHavePassiveEffects) {
      rootDoesHavePassiveEffects = true;
      pendingPassiveEffectsRemainingLanes = remainingLanes;
      // workInProgressTransitions might be overwritten, so we want
      // to store it in pendingPassiveTransitions until they get processed
      // We need to pass this through as an argument to commitRoot
      // because workInProgressTransitions might have changed between
      // the previous render and commit if we throttle the commit
      // with setTimeout
      pendingPassiveTransitions = transitions;
      scheduleCallback(NormalSchedulerPriority, () => {
        /*
          저자) 여기서 useEffect()에 의해 만들어진 passive effects를 flush 합니다.
          이는 당장이 아닌, 다음 tick에 flushing을 예약합니다.
          자세한 내용은 'how react scheduler works' 글을 참고하세요.
        */
        flushPassiveEffects();
        // This render triggered passive effects: release the root cache pool
        // *after* passive effects fire to avoid freeing a cache pool that may
        // be referenced by a node in the tree (HostRoot, Cache boundary etc)
        return null;
      });
    }
  }
  ...
}

3.2 flushPassiveEffects()

function flushPassiveEffectsImpl() {
  if (rootWithPendingPassiveEffects === null) {
    return false;
  }
  
  // Cache and clear the transitions flag
  const transitions = pendingPassiveTransitions;
  pendingPassiveTransitions = null;
  
  const root = rootWithPendingPassiveEffects;
  const lanes = pendingPassiveEffectsLanes;
  rootWithPendingPassiveEffects = null;
  // TODO: This is sometimes out of sync with rootWithPendingPassiveEffects.
  // Figure out why and fix it. It's not causing any known issues (probably
  // because it's only used for profiling), but it's a refactor hazard.
  pendingPassiveEffectsLanes = NoLanes;
  
  const prevExecutionContext = executionContext;
  executionContext |= CommitContext;
  
  // 저자) 여기서 effect cleanUp이 callback 보다 먼저 실행되는 것을 명확히 볼 수 있습니다.
  commitPassiveUnmountEffects(root.current);
  commitPassiveMountEffects(root, root.current, lanes, transitions);
  ...
}

3.3 commitPassiveUnmountEffects()

export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
  setCurrentDebugFiberInDEV(finishedWork);
  commitPassiveUnmountOnFiber(finishedWork);
  resetCurrentDebugFiberInDEV();
}

function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      // 저자) 자식들의 effects가 먼저 정리되는 것을 볼 수 있습니다. 
      recursivelyTraversePassiveUnmountEffects(finishedWork);
      if (finishedWork.flags & Passive) {
        commitHookPassiveUnmountEffects(
          finishedWork,
          finishedWork.return,
          // 저자) HookHasEffect 플래그는 의존성 배열의 값이 변경되지 않을 경우 콜백이 실행되지 않도록 합니다.
          HookPassive | HookHasEffect,
        );
      }
      break;
    }
    ...
  }
}
  
function commitHookPassiveUnmountEffects(
  finishedWork: Fiber,
  nearestMountedAncestor: null | Fiber,
  hookFlags: HookFlags,
) {
  if (shouldProfile(finishedWork)) {
    startPassiveEffectTimer();
    commitHookEffectListUnmount(
      hookFlags,
      finishedWork,
      nearestMountedAncestor,
    );
    recordPassiveEffectDuration(finishedWork);
  } else {
    commitHookEffectListUnmount(
      hookFlags,
      finishedWork,
      nearestMountedAncestor,
    );
  }
}

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;
    // 저자) 여기서는 updateQueue의 모든 Effect들을 순회하며 플래그를 통해 필요한 것만 필터링합니다.
    do {
      if ((effect.tag & flags) === flags) {
        // Unmount
        const inst = effect.inst;
        const destroy = inst.destroy;
        if (destroy !== undefined) {
          inst.destroy = undefined;
          safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

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

3.4 commitPassiveMountEffects()

commitPassiveMountEffects()도 같은 방식으로 동작합니다.

export function commitPassiveMountEffects(
  root: FiberRoot,
  finishedWork: Fiber,
  committedLanes: Lanes,
  committedTransitions: Array<Transition> | null,
): void {
  setCurrentDebugFiberInDEV(finishedWork);
  commitPassiveMountOnFiber(
    root,
    finishedWork,
    committedLanes,
    committedTransitions,
  );
  resetCurrentDebugFiberInDEV();
}

function commitPassiveMountOnFiber(
  finishedRoot: FiberRoot,
  finishedWork: Fiber,
  committedLanes: Lanes,
  committedTransitions: Array<Transition> | null,
): void {
  // When updating this function, also update reconnectPassiveEffects, which does
  // most of the same things when an offscreen tree goes from hidden -> visible,
  // or when toggling effects inside a hidden tree.
  const flags = finishedWork.flags;
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      // 저자) 자식들의 effects가 먼저 실행되는 것을 볼 수 있습니다.
      recursivelyTraversePassiveMountEffects(
        finishedRoot,
        finishedWork,
        committedLanes,
        committedTransitions,
      );
      if (flags & Passive) {
        commitHookPassiveMountEffects(
          finishedWork,
          // 저자) HookHasEffect 플래그는 의존성 배열의 값이 변경되지 않을 경우 콜백이 실행되지 않도록 합니다.
          HookPassive | HookHasEffect,
        );
      }
      break;
    }
    ...
  }
}

function commitHookPassiveMountEffects(
  finishedWork: Fiber,
  hookFlags: HookFlags,
) {
  if (shouldProfile(finishedWork)) {
    startPassiveEffectTimer();
    try {
      commitHookEffectListMount(hookFlags, finishedWork);
    } catch (error) {
      captureCommitPhaseError(finishedWork, finishedWork.return, error);
    }
    recordPassiveEffectDuration(finishedWork);
  } else {
    try {
      commitHookEffectListMount(hookFlags, finishedWork);
    } catch (error) {
      captureCommitPhaseError(finishedWork, finishedWork.return, error);
    }
  }
}

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;
    // 저자) 역시, 필요한 Effects만 필터링하고 실행합니다.
    do {
      if ((effect.tag & flags) === flags) {
        // Mount
        const create = effect.create;
        const inst = effect.inst;
        // 저자) 콜백이 여기서 실행됩니다!
        const destroy = create();
        inst.destroy = destroy;
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

4. 요약

소스 코드를 살펴보니, useEffect()의 내부는 꽤 직관적임을 알 수 있었습니다.

  1. useEffect()는 fiber에 저장되는 Effect 객체를 생성합니다.
    • Effect는 실행되어야 하는지 여부를 나타내는 tag를 갖고 있습니다.
    • Effect는 우리가 전달하는 첫 번째 인자인 create()를 갖고 있습니다.
    • Effect는 create()의 정리 함수인 destory()를 갖고 있으며, create()가 실행될 때만 설정됩니다.
  2. useEffect()는 늘 새로운 Effect 객체를 생성하지만, 의존성 배열의 변경 여부에 따라 다른 tag를 설정합니다.
  3. host DOM에 업데이트를 commit할 때, 모든 Effect를 tag에 따라 다시 실행하도록 다음 tick에 작업이 예약됩니다.
    • 자식 컴포넌트의 Effect들이 먼저 처리됩니다.
    • 정리 함수들이 먼저 실행됩니다.

5. 퀴즈

오늘 배운 내용을 바탕으로, BFE.dev의 React 퀴즈를 풀 수 있습니다.

import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";
function App() {
  const [count, setCount] = useState(1);
  console.log(1);
  useEffect(() => {
    console.log(2);
    return () => {
      console.log(3);
    };
  }, [count]);
  useEffect(() => {
    console.log(4);
    setCount((count) => count + 1);
  }, []);
  return <Child count={count} />;
}

function Child({ count }) {
  useEffect(() => {
    console.log(5);
    return () => {
      console.log(6);
    };
  }, [count]);
  return null;
}

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);

어떤 순서로 로그가 찍힐까요? (정답은 댓글에)

1개의 댓글

comment-user-thumbnail
2024년 5월 6일

퀴즈 정답
1 -> 5 -> 2 -> 4 -> 1 -> 6 -> 3 -> 5 -> 2

답글 달기