안녕하세요! 오늘은 React에서 가장 많이 사용되는 훅 중 하나인 useEffect의 내부 동작 원리와 생명주기에 대해 자세히 알아보겠습니다. React 18.2.0 버전을 기준으로 설명하며, 최신 버전에서는 일부 구현이 변경되었을 수 있습니다.
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) 함수입니다. 이제 다음 세 가지 질문에 대한 답을 찾아보겠습니다:
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
);
}
여기서 두 가지 중요한 일이 일어납니다:
mountWorkInProgressHook()를 통해 새로운 훅을 생성하고 이를 fiber의 훅 리스트(memoizedState)에 추가합니다.따라서 fiber는 다음과 같은 두 가지를 가질 수 있습니다:
Effect는 side effect를 의미하며 fiber의 updateQueue에 추가되어 React가 변경사항을 커밋한 후 실행됩니다.
pushEffect 함수의 첫 번째 인자는 Effect.tag를 제어합니다. 마운팅 단계에서는 HookHasEffect | hookFlags가 전달되며, 여기서 HookHasEffect는 이 이펙트가 실행되어야 함을 의미합니다.
이 플래그는 매우 중요합니다. updateEffect에서는 deps가 변경되었는지 확인하여 이 플래그를 토글합니다.
flushPassiveEffects()는 useEffect에서 생성된 이펙트를 실행하는 함수입니다. 이 함수는 여러 곳에서 호출되지만, 가장 중요한 위치는 조정(reconciliation) 후 커밋 단계에서 실행되는 commitRoot() 함수 내부입니다.
flushPassiveEffects()는 scheduleCallback을 통해 스케줄링되므로, DOM 변경 직후 동기적으로 실행되지 않고 다음 틱에서 실행됩니다.
내부적으로 flushPassiveEffects()는 두 가지 주요 작업을 수행합니다:
commitPassiveUnmountEffects(root.current): 이전 이펙트의 정리(cleanup) 함수를 실행합니다.commitPassiveMountEffects(root, root.current): 새로운 이펙트를 실행합니다.이펙트의 정리 함수는 이펙트가 다시 실행되기 전에 먼저 실행되어야 하므로, 언마운트가 마운트보다 먼저 발생합니다.
이 함수는 주로 삭제된 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()에서입니다.
이 함수는 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를 설정합니다. 이 시점에서 우리의 생성자 함수가 드디어 실행됩니다!
첫 마운트 이후, 컴포넌트가 다시 실행되면 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
);
}
여기서 주목할 점은:
updateWorkInProgressHook()에서 일어나는 일currentHook이 무엇인지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 함수(destroy)는 다음과 같은 경우에 호출됩니다:
이 과정은 commitPassiveUnmountEffects()에서 처리됩니다. 이 함수는 HookPassive | HookHasEffect 플래그가 있고 destroy가 있는 이펙트를 찾아 정리합니다.
이제 useEffect의 전체 생명주기를 정리해보겠습니다:
useEffect 첫 호출 시:
이펙트 실행 시(마운트):
flushPassiveEffects에서:
컴포넌트 리렌더링 시:
이제 useEffect의 생명주기에 대해 더 깊이 이해할 수 있게 되었습니다. 특히 다음과 같은 내용을 배웠습니다:
효과적인 React 애플리케이션 개발을 위해 이러한 내부 동작 원리를 이해하는 것이 중요합니다. 이 지식을 바탕으로 더 최적화된 컴포넌트를 작성할 수 있을 것입니다.
참고: 이 글은 React@18.2.0 버전을 기준으로 작성되었으며, 최신 버전에서는 일부 구현이 변경되었을 수 있습니다.