useEffect의 생명주기

한상우·2025년 4월 22일

리액트

목록 보기
15/24
post-thumbnail

React의 useEffect 생명주기

안녕하세요! 오늘은 React에서 가장 많이 사용되는 훅 중 하나인 useEffect의 내부 동작 원리와 생명주기에 대해 자세히 알아보겠습니다. React 18.2.0 버전을 기준으로 설명하며, 최신 버전에서는 일부 구현이 변경되었을 수 있습니다.

목차

  1. useEffect란 무엇인가?
  2. useEffect가 처음 호출될 때 발생하는 일
  3. Effect.tag의 역할
  4. flushPassiveEffects() 함수의 역할
  5. deps가 변경될 때 발생하는 일
  6. cleanup 함수가 호출되는 시점
  7. useEffect의 실행 순서 정리
  8. 마무리

useEffect란 무엇인가?

useEffect는 React의 함수형 컴포넌트에서 side effect를 수행할 수 있게 해주는 훅입니다. 아래는 간단한 예시입니다:

function A() {
  useEffect(function create() {
    console.log("create effect");
    return function cleanup() {
      console.log("destroy effect");
    };
  }, []);
  return <div />;
}

여기서 create()는 effect 함수이고, cleanup()은 정리(cleanup) 함수입니다. 이제 다음 세 가지 질문에 대한 답을 찾아보겠습니다:

  1. useEffect가 처음 호출될 때 무슨 일이 발생하는가?
  2. deps(의존성 배열)가 변경될 때 무슨 일이 발생하는가?
  3. cleanup 함수는 언제 호출되는가?

useEffect가 처음 호출될 때 발생하는 일

useEffect가 처음 호출되면 내부적으로 mountEffect 함수가 실행됩니다. 이후 업데이트에서는 updateEffect 함수가 실행됩니다.

mountEffect 함수의 핵심 로직을 살펴보면:

function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps
  );
}

여기서 두 가지 중요한 일이 일어납니다:

  1. mountWorkInProgressHook()를 통해 새로운 훅을 생성하고 이를 fiber의 훅 리스트(memoizedState)에 추가합니다.
  2. 우리가 전달한 생성자 함수(create)와 함께 업데이트 이펙트를 설정하고, 이를 fiber의 updateQueue에 추가합니다. 또한 훅의 memoizedState를 통해 이 이펙트를 추적합니다. 이 시점에서 생성자 함수는 아직 호출되지 않습니다.

따라서 fiber는 다음과 같은 두 가지를 가질 수 있습니다:

  • updateQueue에 있는 업데이트(이펙트) 목록
  • memoizedState에 있는 훅 목록 (이펙트 훅의 경우 이펙트도 추적)

Effect.tag의 역할

Effect는 side effect를 의미하며 fiber의 updateQueue에 추가되어 React가 변경사항을 커밋한 후 실행됩니다.

pushEffect 함수의 첫 번째 인자는 Effect.tag를 제어합니다. 마운팅 단계에서는 HookHasEffect | hookFlags가 전달되며, 여기서 HookHasEffect는 이 이펙트가 실행되어야 함을 의미합니다.

이 플래그는 매우 중요합니다. updateEffect에서는 deps가 변경되었는지 확인하여 이 플래그를 토글합니다.

flushPassiveEffects() 함수의 역할

flushPassiveEffects()는 useEffect에서 생성된 이펙트를 실행하는 함수입니다. 이 함수는 여러 곳에서 호출되지만, 가장 중요한 위치는 조정(reconciliation) 후 커밋 단계에서 실행되는 commitRoot() 함수 내부입니다.

flushPassiveEffects()scheduleCallback을 통해 스케줄링되므로, DOM 변경 직후 동기적으로 실행되지 않고 다음 틱에서 실행됩니다.

내부적으로 flushPassiveEffects()는 두 가지 주요 작업을 수행합니다:

  1. commitPassiveUnmountEffects(root.current): 이전 이펙트의 정리(cleanup) 함수를 실행합니다.
  2. commitPassiveMountEffects(root, root.current): 새로운 이펙트를 실행합니다.

이펙트의 정리 함수는 이펙트가 다시 실행되기 전에 먼저 실행되어야 하므로, 언마운트가 마운트보다 먼저 발생합니다.

commitPassiveUnmountEffects()

이 함수는 주로 삭제된 fiber에서 이펙트를 정리(cleanup)하는 작업을 수행합니다. 왜냐하면 삭제된 fiber는 더 이상 fiber 트리에 존재하지 않기 때문에, React는 부모 fiber의 deletions 속성을 통해 이들을 추적합니다.

핵심 로직은 commitHookEffectListUnmount(HookPassive | HookHasEffect, finishedWork, finishedWork.return)입니다. 이 함수는 연결된 모든 이펙트를 순회하며 태그가 HookPassive | HookHasEffect와 일치하는지 확인하고, destroy 함수를 실행합니다.

그런데 이펙트 훅을 생성할 때 pushEffect(HookHasEffect | hookFlags, create, undefined, nextDeps)에서 destroy는 undefined로 전달됩니다. 그렇다면 destroy는 언제 설정될까요? 정답은 commitPassiveMountEffects()에서입니다.

commitPassiveMountEffects()

이 함수는 commitHookEffectListMount(HookPassive | HookHasEffect, finishedWork)를 트리거합니다:

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();
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

이 함수는 간단하게 effect.destroy = create()를 설정하여 destroy를 설정합니다. 이 시점에서 우리의 생성자 함수가 드디어 실행됩니다!

deps가 변경될 때 발생하는 일

첫 마운트 이후, 컴포넌트가 다시 실행되면 useEffect도 다시 실행되며, 이는 updateEffect()로 이어집니다.

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;
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps
  );
}

여기서 주목할 점은:

  1. updateWorkInProgressHook()에서 일어나는 일
  2. currentHook이 무엇인지
  3. areHookInputsEqual()이 통과할 때와 그렇지 않을 때 무슨 일이 발생하는지

React는 현재 fiber 트리(current)와 작업 중인 fiber 트리(workInProgress)를 가지고 있습니다. 조정(reconciliation)은 workInProgress 트리에서 업데이트를 수행한 다음, 이 업데이트된 트리로 전환하는 것을 의미합니다.

updateWorkInProgressHook()에서는 현재 트리의 훅(currentHook)과 작업 중인 트리의 훅(workInProgressHook)을 추적합니다. 이 두 가지를 추적하는 이유는 비교하여 deps가 변경되었는지 확인하기 위함입니다.

deps 비교 로직:

if (areHookInputsEqual(nextDeps, prevDeps)) {
  hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
  return;
}

deps가 동일하면, 이 이펙트 훅을 실행할 필요가 없으므로 HasEffect 플래그 없이 pushEffect를 수행합니다.

deps가 변경되면, 이펙트 훅을 실행해야 하므로:

hook.memoizedState = pushEffect(
  HookHasEffect | hookFlags,
  create,
  destroy,
  nextDeps
);

이제 HookHasEffect 플래그가 있으므로 flushPassiveEffects()에서 실행됩니다.

cleanup 함수가 호출되는 시점

cleanup 함수(destroy)는 다음과 같은 경우에 호출됩니다:

  1. 컴포넌트가 언마운트될 때
  2. deps가 변경되어 이펙트가 다시 실행되기 전에

이 과정은 commitPassiveUnmountEffects()에서 처리됩니다. 이 함수는 HookPassive | HookHasEffect 플래그가 있고 destroy가 있는 이펙트를 찾아 정리합니다.

useEffect의 실행 순서 정리

이제 useEffect의 전체 생명주기를 정리해보겠습니다:

  1. useEffect 첫 호출 시:

    • 새로운 훅 생성 및 fiber의 memoizedState에 연결
    • HasEffect 태그를 가진 새로운 Effect 생성 및 fiber의 updateQueue에 연결
  2. 이펙트 실행 시(마운트):

    • create 함수 호출 및 반환값을 destroy에 저장
    • 이는 HasEffect 플래그가 있고 destroy가 있는 이펙트는 정리되어야 함을 의미
  3. flushPassiveEffects에서:

    • 첫 번째 패스: HasEffect와 destroy가 있는 이펙트를 찾아 정리
    • 또한 fiber 삭제로 인한 정리도 처리
    • 두 번째 패스: HasEffect가 있는 이펙트를 찾아 다시 실행
  4. 컴포넌트 리렌더링 시:

    • workInProgress fiber는 빈 memoizedState와 emptyQueue를 가짐
    • useEffect가 다시 실행되고 deps 비교
    • 변경 사항 발견 시 HasEffect로 새 이펙트 생성

마무리

이제 useEffect의 생명주기에 대해 더 깊이 이해할 수 있게 되었습니다. 특히 다음과 같은 내용을 배웠습니다:

  • useEffect는 fiber의 updateQueue에 Effect를 생성하여 side effect를 관리합니다
  • Effect는 tag, create, destroy, deps 등의 속성을 가집니다
  • deps 변경 시 Effect가 실행되는 메커니즘
  • cleanup 함수의 호출 시점과 처리 방식

효과적인 React 애플리케이션 개발을 위해 이러한 내부 동작 원리를 이해하는 것이 중요합니다. 이 지식을 바탕으로 더 최적화된 컴포넌트를 작성할 수 있을 것입니다.

참고: 이 글은 React@18.2.0 버전을 기준으로 작성되었으며, 최신 버전에서는 일부 구현이 변경되었을 수 있습니다.

profile
안녕하세요

0개의 댓글