[번역] React의 useEffect()는 어떻게 동작하나요?
React Internals Deep Dive - ep4
![[번역] React의 useEffect()는 어떻게 동작하나요?](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1712560949630%2F0ed6696c-8e68-4f45-8d24-5f3c6d27a16c.jpeg&w=3840&q=75)
Search for a command to run...
React Internals Deep Dive - ep4
![[번역] React의 useEffect()는 어떻게 동작하나요?](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1712560949630%2F0ed6696c-8e68-4f45-8d24-5f3c6d27a16c.jpeg&w=3840&q=75)
No comments yet. Be the first to comment.
[번역] 리액트 내부 작동원리에 대한 JSer의 시리즈입니다. https://jser.dev/series/react-source-code-walkthrough
React Internals Deep Dive - ep5
프론트엔드는 원래도 복잡했습니다. 브라우저마다 다른 렌더링, 상태 동기화, 성능, 접근성 — 화면 하나 제대로 만드는 데 신경 쓸 게 늘 많았습니다. 다만 그 복잡함은 대체로 한 방향을 향했습니다. 이 UI를 어떻게 만들까, 상태를 어떻게 관리할까, 클릭하면 무엇이 바뀌어야 할까. 그런데 요즘 질문의 축이 하나 더 늘고 있습니다. 이 UI는 서버에서 먼저
Microsoft가 TypeScript 7.0 beta를 공개했습니다. Go로 포팅한 네이티브 컴파일러고 "약 10배 빠르다"는 게 광고 문구입니다. 그 숫자가 실제 코드베이스에서도 나오는지, 따라붙는 호환성 비용은 뭔지 직접 돌려봤습니다. 대상 프로젝트는 React 19 + Vite 5로 굴리는 SPA고 .ts/.tsx 파일은 604개, composite
프론트엔드 개발자라면 아는 고통
'더 잘 놀기위해' 나를 빚는 인간

솔직히 말하면, 요즘 PR을 제때 리뷰하지 못하고 있습니다. 개발 속도는 빨라졌는데 리뷰 속도는 그대로입니다. 예전엔 하루 이틀이면 처리하던 게 이제는 며칠씩 밀립니다. 처음엔 제가 게을러진 탓인가 싶었습니다. 돌아보니 그게 아니었습니다. AI 도구를 쓰면서 코드가 쏟아지는 속도가 달라졌습니다. 한 사람이 만들어내는 코드 양이 예전과 비교가 안 됩니다. P

On this page
영문 블로그 글을 번역했습니다. 허가를 받으면 시리즈를 이어갈 예정입니다.
원문링크: https://jser.dev/2023-07-08-how-does-useeffect-work
ℹ️ React Internals Deep Dive 에피소드 4, 유튜브에서 제가 설명하는 것을 시청해주세요.
⚠ React@18.2.0 기준, 최신 버전에서는 구현이 변경되었을 수 있습니다.
useEffect()는 React에서 useState()를 제외하고 가장 많이 사용되는 훅입니다.
매우 강력하지만 때때로 혼란스러울 수 있는데, 내부적으로 어떻게 작동하는지 알아봅시다.
useEffect(() => {
// ...
}, [deps])
useEffect()useEffect()는 최초 마운트에서 mountEffect()를 사용합니다.
💬 역자 주석: Jser의 코멘트는 ❗❗로 표시 해뒀습니다.
그 외 주석은 리액트 소스 코드 자체의 주석입니다.
... 은 생략된 코드입니다.
function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return mountEffectImpl(
PassiveEffect | PassiveStaticEffect,
// ❗❗ ↖ 이 플래그를 사용하여 레이아웃 Effect와의 차이점을 구분할 수 있어 중요합니다.
// ❗❗ PassiveStaticEffect는 무엇일까요? 이 것은 다른 에피소드에서 다룰 가치가 있을거 같습니다.
HookPassive,
create,
deps,
);
}
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
const hook = mountWorkInProgressHook();
// ❗❗ ↗ 새로운 훅을 만듭니다.
const nextDeps = deps === undefined ? null : deps;
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushEffect(
// ❗❗ ↗ pushEffect()는 Effect 객체를 만들고, 그것을 훅에 설정합니다.
HookHasEffect | hookFlags,
// ❗❗ ↖ 이 플래그도 중요한데, 이것은 이 이팩트를 최초 마운트에서 실행해야 한다는 것을 의미합니다.
create,
undefined,
nextDeps,
);
}
function pushEffect(tag, create, destroy, deps) {
const effect: Effect = {
tag,
// ❗❗ ↖ tag는 중요한데, 이 Effect를 실행해야 하는지 여부를 표시하는 데 사용됩니다.
create,
// ❗❗ 우리가 전달한 콜백 입니다
destroy,
// ❗❗ 콜백에서 반환한 정리 함수 입니다.
deps,
// ❗❗ 우리가 전달한 deps 배열입니다.
// Circular
next: (null: any),
// ❗❗ 하나의 컴포넌트에 여러 Effect가 있을 수 있습니다. 그걸 체인으로 엮습니다.
};
let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
// ❗❗ Effect는 fiber의 updateQueue에 저장됩니다.
// ❗❗ 이것은 훅의 memoizedState와는 다릅니다.
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
}
return effect;
}
최초 마운트의 경우, useEffect()가 필요한 플래그를 사용하여 Effect 객체를 생성하는 것을 볼 수 있습니다. effect 객체들은 다른 타이밍에 처리될 것입니다.
useEffect()function updateEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}
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;
// ❗❗ ↗ memoizedState의 effect 훅이 Effect 객체라는 것을 기억하세요.
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) {
hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
// ❗❗ deps가 변경되지 않으면, Effect 객체를 다시 생성하는 것만 수행합니다
// ❗❗ 여기서 다시 생성해야 하는 이유는 아마도 단순히 updateQueue를 다시 생성해야 하기 때문일겁니다.
// ❗❗ 그리고 우리는 업데이트 된 create()함수를 가져올 필요가 있습니다.
// ❗❗ 여기서는 이전 destory() 함수를 사용하고 있음도 알 수 있습니다.
}
}
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
// ❗❗ ↖ 만약 deps가 바뀌면, HookHasEffect 는 이 Effect를 실행 해야함을 나타내는 표시입니다.
create,
destroy,
nextDeps,
);
}
우리는 혼란스러운 deps 배열이 어떻게 작동하는지 알 수 있엇습니다. 리-렌더링할 때는 어떤 경우에는 Effect 객체를 다시 생성하지만, deps가 변경되면 생성된 Effect가 이전 정리(destory()) 함수를 사용하여 실행되도록 표시됩니다.
위에서 우리는 useEffect()가 Fiber Node에 추가 데이터 구조를 생성할 뿐이라는 것을 알았습니다. 이제 이러한 Effect 객체가 어떻게 처리되는지 알아볼 필요가 있습니다.
commitRoot()내에서 트리거 됩니다.두 개의 Fiber Tree(조정)를 비교하여 서로 다른 결과를 얻은 후 "Commit" 단계에서 호스트 DOM에 변경 사항을 반영합니다. 패시브 Effect의 의 flush를 시작한 코드를 쉽게 찾을 수 있습니다.
function commitRootImpl(
root: FiberRoot,
recoverableErrors: null | Array<CapturedValue<mixed>>,
transitions: Array<Transition> | null,
renderPriorityLevel: EventPriority,
) {
// If there are pending passive effects, schedule a callback to process them.
// Do this as early as possible, so it is queued before anything else that
// might get scheduled in the commit phase. (See #16714.)
// TODO: Delete all other places that schedule the passive effect callback
// They're redundant.
if (
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags
) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
pendingPassiveEffectsRemainingLanes = remainingLanes;
// workInProgressTransitions might be overwritten, so we want
// to store it in pendingPassiveTransitions until they get processed
// We need to pass this through as an argument to commitRoot
// because workInProgressTransitions might have changed between
// the previous render and commit if we throttle the commit
// with setTimeout
pendingPassiveTransitions = transitions;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
// This render triggered passive effects: release the root cache pool
// *after* passive effects fire to avoid freeing a cache pool that may
// be referenced by a node in the tree (HostRoot, Cache boundary etc)
return null;
});
// ❗❗ 여기서 useEffect()에 의해 생겨난 패시드 Effect를 플러시 합니다.
// ❗❗ 이렇게 하면 당장이 아닌 다음 틱(tick)에 플러싱이 예약됩니다.
// ❗❗ 좀 더 자세한 정보는 EP20의 리액트 스케쥴러는 어떻게 동작하는가를 참고하세요
}
}
...
}
flushPassiveEffects()function flushPassiveEffectsImpl() {
if (rootWithPendingPassiveEffects === null) {
return false;
}
// Cache and clear the transitions flag
const transitions = pendingPassiveTransitions;
pendingPassiveTransitions = null;
const root = rootWithPendingPassiveEffects;
const lanes = pendingPassiveEffectsLanes;
rootWithPendingPassiveEffects = null;
// TODO: This is sometimes out of sync with rootWithPendingPassiveEffects.
// Figure out why and fix it. It's not causing any known issues (probably
// because it's only used for profiling), but it's a refactor hazard.
pendingPassiveEffectsLanes = NoLanes;
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
commitPassiveUnmountEffects(root.current);
commitPassiveMountEffects(root, root.current, lanes, transitions);
// ❗❗↗ 여기서 우리는 콜백이 실행되지 전에 Effect 정리가 먼저 실행되는 것을 명확하게 볼 수 있습니다.
...
}
commitPassiveUnmountEffects()export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
setCurrentDebugFiberInDEV(finishedWork);
commitPassiveUnmountOnFiber(finishedWork); // ❗❗ commitPassiveUnmountOnFiber
resetCurrentDebugFiberInDEV();
}
function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
recursivelyTraversePassiveUnmountEffects(finishedWork);
// ❗❗ ↗ 우리는 여기서 자식들의 effects들이 먼저 정리되는 것을 볼 수 있습니다.
if (finishedWork.flags & Passive) {
commitHookPassiveUnmountEffects( // ❗❗ commitHookPassiveUnmountEffects
finishedWork,
finishedWork.return,
HookPassive | HookHasEffect,
// ❗❗ ↗ 이 플래그(HookHasEffect)는 deps가 변경되지 않았을 때 훅이 실행되지 않는 것을 보장합니다
);
}
break;
}
...
}
}
function commitHookPassiveUnmountEffects(
finishedWork: Fiber,
nearestMountedAncestor: null | Fiber,
hookFlags: HookFlags,
) {
if (shouldProfile(finishedWork)) {
startPassiveEffectTimer();
commitHookEffectListUnmount( // ❗❗ commitHookEffectListUnmount
hookFlags,
finishedWork,
nearestMountedAncestor,
);
recordPassiveEffectDuration(finishedWork);
} else {
commitHookEffectListUnmount( // ❗❗ 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;
do {
if ((effect.tag & flags) === flags) { // ❗❗ (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);
// ❗❗ 여기서는 updateQueue의 모든 effect를 단순히 루프 돌기만 하면 됩니다
// ❗❗ 그러면서 플래그를 통해 필요한 항목들을 필터링 합니다.
}
}
function safelyCallDestroy(
current: Fiber,
nearestMountedAncestor: Fiber | null,
destroy: () => void,
) {
try {
destroy();
} catch (error) {
captureCommitPhaseError(current, nearestMountedAncestor, error);
}
}
commitPassiveMountEffects()commitPassiveMountEffects()는 같은 방식으로 작동합니다.
export function commitPassiveMountEffects(
root: FiberRoot,
finishedWork: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
): void {
setCurrentDebugFiberInDEV(finishedWork);
commitPassiveMountOnFiber( // ❗❗ commitPassiveMountOnFiber
root,
finishedWork,
committedLanes,
committedTransitions,
);
resetCurrentDebugFiberInDEV();
}
function commitPassiveMountOnFiber(
finishedRoot: FiberRoot,
finishedWork: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
): void {
// When updating this function, also update reconnectPassiveEffects, which does
// most of the same things when an offscreen tree goes from hidden -> visible,
// or when toggling effects inside a hidden tree.
const flags = finishedWork.flags;
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
recursivelyTraversePassiveMountEffects(
// ❗❗ ↖ 우리는 여기서 자식들의 effects들이 먼저 실행되는 것을 볼 수 있습니다.
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
if (flags & Passive) {
commitHookPassiveMountEffects( // ❗❗ commitHookPassiveMountEffects
finishedWork,
HookPassive | HookHasEffect,
// ❗❗ ↗ 이 플래그(HookHasEffect)는 deps가 변경되지 않았을 때 훅이 실행되지 않는 것을 보장합니다
);
}
break;
}
...
}
}
function commitHookPassiveMountEffects(
finishedWork: Fiber,
hookFlags: HookFlags,
) {
if (shouldProfile(finishedWork)) {
startPassiveEffectTimer();
try {
commitHookEffectListMount(hookFlags, finishedWork); // ❗❗ commitHookEffectListMount
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
recordPassiveEffectDuration(finishedWork);
} else {
try {
commitHookEffectListMount(hookFlags, finishedWork); // ❗❗ commitHookEffectListMount
} 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();
callback is run here!
inst.destroy = destroy;
}
effect = effect.next;
} while (effect !== firstEffect);
// ❗❗ 다시, 필요한 Effect들을 필터링하고 다음을 실행합니다.
}
}
소스 코드를 살펴본 결과, useEffect()의 내부 구조는 매우 간단하는 것을 알 수 있었습니다.
useEffect() 는 Fiber에 저장되어 있는 Effect 객체를 생성합니다.
Effect 는 실행이 필요한지 여부를 나타내는 tag 를 가집니다.
Effect 는 첫번째 인수로 전달되는 create() 함수가 있습니다.
Effect create()의 정리인 destroy()가 있고, 이것은 create()가 실행될 때만 설정됩니다.
useEffect() 는 매번 새로운 Effect 객체들을 만들어냅니다, 하지만 이것들은 만약 deps 배열이 변경되면 다른 tag를 설정하게 됩니다.
호스트 DOM에 업데이트를 커밋할 때, 다음 틱(tick)의 작업은 tag를 기반으로 모든 Effect를 다시 실행 하도록 예약됩니다.
하위 컴포넌트의 Effect가 먼저 처리 됩니다.
정리가 먼저 실행됩니다.
이제 오늘 배운 내용을 바탕으로 BFE.dev의 React 퀴즈를 풀 수 있습니다.
원본 글 작성일: 2023-07-08