useEffect가 실제로 언제 실행되는가

한상우·2025년 5월 4일

리액트

목록 보기
20/24
post-thumbnail

useEffect가 실제로 언제 실행되는가: React 공식 문서를 넘어보자

안녕하세요, 오늘은 React의 useEffect가 실제로 언제 실행되는지에 대해알아보겠습니다. React 공식 문서에서는 useEffect의 실행 타이밍에 대해 간단하게 설명하고 있지만, 실제 동작은 훨씬 더 복잡합니다.

목차

  1. React 공식 문서의 설명은 정확한가?
  2. 실제 동작 검증하기
  3. React Scheduler의 내부 동작 원리
  4. 다양한 상황에서의 useEffect 실행 타이밍
  5. useEffect vs useLayoutEffect: 언제 무엇을 사용해야 할까?
  6. 결론: useEffect 실행 타이밍의 진실
  7. 개발 시 고려사항

React 공식 문서의 설명은 정확한가?

React 공식 문서(React.dev)에 따르면 다음과 같이 설명하고 있습니다:

"Effect가 상호작용(클릭과 같은)에 의해 발생하지 않았다면, React는 업데이트된 화면을 먼저 그린 후에 Effect를 실행합니다."

그러나 이 설명은 정확하지 않습니다. 상호작용 없이 발생한 Effect도 화면이 그려지기 전에 실행될 수 있습니다. 여러 데모를 통해 이를 검증해 보겠습니다.

실제 동작 검증하기

데모 1: 렌더링 전에 실행되는 useEffect

import React, { useState, useEffect } from "react";
export default function App() {
  const [state] = useState(0);
  console.log(1);
  useEffect(() => {
    console.log(2);
  }, [state]);
  Promise.resolve().then(() => console.log(3));
  setTimeout(() => console.log(4), 0);
  return <div>open console to see the logs</div>;
}

만약 useEffect가 항상 비동기적으로 실행된다면, 로그 순서는 1 → 3 → 4 → 2가 되어야 하지만, 실제로는 1 → 2 → 3 → 4 순으로 출력됩니다. 이는 useEffect가 화면 렌더링 전에 실행될 수 있음을 보여줍니다.

데모 2: 렌더링 후에 실행되는 useEffect

import React, { useState, useEffect } from "react";
export default function App() {
  const [state] = useState(0);
  console.log(1);
  let start = Date.now();
  while (Date.now() - start < 50) {} // 블로킹 코드 추가
  useEffect(() => {
    console.log(2);
  }, [state]);
  Promise.resolve().then(() => console.log(3));
  setTimeout(() => console.log(4), 0);
  return <div>open console to see the logs</div>;
}

이 코드에서는 로그 순서가 1 → 3 → 4 → 2로 출력됩니다. 이는 비동기적으로 실행되는 것처럼 보입니다. 그렇다면 왜 두 데모의 결과가 다를까요?

React Scheduler의 내부 동작 원리

React Scheduler는 여러 작업을 한 번에 처리하려고 시도합니다. 렌더링이 완료된 후 commit 단계에서 useEffect 콜백은 다음과 같이 예약됩니다

function commitRootImpl(
  root: FiberRoot,
  recoverableErrors: null | Array<CapturedValue<mixed>>,
  transitions: Array<Transition> | null,
  renderPriorityLevel: EventPriority,
) {
  // ...
  if (
    (finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
    (finishedWork.flags & PassiveMask) !== NoFlags
  ) {
    if (!rootDoesHavePassiveEffects) {
      rootDoesHavePassiveEffects = true;
      pendingPassiveEffectsRemainingLanes = remainingLanes;
      pendingPassiveTransitions = transitions;
      scheduleCallback(NormalSchedulerPriority, () => {
        flushPassiveEffects();
        return null;
      });
    }
  }
  // ...
}

여기서 scheduleCallback은 새로운 작업을 생성하지만, 이미 작업이 진행 중인 경우 실제 스케줄링은 일어나지 않고 작업 큐에 푸시만 합니다

function unstable_scheduleCallback(
  priorityLevel: PriorityLevel,
  callback: Callback,
  options?: { delay: number }
): Task {
  // ...
  var newTask: Task = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };

  if (startTime > currentTime) {
    // ...
  } else {
    newTask.sortIndex = expirationTime;
    push(taskQueue, newTask);
    
    // Schedule a host callback, if needed. If we're already performing work,
    // wait until the next time we yield.
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback();
    }
  }
  return newTask;
}

React Scheduler는 작업 처리 중 시간이 충분한지 확인하고, 시간이 부족하면 브라우저에 제어권을 넘깁니다

function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime;
  advanceTimers(currentTime);
  currentTask = peek(taskQueue);
  while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // This currentTask hasn't expired, and we've reached the deadline.
      break;
    }
    const callback = currentTask.callback;
    // ...
  }
}

따라서 두 데모의 차이는 다음과 같습니다

  1. 두 데모 모두 useEffect로 인해 scheduleCallback에서 새 작업이 생성됩니다.
  2. 데모 1에서는 전체 렌더링이 빠르게 완료되어 작업이 동기적으로 실행됩니다.
  3. 데모 2에서는 while 루프로 인해 렌더링이 오래 걸리므로 Scheduler가 메인 스레드에 양보하고 비동기적으로 예약합니다.

다양한 상황에서의 useEffect 실행 타이밍

데모 3: 사용자 상호작용에 의한 useEffect는 화면 렌더링 전에 실행됨

import React, { useState, useEffect } from "react";
export default function App() {
  const [state, setState] = useState(0);
  console.log(1);
  let start = Date.now();
  while (Date.now() - start < 50) {}
  useEffect(() => {
    console.log(2);
  }, [state]);
  Promise.resolve().then(() => console.log(3));
  setTimeout(() => console.log(4), 0);
  return (
    <div>
      click <button onClick={() => setState((state) => state + 1)}>rerender</button>{" "}
      and open console to see the logs{" "}
    </div>
  );
}

이 데모의 결과는 1 → 3 → 4 → 2(초기 렌더링) 그리고 버튼 클릭 후 1 → 2 → 3 → 4(재렌더링)입니다.

사용자 상호작용(클릭 등)에 의한 리렌더링은 내부적으로 SyncLane으로 처리됩니다. commitRoot 함수 내에서 플래그를 검사하여 SyncLane에 의한 passive effects가 있으면 동기적으로 실행합니다

function commitRootImpl(/* ... */) {
  // ...
  // If the passive effects are the result of a discrete render, flush them
  // synchronously at the end of the current task so that the result is
  // immediately observable. Otherwise, we assume that they are not
  // order-dependent and do not need to be observed by external systems, so we
  // can wait until after paint.
  if (
    includesSomeLane(pendingPassiveEffectsLanes, SyncLane) &&
    root.tag !== LegacyRoot
  ) {
    flushPassiveEffects();
  }
  // ...
}

데모 4: useLayoutEffect에 의해 useEffect가 화면 렌더링 전에 실행될 수 있음

import React, { useState, useEffect, useLayoutEffect } from "react";
export default function App() {
  const [state, setState] = useState(0);
  console.log(1);
  let start = Date.now();
  while (Date.now() - start < 50) {}
  useEffect(() => {
    console.log(2);
  }, [state]);
  useLayoutEffect(() => {
    setState((state) => state + 1);
  }, []);
  Promise.resolve().then(() => console.log(3));
  setTimeout(() => console.log(4), 0);
  return <div>open console to see the logs </div>;
}

이 데모의 결과는 1 → 2 → 1 → 2 → 3 → 3 → 4 → 4입니다.

commit 단계에서 발생하는 업데이트는 DiscreteEventPriority로 처리되며, 이는 SyncLane과 같습니다

function commitRoot(/* ... */) {
  // ...
  try {
    ReactCurrentBatchConfig.transition = null;
    setCurrentUpdatePriority(DiscreteEventPriority);
    commitRootImpl(/* ... */);
  } finally {
    ReactCurrentBatchConfig.transition = prevTransition;
    setCurrentUpdatePriority(previousUpdatePriority);
  }
  return null;
}

데모 4의 로그 순서는 다음과 같이 설명할 수 있습니다
1. commit 시작 → 1 출력
2. flushPassiveEffects() 비동기 예약
3. layout effects 실행, setState 호출, performSyncWorkOnRoot() 동기적 예약
4. performSyncWorkOnRoot()에서 flushPassiveEffects() 호출 → 2 출력
5. 리렌더링 완료, 다시 commit 시작 → 1 출력
6. flushPassiveEffects() 비동기 예약
7. SyncLane 아래에서 flushPassiveEffects() 호출 → 2 출력
8. 여기까지 모두 동기, 그 다음 Promise callbackssetTimeout 실행 → 3, 3, 4, 4 출력

useEffect vs useLayoutEffect: 언제 무엇을 사용해야 할까?

useEffect와 useLayoutEffect의 주요 차이점은 실행 타이밍입니다

  • useEffect: 대부분의 경우 화면이 그려진 후에 비동기적으로 실행됩니다. 이는 성능 최적화에 도움이 되며, 일반적인 데이터 페칭이나 DOM 조작에 적합합니다.

  • useLayoutEffect: DOM 변경 직후, 화면이 그려지기 전에 동기적으로 실행됩니다. 이는 레이아웃 계산이나 시각적 깜빡임을 방지할 때 유용합니다.

언제 useLayoutEffect를 사용해야 할까요?

  1. 시각적 깜빡임 방지가 필요할 때
  2. DOM 요소의 크기나 위치를 측정하고 이에 따라 다시 렌더링해야 할 때
  3. 화면 업데이트 전에 반드시 실행되어야 하는 DOM 조작이 있을 때

다만, useLayoutEffect는 브라우저의 페인팅을 차단하므로 성능에 영향을 줄 수 있습니다. 특히 복잡한 계산이나 무거운 작업을 수행할 경우 사용자 경험이 저하될 수 있으므로 주의해야 합니다.

결론: useEffect 실행 타이밍의 진실

useEffect 콜백은 DOM 변경이 완료된 후에 실행됩니다. 대부분의 경우 화면 렌더링 후에 비동기적으로 실행되지만, React는 다음과 같은 상황에서 화면 렌더링 전에 동기적으로 실행할 수 있습니다

  1. 최신 UI를 보여주는 것이 더 중요한 경우
    (사용자 상호작용에 의한 리렌더링이나 layout effects에 의한 리렌더링)
  2. React가 내부적으로 시간적 여유가 있는 경우

따라서 React 공식 문서의 설명은 완전히 정확하지 않습니다. useEffect 콜백의 실행 타이밍은 상황에 따라 달라질 수 있으며, 항상 화면 렌더링 후에 실행된다고 보장할 수 없습니다.

개발 시 고려사항

이러한 내부 동작을 이해하면 다음과 같은 점을 고려할 수 있습니다

  1. useEffect의 타이밍에 의존하지 마세요: useEffect가 항상 화면 렌더링 후에 실행된다고 가정하지 마세요. 실행 타이밍은 여러 요인에 의해 달라질 수 있습니다.

  2. 목적에 맞는 훅 선택하기:

    • 화면 렌더링 전에 반드시 실행되어야 하는 코드는 useLayoutEffect를 사용하세요.
    • 비동기 데이터 페칭, 이벤트 리스너 설정과 같은 작업은 useEffect를 사용하세요.
  3. 성능 최적화에 집중하기: useLayoutEffect는 브라우저 페인팅을 차단하므로 필요한 경우에만 사용하고, 가능한 한 useEffect를 사용하세요.

  4. 디버깅 활용하기: 렌더링 과정에서 발생하는 문제를 디버깅할 때 useEffect와 useLayoutEffect의 실행 타이밍을 이해하면 문제 해결에 도움이 됩니다.

React의 내부 동작을 이해하는 것은 더 효율적이고 버그가 적은 코드를 작성하는 데 큰 도움이 됩니다. useEffect와 useLayoutEffect의 실행 타이밍을 정확히 이해하고 적절히 활용하면 더 나은 사용자 경험을 제공할 수 있습니다.


참고: 이 글은 React의 내부 구현을 분석한 것이며, 향후 React 버전에서는 동작이 달라질 수 있습니다.

profile
안녕하세요

0개의 댓글