
안녕하세요, 오늘은 React의 useEffect가 실제로 언제 실행되는지에 대해알아보겠습니다. React 공식 문서에서는 useEffect의 실행 타이밍에 대해 간단하게 설명하고 있지만, 실제 동작은 훨씬 더 복잡합니다.
React 공식 문서(React.dev)에 따르면 다음과 같이 설명하고 있습니다:
"Effect가 상호작용(클릭과 같은)에 의해 발생하지 않았다면, React는 업데이트된 화면을 먼저 그린 후에 Effect를 실행합니다."
그러나 이 설명은 정확하지 않습니다. 상호작용 없이 발생한 Effect도 화면이 그려지기 전에 실행될 수 있습니다. 여러 데모를 통해 이를 검증해 보겠습니다.
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가 화면 렌더링 전에 실행될 수 있음을 보여줍니다.
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는 여러 작업을 한 번에 처리하려고 시도합니다. 렌더링이 완료된 후 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;
// ...
}
}
따라서 두 데모의 차이는 다음과 같습니다
scheduleCallback에서 새 작업이 생성됩니다.Scheduler가 메인 스레드에 양보하고 비동기적으로 예약합니다.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();
}
// ...
}
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 callbacks와 setTimeout 실행 → 3, 3, 4, 4 출력
useEffect와 useLayoutEffect의 주요 차이점은 실행 타이밍입니다
useEffect: 대부분의 경우 화면이 그려진 후에 비동기적으로 실행됩니다. 이는 성능 최적화에 도움이 되며, 일반적인 데이터 페칭이나 DOM 조작에 적합합니다.
useLayoutEffect: DOM 변경 직후, 화면이 그려지기 전에 동기적으로 실행됩니다. 이는 레이아웃 계산이나 시각적 깜빡임을 방지할 때 유용합니다.
언제 useLayoutEffect를 사용해야 할까요?
다만, useLayoutEffect는 브라우저의 페인팅을 차단하므로 성능에 영향을 줄 수 있습니다. 특히 복잡한 계산이나 무거운 작업을 수행할 경우 사용자 경험이 저하될 수 있으므로 주의해야 합니다.
useEffect 콜백은 DOM 변경이 완료된 후에 실행됩니다. 대부분의 경우 화면 렌더링 후에 비동기적으로 실행되지만, React는 다음과 같은 상황에서 화면 렌더링 전에 동기적으로 실행할 수 있습니다
따라서 React 공식 문서의 설명은 완전히 정확하지 않습니다. useEffect 콜백의 실행 타이밍은 상황에 따라 달라질 수 있으며, 항상 화면 렌더링 후에 실행된다고 보장할 수 없습니다.
이러한 내부 동작을 이해하면 다음과 같은 점을 고려할 수 있습니다
useEffect의 타이밍에 의존하지 마세요: useEffect가 항상 화면 렌더링 후에 실행된다고 가정하지 마세요. 실행 타이밍은 여러 요인에 의해 달라질 수 있습니다.
목적에 맞는 훅 선택하기:
성능 최적화에 집중하기: useLayoutEffect는 브라우저 페인팅을 차단하므로 필요한 경우에만 사용하고, 가능한 한 useEffect를 사용하세요.
디버깅 활용하기: 렌더링 과정에서 발생하는 문제를 디버깅할 때 useEffect와 useLayoutEffect의 실행 타이밍을 이해하면 문제 해결에 도움이 됩니다.
React의 내부 동작을 이해하는 것은 더 효율적이고 버그가 적은 코드를 작성하는 데 큰 도움이 됩니다. useEffect와 useLayoutEffect의 실행 타이밍을 정확히 이해하고 적절히 활용하면 더 나은 사용자 경험을 제공할 수 있습니다.
참고: 이 글은 React의 내부 구현을 분석한 것이며, 향후 React 버전에서는 동작이 달라질 수 있습니다.