useEffect의 내부 동작 원리

우혁·2024년 7월 25일
17

React

목록 보기
2/10
post-thumbnail

✨ useEffect란

애플리케이션 내 컴포넌트의 여러 값들을 활용해 동기적으로 사이드 이펙트(부수 효과)를 만드는 매커니즘이다.
사이드 이펙트가 언제 일어나는지보다 어떤 상태 값과 함께 실행되는지 살펴보는 것이 중요하다.

// useEffect 예시 코드
function Componets(){
  useEffect(() => {
    // 콜백 함수
    
    return () => {
      // 클린업 함수
    }
  }, [의존성 배열])
}

첫 번째 인수로는 실행할 사이드 이펙트가 포함된 함수를, 두 번째 인수로는 의존성 배열을 전달한다.

useEffect는 의존성 배열이 변경될 때 마다 첫 번째 인수인 콜백을 실행한다.

useEffect는 Proxy나 데이터 바인딩, 옵저버와 같은 특별한 기능을 통해 값의 변화를 관찰하는 것이 아니고, 렌더링할 때마다 의존성에 있는 값을 보면서 이전과 다른게 하나라도 있으면 사이드 이펙트를 실행하는 함수라고 볼 수 있다.


🎯 클린업(Clean-Up) 함수의 목적

클린업 함수는 주로 이벤트 리스너 등록/해제, 타이머 설정/해제, 구독 설정/해제, 리소스 정리와 같이 컴포넌트의 생명주기 동안 발생한 부작용을 정리하는 데 사용하여 메모리 누수나 불필요한 리소스 사용을 방지할 수 있다.

useEffect는 콜백이 실행될 때 마다 이전의 클린업 함수가 존재한다면 그 클린업 함수를 실행한 뒤에 콜백을 실행한다. 따라서 이벤트를 추가하기 전에 이전에 등록했던 이벤트 핸들러를 삭제하는 코드를 클린업 함수에 추가하는 것이다. 이렇게 함으로써 특정 이벤트의 핸들러가 무한히 추가되는 것을 방지할 수 있다.

💡 클린업 함수는 생명 주기 메서드의 언마운트 개념과는 다르다는 점이 중요하다!

언마운트는 특정 컴포넌트가 DOM에서 사라진다는 것을 의미하고, 클린업 함수는 컴포넌트가 리렌더링됐을 때 의존성 변화가 있다면 이전 상태를 청소해 주는 개념이다.

function Test({ count }){
  useEffect(() => {
    const handleMouseClick = () => {
      console.log("클릭");
    }

    window.addEventListener("click", handleMouseClick);

    return () => {
      // 클린업 함수가 실행되는 시점은 언마운트가 아닌 의존성 배열의 값이 변화가 있어
      // 재렌더링되고 useEffect의 콜백 함수가 실행될 때 이전 클린업 함수가 실행된다.
      window.removeEventListener("click", handleMouseClick);
    }
  }, [count])
}

📦 의존성 배열(Dependency Array)

의존성 배열은 길이를 가진 배열일 수도 있고, 값이 없는 빈배열, 배열 자체를 넣지 않고 생략할 수 있다.

각 차이점은 다음과 같다.

1. 의존성 배열이 빈 배열인 경우
useEffect(() => {
 컴포넌트가 첫 렌더링할 때만 실행
}, [])

2. 의존성 배열에 값이 있는 경우
useEffect(() => {
 // 컴포넌트가 첫 렌더링할 때 실행
 // count 값의 변화가 있을 때 실행
}, [count])

3. 의존성 배열 x
useEffect(() => {
 // 컴포넌트가 렌더링할 때마다 실행
})

3번 의존성 배열을 추가하지 않는 방식은 useEffect를 사용하지 않는 것과 같을까?

// useEffect 사용 X
function Component(){
  console.log("렌더링");
}

// useEffect 사용 O
function Component(){
  useEffect(() => {
    console.log("렌더링");
  })
}

두 코드는 명백히 리액트에서 차이점을 지니고 있다.

1. 서버 사이드 렌더링 관점에서 useEffect는 클라이언트 사이드에서 실행되는 것을 보장해줘서 useEffect 내부에서는 window 객체의 접근에 의존하는 코드를 사용할 수 있다.

2. useEffect는 컴포넌트의 렌더링이 완료된 이후에 실행된다. 반면 useEffect를 사용하지 않는 직접 실행 방식은 컴포넌트가 렌더링되는 도중에 실행된다.

서버 사이드 렌더링의 경우에 서버에서도 실행되어 함수형 컴포넌트의 반환을 지연시키는 행위이다. 즉 무거운 작업일 경우 렌더링을 방해하므로 성능에 악영향을 미칠 수 있다.


🤷‍♂️ 의존성 배열은 어떻게 값의 변경을 감지 할까?

아래 코드는 React 훅의 의존성 배열을 비교하여, 다음 렌더링에서 의존성이 변경되었는지 판단하는 로직이다.

function areHookInputsEqual(
  nextDeps: Array<mixed>, // 다음 의존성 배열
  prevDeps: Array<mixed> | null // 이전 의존성 배열 (null일 수 있음)
): boolean {
  // 개발 모드에서 이전 의존성을 무시하는 경우 false를 반환합니다.
  if (__DEV__) {
    if (ignorePreviousDependencies) {
      return false;
    }
  }

  // 이전 의존성 배열이 null인 경우 에러 메시지를 출력하고 false를 반환합니다.
  if (prevDeps === null) {
    if (__DEV__) {
      console.error(currentHookNameInDev); // 현재 훅 이름을 에러로 출력
    }
    return false;
  }

  // 개발 모드에서 두 배열의 길이를 비교하여 다르면 에러 메시지를 출력합니다.
  if (__DEV__) {
    // 프로덕션 환경에서는 배열의 길이를 비교하지 않습니다.
    if (nextDeps.length !== prevDeps.length) {
      console.error(
        'The final argument passed to %s changed size between renders. The ' +
          'order and size of this array must remain constant.\n\n' +
          'Previous: %s\n' +
          'Incoming: %s',
        currentHookNameInDev, // 현재 훅 이름
        `[${prevDeps.join(', ')}]`, // 이전 의존성 배열 출력
        `[${nextDeps.join(', ')}]` // 다음 의존성 배열 출력
      );
    }
  }

  // 이전 의존성 배열의 길이와 다음 의존성 배열의 길이를 비교하며 반복합니다.
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    // 현재 인덱스의 두 의존성을 사용자 정의 비교 함수로 비교합니다.
    if (is(nextDeps[i], prevDeps[i])) {
      continue; // 두 값이 같으면 다음 인덱스로 넘어갑니다.
    }
    return false; // 하나라도 다르면 false를 반환합니다.
  }
  return true; // 모든 요소가 같으면 true를 반환합니다.
}

코드가 길어보이지만 DEV 코드를 제외하면 아래 코드와 같이 남는다.

import is from 'shared/objectIs'; // 아래 설명 참조

function areHookInputsEqual(
  nextDeps: Array<mixed>, // 다음 의존성 배열
  prevDeps: Array<mixed> | null // 이전 의존성 배열 (null일 수 있음)
): boolean {
  // 이전 의존성 배열이 null인 경우 에러 메시지를 출력하고 false를 반환합니다.
  if (prevDeps === null) {
    return false;
  }

  // 이전 의존성 배열의 길이와 다음 의존성 배열의 길이를 비교하며 반복합니다.
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    // 현재 인덱스의 두 의존성을 사용자 정의 비교 함수로 비교합니다.
    if (is(nextDeps[i], prevDeps[i])) {
      continue; // 두 값이 같으면 다음 인덱스로 넘어갑니다.
    }
    
    return false; // 하나라도 다르면 false를 반환합니다.
  }
  return true; // 모든 요소가 같으면 true를 반환합니다.
}

if문 내부에 있는 is() 함수는 추상화 되어 있는 Object.is라고 볼 수 있다.

// 두 값을 비교하는 함수
function is(x: any, y: any) {
  // - 첫 번째 조건: x와 y가 동일하고, x가 0이 아닐 경우 또는 x와 y가 모두 0인 경우
  // - 두 번째 조건: x와 y가 NaN인 경우 (NaN은 자신과 같지 않음)
  return (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y);
}

// Object.is가 함수로 정의되어 있다면 이를 사용하고, 그렇지 않으면 사용자 정의 is 함수를 사용
const objectIs: (x: any, y: any) => boolean =
  typeof Object.is === 'function' ? Object.is : is;

export default objectIs;

이렇게 되어있는 objectIs() 함수를 is라는 이름으로 import한 것이다.

💡 결국 useEffect의 의존성 배열은 Object.is()를 통해 비교하여 값의 변경을 감지하는 것이다!

Object.is()란 무엇일까?
ES6에 도입되었으며, 두 값이 같은 값인지 여부를 판별하는 메소드이다.

  • 일치 비교 연산자(===)와의 차이점
console.log(NaN === NaN); // 일치 비교 연산 - false
console.log(Object.is(NaN, NaN)); // Object.is - true
console.log(-0 === +0) // 일치 비교 연산 - true 
console.log(Object.is(-0, +0)); // Object.is - false

다음과 같이 예측 가능한 정확한 비교 결과를 반환한다.


⚙️ useEffect의 내부 동작 원리

1. 초기 렌더링(initial mount)

useEffect는 최소 마운트 단계에서 mountEffect()를 사용한다.

function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return mountEffectImpl(
    PassiveEffect | PassiveStaticEffect, // 이 플래그는 Layout Effects와의 차이점을 구분하는 데 중요하다.
    HookPassive, 
    create, // 콜백 함수(선택적으로 클린업 함수를 반환할 수 있다)
    deps, // 의존성 배열
  );
}

// 새로운 effect를 마운트하는 데 사용하는 함수
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  // 새로운 훅을 생성한다.
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;

  // pushEffect()는 Effect 객체를 생성하고 훅에 설정한다.
  // HookHasEffect 플래그는 최초 마운트 시 이 Effect를 실행해야 함을 의미합니다.
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps,
  );
}
// 컴포넌트의 효과를 관리하는데 사용
// 효과는 컴포넌트의 컴포넌트의 업데이트 큐에 저장되면 나중에 컴포넌트가 렌더링될 때 실행
// 이를 통해 컴포넌트의 부작용을 관리하고 정리
function pushEffect(tag, create, destroy, deps) {
  const effect: Effect = {
    tag, // tag는 중요하며, 이 effect를 실행해야 하는지 여부를 표시하는 데 사용됩니다.
    create, // 콜백 함수
    destroy, // 콜백에서 반환하는 클린업(Clean-Up) 함수
    deps, // 의존성 배열(Dependency Array)
    next: (null: any), // 하나의 컴포넌트에 여러 효과가 있을 수 있으므로, 다음 effect를 가리키는 링크이다.
  };
  
  // 현재 렌더링 중인 Fiber 노드의 updateQueue(컴포넌트의 업데이트와 관련된 정보 저장) 가져오기
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);

  if (componentUpdateQueue === null) { 
    // Effects는 파이버의 updateQueue에 저장된다.
    // Hooks의 memoizedState와 다르다는 것에 주목해야 한다.
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);

	// lastEffect를 새로 생성한 effect로 설정
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    // 현재까지 컴포넌트에 적용된 마지막 효과
    const lastEffect = componentUpdateQueue.lastEffect;

    if (lastEffect === null) {
      // 새로 생성한 effect를 lastEffect로 추가
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      // 원형 리스트 형태로 새로 생성한 effect를 추가
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect; 

      // 새로 추가된 effect로 업데이트
      componentUpdateQueue.lastEffect = effect;
    }
  }
  return effect;
}

2. 리렌더(re-render)

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

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  // 현재 hook을 가져온다.
  const hook = updateWorkInProgressHook();

  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;

  if (currentHook !== null) {
    // effect hook의 memoizedState는 Effect 객체이다.
    const prevEffect = currentHook.memoizedState;

	// 이전 effect의 destroy 함수를 가져온다.
    destroy = prevEffect.destroy;

    if (nextDeps !== null) {
      // deps가 변경되지 않으면 Effect 객체를 다시 생성한다.
      // 단순히 updateQueue를 재생성하고 업데이트된 create() 함수가 필요하기 때문에
      // 여기서 다시 만들어야 한다. 여기서는 이전 destroy() 함수를 사용하고 있음을 알 수 있다.
      const prevDeps = prevEffect.deps;
      
      // 위에서 설명했던 의존성 배열의 값이 변경하는 것을 감지하는 함수(Object.is) 사용
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  currentlyRenderingFiber.flags |= fiberFlags;

  // pushEffect 함수를 호출하여 새로운 effect를 생성하고, hook.memoizedState에 저장한다.
  // HookHasEffect는 deps가 변경될 때 effect를 실행해야 함을 표시하는 플래그이다.
  hook.memoizedState = pushEffect(
    // HooksHasEffect는 deps가 변경될 때 Effect를 실행해야 함을 표시하는 플래그이다.
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps,
  );
}

리렌더링에서는 반드시 Effect 객체를 다시 생성하지만, deps가 변경되면 생성된 Effect가 이전 클린업 함수와 함께 다시 실행되도록 표시된다.


3. Effect는 어떻게 실행되고, 정리될까?

위 함수를 통해 useEffect가 파이버 노드에 추가 데이터 구조를 생성할 뿐이라는 것을 알게 되었다.
이제 이러한 Effect 객체가 어떻게 처리되는지 알아볼 필요가 있다.

두 파이버 트리(재조정)를 비교하여 서로 다른 결과를 얻은 후 커밋 단계에서 호스트 DOM에 변경 사항을 반영한다.

3-1. commitRoot()

function commitRootImpl(
  root: FiberRoot,
  recoverableErrors: null | Array<CapturedValue<mixed>>,
  transitions: Array<Transition> | null,
  renderPriorityLevel: EventPriority,
) {
  // 완료된 작업 트리에 PassiveMask 플래그가 설정되어 있는지 확인
  // 이는 useEffect로 등록된 Passive Effect가 있음을 의미
  if (
    (finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
    (finishedWork.flags & PassiveMask) !== NoFlags
  ) {
	// 이 플래그는 Passive Effect를 처리하고 있음을 나타낸다.
    if (!rootDoesHavePassiveEffects) {
      rootDoesHavePassiveEffects = true;

      // 처리해야 할 Passive Effect가 있는 Lanes를 저장합니다.
      pendingPassiveEffectsRemainingLanes = remainingLanes;

      // 처리해야 할 Transitions를 저장합니다.
      pendingPassiveTransitions = transitions;

      // 여기서 useEffect로 생성된 PassiveEffect를 Flush 한다.
      // 📌 Next Tick에 Flushing을 예약한다.
      scheduleCallback(NormalSchedulerPriority, () => {
        flushPassiveEffects();
        return null;
      });
    }
  }
  // code...
}

이 코드는 React의 Fiber 트리에서 완료된 작업에 대한 Passive Effect를 처리하는 로직이다.

💡 Passive Effect와 PassiveMask란 무엇일까?
Passive Effect와 PassiveMask는 React의 Fiber 아키텍처에서 사용되는 개념이다.

Passive Effect

  • 사이드 이펙트를 발생시키지만 화면 업데이트에 직접적인 영향을 주지 않는 Effect를 의미한다.
  • 대표적인 예로 useEffect에서 클린업 함수를 반환하는 경우가 있다.
  • Passive Effect는 화면 업데이트 후 비동기적으로 실행된다.

PassiveMask

  • Fiber 노드에 설정되는 플래그 중 하나이다.
  • 이 플래그가 설정된 Fiber 노드에는 Passive Effect가 연결되어 있음을 나타낸다.
  • 이 플래그를 통해 Passive Effectr가 있는 Fiber 노드를 빠르게 찾을 수 있다.

Passive Effect를 처리하는 순서

1. Fiber 트리에 Passive Effect가 있는지 확인

2. Passive Effect가 있는 경우, 이를 처리하기 위해 rootDoesHavePassiveEffects 플래그를 설정하고, 처리해야 할 LanesTransitions를 저장한다.

3. 다음 Tick(이벤트 루프 사이클)에 flushPassiveEffects 함수를 실행하도록 예약한다.

이를 통해 React는 Passive Effect를 비동기적으로 처리할 수 있다.

💡 Lanes와 Transitions이란 무엇일까?
Lanes와와 Transitions는 React의 업데이트 우선순위와 관련된 개념이다.

Lanes

  • Lanes는 React에서 업데이트의 우선순위를 나타내는 개념입니다.
  • 각각의 업데이트는 특정한 Lane에 할당되며, 이를 통해 React는 중요한 업데이트를 먼저 처리할 수 있다.
  • 예를 들어, 사용자 입력에 의한 업데이트는 화면 업데이트보다 더 높은 우선순위를 가질 수 있다.

Transitions

  • Transitions는 사용자 경험을 개선하기 위해 도입한 개념이다.
  • 사용자 입력에 의한 업데이트 등 중요한 업데이트를 Transtion으로 표시할 수 있다.
  • Transtion으로 표시된 업데이트는 다른 업데이트 보다 우선순위가 높다.

3-2. flushPassiveEffects()

function flushPassiveEffectsImpl() {
  // rootWithPendingPassiveEffects는 Passive Effect가 대기 중인 루트 Fiber를 가르킨다.
  if (rootWithPendingPassiveEffects === null) {
    return false;
  }

  // pendingPassiveTransitions에 저장된 트래지션 정보를 캐시하고 초기화한다.
  const transitions = pendingPassiveTransitions;
  pendingPassiveTransitions = null;

  const root = rootWithPendingPassiveEffects;
  const lanes = pendingPassiveEffectsLanes;
  rootWithPendingPassiveEffects = null;
  pendingPassiveEffectsLanes = NoLanes;

  const prevExecutionContext = executionContext;
  executionContext |= CommitContext;

  // 콜백이 실행되기 전에 Effect Cleanup이 먼저 실행되는 것을 명확하게 볼 수 있다.
  commitPassiveUnmountEffects(root.current);
  commitPassiveMountEffects(root, root.current, lanes, transitions);

  // code...
}

이 코드는 React에서 Passive Effect를 처리하는 과정을 나타낸다.

1. rootWithPendingPassiveEffects에 저장된 루트 Fiber를 가져와 Passive Effect를 처리한다.

2. pendingPassiveTransitions에 저장된 트랜지션 정보를 캐시하고 초기화한다.

3. 먼저 commitPassiveUnmountEffects를 실행하여 Effect Clean-Up을 수행한 후, commitPassiveMountEffects를 실행하여 새로운 Passive Effect를 마운트한다.

4. 이 과정은 업데이트 우선순위를 나타내는 LanesTransitions 정보를 활용하여 수행된다.


3-3. commitPassiveUnmountEffects()

export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
  setCurrentDebugFiberInDEV(finishedWork);
  commitPassiveUnmountOnFiber(finishedWork); // Passive Effect의 Unmount 실행
  resetCurrentDebugFiberInDEV();
}

function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      // Children의 Effect가 먼저 클린업되는 것을 볼 수 있다.
      recursivelyTraversePassiveUnmountEffects(finishedWork);

      if (finishedWork.flags & Passive) {
		// 현재 Fiber가 등록된 Passive Effect를 클린업한다.
        commitHookPassiveUnmountEffects( 
          finishedWork,
          finishedWork.return,
          HookPassive | HookHasEffect, // HookHasEffect는 deps가 변경되지 않으면 콜백이 실행되지 않도록 한다.
        );
      }
      break;
    }
    // code...
  }
}

function commitHookPassiveUnmountEffects(
  finishedWork: Fiber,
  nearestMountedAncestor: null | Fiber,
  hookFlags: HookFlags,
) {
  if (shouldProfile(finishedWork)) {
    startPassiveEffectTimer();
	// 실제 Passive Effect의 Unmount 콜백을 실행
    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 {
      // 현재 Effect의 tag가 주어진 flags와 일치하는지 확인하여 필터링
      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);
  }
}

위 코드는 React의 Passive Effect 관련 Unmount 작업을 처리한다.

1. commitPassiveUnmountEffects 함수는 완료된 Fiber 노드에 등록된 Passive EffectUnmount 작업을 실행한다.

2. commitHookPassiveUnmountEffects 함수는 Passive EffectUnmount 콜백을 실제로 호출하며, 성능 프로파일링을 위한 타이밍 측정도 수행한다.

3. commitHookEffectListUnmount 함수는 Fiber 노드의 updateQueue에 등록된 모든 Effect 중 Unmount가 필요한 Effect를 찾아 실행한다.


3-4. 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 {
  const flags = finishedWork.flags;
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      // Children의 Effect가 먼저 실행되는 것을 볼 수 있다.
      recursivelyTraversePassiveMountEffects(
        finishedRoot,
        finishedWork,
        committedLanes,
        committedTransitions,
      );
      if (flags & Passive) {
        commitHookPassiveMountEffects( 
          finishedWork,
          // HookHasEffect는 deps가 변경되지 않으면 콜백이 실행되지 않도록 한다.
          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;
    do {
      if ((effect.tag & flags) === flags) {
        // Mount
        const create = effect.create;
        const inst = effect.inst;
        // 📌 콜백이 여기에서 실행된다.
        const destroy = create();
        inst.destroy = destroy;
      }
      effect = effect.next;
    // 다시 필요한 Effect를 필터링하고 실행한다.
    } while (effect !== firstEffect);
  }
}

위 코드는 React의 Passive Mount Effect를 처리하는 함수들이다.

1. commitPassiveMountEffects는 완료된 작업 단위(Fiber)의 Passive Effect를 커밋하는 역할을 한다.

2. commitPassiveMountOnFiber는 개별 작업 단위(Fiber)의 Passive Effect를 처리하며, 자식 Fiber의 effect를 먼저 실행한 후 자신의 effect를 실행합니다.

3. commitHookPassiveMountEffects는 실제 effect 콜백을 실행하는 부분으로, 프로파일링 여부에 따라 타이밍을 측정하고 에러를 캡처하는 기능을 포함합니다.

commitPassiveUnmountEffects()를 처리하는 코드와 비슷한 구조를 가지고 있다.


📝 정리하기

1. useEffect는 파이버에 저장되는 Effect 객체를 생성한다. 이 Effect 객체에는 다음과 같은 정보가 포함된다.

  • tag: Effect의 실행 시기를 나타내는 표시이다.
  • create: useEffect에서 전달한 첫 번째 인자인 콜백 함수이다.
  • destroy: create 함수에서 반환된 클린업 함수이다

2. useEffect를 호출할 때 마다 React는 매번 새로운 Effect 객체를 생성하지만 deps배열이 변경되면 React는 다른 tag를 설정한다. 이를 통해 Effect의 실행 여부를 결정할 수 있다.

3. DOM에 업데이트를 커밋할 때, React는 다음 이벤트 루프 사이클에서 모든 Effect를 실행하도록 예약한다. 이 때 다음과 같은 순서로 처리된다.

  • 자식 컴포넌트의 클린업 함수가 먼저 실행된다.

  • 부모 컴포넌트의 클린업 함수가 실행된다.

  • 자식 컴포넌트의 Effect가 실행된다.

  • 부모 컴포넌트의 Effect가 실행된다.

이렇게 React는 Effect 객체를 관리하고, 효율적으로 Effect를 실행하도록 설계되어 있다. 이를 통해 개발자는 useEffect를 사용하여 컴포넌트의 부작용을 쉽게 관리할 수 있다.


💻 useEffect 실행 순서 퀴즈

👉 다음 코드의 콘솔 출력은 어떤 순서로 될까?

import { useEffect, useState } from "react";

export default function Home() {
  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;
}
출력 결과: 1 > 5 > 2 > 4 > 1 > 6 > 3 > 5 > 2

🎯 출력 순서 해설

1. 부모 컴포넌트 렌더링 ➔ 1 출력

2. 자식 컴포넌트(Child)의 effect부터 실행 ➔ 5 출력

3. 부모 컴포넌트(Home)의 첫 번째 effect(상단에 있는 effect부터) 실행 ➔ 2 출력

4. 부모 컴포넌트의 두 번째 effect 실행 ➔ 4 출력, setState 실행(count 상태 변화)

5. 상태 변화로 인한 재렌더링 ➔ 1 출력

6. 자식 컴포넌트에서 count를 deps로 하는 useEffect 실행, 이전 effect에서 반환된 클린업 함수 실행 ➔ 6 출력

7. 부모 컴포넌트에서 count를 deps로 하는 useEffect 실행, 이전 effect에서 반환된 클린업 함수 실행 ➔ 3 출력

8. 자식 컴포넌트 effect 실행 ➔ 5 출력

9. 부모 컴포넌트 effect 실행 ➔ 2 출력

💡 useEffect의 클린업 함수 실행 시점

헷갈릴 수 있는 부분이 클린업 함수가 포함되어 있는 useEffect 부분인데, 클린업 함수가 실행되는 시점으로 많이 알고 있는 부분이 컴포넌트가 언마운트 되는 상황이 있다.

하지만 그 밖에 의존성 배열(deps)에 있는 값이 변경되어 effect가 다시 실행되는 경우에도 클린업 함수가 실행된다는 것을 알아야 한다!


🙃 도움이 되었던 자료들

How does useEffect() work internally in React?
리액트 내부 동작 원리 4 — useEffect()
모던 리액트 Deep Dive 3.1 리액트의 모든 훅 파헤치기 - useEffect
리액트 내부 코드들

profile
🏁

0개의 댓글