[번역] React의 useEffect()는 어떻게 동작하나요?

[번역] React의 useEffect()는 어떻게 동작하나요?

React Internals Deep Dive - ep4

·

8 min read

영문 블로그 글을 번역했습니다. 허가를 받으면 시리즈를 이어갈 예정입니다.
원문링크: https://jser.dev/2023-07-08-how-does-useeffect-work


ℹ️ React Internals Deep Dive 에피소드 4, 유튜브에서 제가 설명하는 것을 시청해주세요.

React@18.2.0 기준, 최신 버전에서는 구현이 변경되었을 수 있습니다.

useEffect()는 React에서 useState()를 제외하고 가장 많이 사용되는 훅입니다.
매우 강력하지만 때때로 혼란스러울 수 있는데, 내부적으로 어떻게 작동하는지 알아봅시다.

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

1. 최초 마운트에서 useEffect()

useEffect()는 최초 마운트에서 mountEffect()를 사용합니다.

💬 역자 주석: Jser의 코멘트는 ❗❗로 표시 해뒀습니다.
그 외 주석은 리액트 소스 코드 자체의 주석입니다.
... 은 생략된 코드입니다.

function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return mountEffectImpl(
    PassiveEffect | PassiveStaticEffect,
    // ❗❗ ↖ 이 플래그를 사용하여 레이아웃 Effect와의 차이점을 구분할 수 있어 중요합니다.
    // ❗❗ PassiveStaticEffect는 무엇일까요? 이 것은 다른 에피소드에서 다룰 가치가 있을거 같습니다.
    HookPassive,
    create,
    deps,
  );
}
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = mountWorkInProgressHook();
  // ❗❗ ↗ 새로운 훅을 만듭니다.
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
  // ❗❗               ↗ pushEffect()는 Effect 객체를 만들고, 그것을 훅에 설정합니다.
    HookHasEffect | hookFlags,
    // ❗❗ ↖ 이 플래그도 중요한데, 이것은 이 이팩트를 최초 마운트에서 실행해야 한다는 것을 의미합니다.
    create,
    undefined,
    nextDeps,
  );
}
function pushEffect(tag, create, destroy, deps) {
  const effect: Effect = {
    tag,
    // ❗❗ ↖ tag는 중요한데, 이 Effect를 실행해야 하는지 여부를 표시하는 데 사용됩니다.
    create,
    // ❗❗ 우리가 전달한 콜백 입니다
    destroy,
    // ❗❗ 콜백에서 반환한 정리 함수 입니다.
    deps,
    // ❗❗ 우리가 전달한 deps 배열입니다.
    // Circular
    next: (null: any),
    // ❗❗ 하나의 컴포넌트에 여러 Effect가 있을 수 있습니다. 그걸 체인으로 엮습니다.
  };
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    // ❗❗ Effect는 fiber의 updateQueue에 저장됩니다.
    // ❗❗ 이것은 훅의 memoizedState와는 다릅니다.
    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;
}

최초 마운트의 경우, useEffect()가 필요한 플래그를 사용하여 Effect 객체를 생성하는 것을 볼 수 있습니다. effect 객체들은 다른 타이밍에 처리될 것입니다.

2. 리-렌더링의 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) {
    const prevEffect = currentHook.memoizedState;
    // ❗❗                        ↗ memoizedState의 effect 훅이 Effect 객체라는 것을 기억하세요.
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
      // ❗❗ deps가 변경되지 않으면, Effect 객체를 다시 생성하는 것만 수행합니다
      // ❗❗ 여기서 다시 생성해야 하는 이유는 아마도 단순히 updateQueue를 다시 생성해야 하기 때문일겁니다.
      // ❗❗ 그리고 우리는 업데이트 된 create()함수를 가져올 필요가 있습니다.
      // ❗❗ 여기서는 이전 destory() 함수를 사용하고 있음도 알 수 있습니다.
    }
  }
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    // ❗❗ ↖ 만약 deps가 바뀌면, HookHasEffect 는 이 Effect를 실행 해야함을 나타내는 표시입니다.
    create,
    destroy,
    nextDeps,
  );
}

우리는 혼란스러운 deps 배열이 어떻게 작동하는지 알 수 있엇습니다. 리-렌더링할 때는 어떤 경우에는 Effect 객체를 다시 생성하지만, deps가 변경되면 생성된 Effect가 이전 정리(destory()) 함수를 사용하여 실행되도록 표시됩니다.

3. Effect는 언제, 어떻게 실행되고 정리되나요?

위에서 우리는 useEffect()가 Fiber Node에 추가 데이터 구조를 생성할 뿐이라는 것을 알았습니다. 이제 이러한 Effect 객체가 어떻게 처리되는지 알아볼 필요가 있습니다.

3.1 패시브 Effect에 대한 flushing은 commitRoot()내에서 트리거 됩니다.

두 개의 Fiber Tree(조정)를 비교하여 서로 다른 결과를 얻은 후 "Commit" 단계에서 호스트 DOM에 변경 사항을 반영합니다. 패시브 Effect의 의 flush를 시작한 코드를 쉽게 찾을 수 있습니다.

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, () => {
        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;
      });
      // ❗❗ 여기서 useEffect()에 의해 생겨난 패시드 Effect를 플러시 합니다.
      // ❗❗ 이렇게 하면 당장이 아닌 다음 틱(tick)에 플러싱이 예약됩니다.
      // ❗❗ 좀 더 자세한 정보는 EP20의 리액트 스케쥴러는 어떻게 동작하는가를 참고하세요
    }
  }
  ...
}

EP20 - 리액트 스케쥴러는 어떻게 동작하는가? scheduleCallback() 북마크

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;
  commitPassiveUnmountEffects(root.current);
  commitPassiveMountEffects(root, root.current, lanes, transitions);
  // ❗❗↗ 여기서 우리는 콜백이 실행되지 전에 Effect 정리가 먼저 실행되는 것을 명확하게 볼 수 있습니다.
  ...
}

3.3 commitPassiveUnmountEffects()

export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
  setCurrentDebugFiberInDEV(finishedWork);
  commitPassiveUnmountOnFiber(finishedWork); // ❗❗ commitPassiveUnmountOnFiber
  resetCurrentDebugFiberInDEV();
}
function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      recursivelyTraversePassiveUnmountEffects(finishedWork);
      // ❗❗ ↗ 우리는 여기서 자식들의 effects들이 먼저 정리되는 것을 볼 수 있습니다.
      if (finishedWork.flags & Passive) {
        commitHookPassiveUnmountEffects( // ❗❗ commitHookPassiveUnmountEffects
          finishedWork,
          finishedWork.return,
          HookPassive | HookHasEffect,
          // ❗❗         ↗ 이 플래그(HookHasEffect)는 deps가 변경되지 않았을 때 훅이 실행되지 않는 것을 보장합니다
        );
      }
      break;
    }
    ...
  }
}
function commitHookPassiveUnmountEffects(
  finishedWork: Fiber,
  nearestMountedAncestor: null | Fiber,
  hookFlags: HookFlags,
) {
  if (shouldProfile(finishedWork)) {
    startPassiveEffectTimer();
    commitHookEffectListUnmount( // ❗❗ commitHookEffectListUnmount
      hookFlags,
      finishedWork,
      nearestMountedAncestor,
    );
    recordPassiveEffectDuration(finishedWork);
  } else {
    commitHookEffectListUnmount( // ❗❗ 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;
    do {
      if ((effect.tag & flags) === flags) { // ❗❗ (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);
    // ❗❗ 여기서는 updateQueue의 모든 effect를 단순히 루프 돌기만 하면 됩니다
    // ❗❗ 그러면서 플래그를 통해 필요한 항목들을 필터링 합니다.
  }
}
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( // ❗❗ 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: {
      recursivelyTraversePassiveMountEffects(
      // ❗❗ ↖ 우리는 여기서 자식들의 effects들이 먼저 실행되는 것을 볼 수 있습니다.
        finishedRoot,
        finishedWork,
        committedLanes,
        committedTransitions,
      );
      if (flags & Passive) {
        commitHookPassiveMountEffects( // ❗❗ commitHookPassiveMountEffects
          finishedWork,
          HookPassive | HookHasEffect,
          // ❗❗        ↗ 이 플래그(HookHasEffect)는 deps가 변경되지 않았을 때 훅이 실행되지 않는 것을 보장합니다

        );
      }
      break;
    }
    ...
  }
}
function commitHookPassiveMountEffects(
  finishedWork: Fiber,
  hookFlags: HookFlags,
) {
  if (shouldProfile(finishedWork)) {
    startPassiveEffectTimer();
    try {
      commitHookEffectListMount(hookFlags, finishedWork); // ❗❗ commitHookEffectListMount
    } catch (error) {
      captureCommitPhaseError(finishedWork, finishedWork.return, error);
    }
    recordPassiveEffectDuration(finishedWork);
  } else {
    try {
      commitHookEffectListMount(hookFlags, finishedWork); // ❗❗ commitHookEffectListMount
    } 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;
    do {
      if ((effect.tag & flags) === flags) {
        // Mount
        const create = effect.create;
        const inst = effect.inst;
        const destroy = create();
callback is run here!

        inst.destroy = destroy;
      }
      effect = effect.next;
    } while (effect !== firstEffect);
    // ❗❗ 다시, 필요한 Effect들을 필터링하고 다음을 실행합니다.
  }
}

4. 요약

소스 코드를 살펴본 결과, useEffect()의 내부 구조는 매우 간단하는 것을 알 수 있었습니다.

  1. useEffect() 는 Fiber에 저장되어 있는 Effect 객체를 생성합니다.

    • Effect 는 실행이 필요한지 여부를 나타내는 tag 를 가집니다.

    • Effect 는 첫번째 인수로 전달되는 create() 함수가 있습니다.

    • Effect create()의 정리인 destroy()가 있고, 이것은 create()가 실행될 때만 설정됩니다.

  2. useEffect() 는 매번 새로운 Effect 객체들을 만들어냅니다, 하지만 이것들은 만약 deps 배열이 변경되면 다른 tag를 설정하게 됩니다.

  3. 호스트 DOM에 업데이트를 커밋할 때, 다음 틱(tick)의 작업은 tag를 기반으로 모든 Effect를 다시 실행 하도록 예약됩니다.

    • 하위 컴포넌트의 Effect가 먼저 처리 됩니다.

    • 정리가 먼저 실행됩니다.

5. 퀴즈 챌린지

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

원본 글 작성일: 2023-07-08