
useLayoutEffect와 useEffect를 사용하면서 느꼈던 의문을 해결하기 위해서 파보기로 했습니다.
우선 브라우저의 렌더링 과정에 대해서 간략하게 알아보겠습니다. 크게 4가지로 나눌 수 있습니다.
- 파싱
- 레이아웃
- 페인트
- 컴포지팅
해당 과정에 대해서 자세히 알고 싶으시다면 제가 발표한 테코톡 참고해주세용~
기본적으로 useLayoutEffect 와 useEffect 훅은 해당 렌더링 과정에 관여하는 훅입니다.
- useLayoutEffect는 Layout단계 직후 실행됩니다.
- useEffect는 Paint 단계 직후 실행됩니다.
저는 여기서 해당 훅들이 어떻게 렌더링 타이밍을 정확하게 감지하는지에 대한 의문이 생겼습니다.
1️⃣ useLayoutEffect가 Layout 직후 실행되는 방법
React는 가상 DOM(Virtual DOM)을 활용하여 UI를 효율적으로 업데이트합니다. React의 렌더링 과정은 크게 다음과 같은 단계로 이루어집니다.
- 가상 DOM에서 렌더링: React 컴포넌트가 변경되면 새로운 가상 DOM 트리가 생성됩니다.
- 실제 DOM에 커밋(commit): React는 가상 DOM과 실제 DOM을 비교(diff)하여 변경 사항만 실제 DOM에 반영합니다.
useLayoutEffect와 Layout 단계
- 브라우저는 실제 DOM에 변경 사항이 반영(commit)되면 Layout 단계를 시작합니다.
- Layout 단계에서 브라우저는 DOM 요소의 크기와 위치를 계산하고, 이 과정은 브라우저의 렌더링 루프(Rendering Loop)에서 독립적으로 처리됩니다.
- 이때 JavaScript의 실행을 잠시 멈추고 DOM 계산을 완료합니다. 이는 DOM 상태를 읽고 변경하는 JavaScript 작업과 충돌할 가능성이 있어, Layout 단계의 독립성을 지키기 위한 브라우저의 설계입니다.
useLayoutEffect의 실행
- React는 커밋이 완료되자마자, 즉 Layout 계산 직후에 useLayoutEffect 콜백을 실행합니다.
- 커밋 이후 실행되는 JavaScript 메서드는 콜스택(Call Stack)에 가장 먼저 올라가기 때문에, useLayoutEffect는 Layout 직후 DOM이 최신 상태일 때 실행됩니다.
1. Virtual DOM Render → 2. Diffing → 3. Commit (실제 DOM 반영) → 4. Layout → 5. useLayoutEffect 실행
2️⃣ useEffect가 Paint 직후 실행되는 방법
useEffect는 React에서 브라우저가 화면을 Paint한 이후 실행되는 것이 보장됩니다. 이 훅을 이용해 DOM 업데이트가 완료된 후 안정적으로 비동기 작업을 실행할 수 있게 됩니다.
이를 위해서 React는 useEffect를 매크로 태스크 큐(Macro Task Queue)에 예약합니다.
왜 Macro Task Queue에 등록될까?
- 이벤트 루프는 Micro Task Queue의 작업을 먼저 실행하고, 그 다음에 Macro Task Queue를 실행합니다.
- Micro Task Queue는 브라우저 렌더링보다 먼저 실행됩니다. (Micro Task Queue가 비워질 때까지 렌더링이 지연됩니다.)
- 반면, Macro Task Queue의 작업은 브라우저의 Paint 작업 이후 실행되므로, DOM이 안정적인 상태에서 useEffect가 실행될 수 있습니다.
❗️조금 더 자세히 들어가봅시다.
React는 공개되어있지 않으니, Preact 코드를 보며 뜯어보려 합니다.
Preact
Fast 3kB alternative to React with the same modern API.
preactjs.com
1️⃣ useLayoutEffect 살펴보기
export function useLayoutEffect(callback, args) {
/** @type {import('./internal').EffectHookState} */
const state = getHookState(currentIndex++, 4);
if (!options._skipEffects && argsChanged(state._args, args)) {
state._value = callback;
state._pendingArgs = args;
currentComponent._renderCallbacks.push(state);
}
}
useLayoutEffect에서는 state를 만들고, renderCallbacks에 넣어주는 로직을 볼 수 있습니다.
그렇다면 _renderCallbacks은 어떻게 실행되는 걸까요
options._commit = (vnode, commitQueue) => {
commitQueue.some(component => {
try {
component._renderCallbacks.forEach(invokeCleanup);
component._renderCallbacks = component._renderCallbacks.filter(cb =>
cb._value ? invokeEffect(cb) : true
);
} catch (e) {
commitQueue.some(c => {
if (c._renderCallbacks) c._renderCallbacks = [];
});
commitQueue = [];
options._catchError(e, component._vnode);
}
});
if (oldCommit) oldCommit(vnode, commitQueue);
};
options의 _commit 메서드가 실행되면, _renderCallbacks를 순회돌면서 함수가 실행되게 됩니다.
이 options._commit은 어디서 실행되냐면,
export function commitRoot(commitQueue, root, refQueue) {
root._nextDom = UNDEFINED;
for (let i = 0; i < refQueue.length; i++) {
applyRef(refQueue[i], refQueue[++i], refQueue[++i]);
}
if (options._commit) options._commit(root, commitQueue); // 1️⃣ 커밋이 실행된다.
commitQueue.some(c => {
try {
commitQueue = c._renderCallbacks;
c._renderCallbacks = [];
commitQueue.some(cb => {
cb.call(c); // 2️⃣ 이곳에서 useLayoutEffect의 callback 함수가 실행된다.
});
} catch (e) {
options._catchError(e, c._vnode);
}
});
}
commitRoot라는 메서드에서 사용되고, 이는
export function render(vnode, parentDom, replaceNode) {
// ...
diff(
// ...
);
// Flush all queued effects
commitRoot(commitQueue, vnode, refQueue);
}
렌더 함수에서 실행하게 됩니다.
즉 diff에서 DOM요소의 직접적인 변경이 일어나고, commitRoot메서드를 통해 바로 실행되는 것이죠.
처음에 ‘1️⃣ useLayoutEffect가 Layout 직후 실행되는 방법’ 에서 살펴봤듯, 커밋 이후에 실행되므로 Layout직후 일어난다는 것을 예상할 수 있는 것입니다.
2️⃣ useEffect 살펴보기
export function useEffect(callback, args) {
/** @type {import('./internal').EffectHookState} */
const state = getHookState(currentIndex++, 3);
if (!options._skipEffects && argsChanged(state._args, args)) {
state._value = callback;
state._pendingArgs = args;
currentComponent.__hooks._pendingEffects.push(state);
}
}
useLayoutEffect와 달리 __hooks._pendingEffects라는 곳에 state를 주입하게 되는데요, 이는 어디서 실행될까요?
options._render = vnode => {
if (oldBeforeRender) oldBeforeRender(vnode);
currentComponent = vnode._component;
currentIndex = 0;
const hooks = currentComponent.__hooks;
if (hooks) {
if (previousComponent === currentComponent) {
hooks._pendingEffects = [];
currentComponent._renderCallbacks = [];
hooks._list.forEach(hookItem => {
if (hookItem._nextValue) {
hookItem._value = hookItem._nextValue;
}
hookItem._pendingArgs = hookItem._nextValue = undefined;
});
} else {
hooks._pendingEffects.forEach(invokeCleanup);
hooks._pendingEffects.forEach(invokeEffect);
hooks._pendingEffects = [];
currentIndex = 0;
}
}
previousComponent = currentComponent;
};
바로 _render 메서드에서 실행되게 됩니다. 이는 diff 메서드 내부 곳곳에서 사용되는 걸 확인할 수 있습니다.
그런데 Preact는 diff메서드가 실행되고 commit메서드가 실행되는데, useEffect가 먼저 실행되고 있었습니다.
예상대로라면, 먼저 실행되더라도 이벤트 Queue에 들어가야 되는데 어디서 실행되는지 잘 모르겠네요.
조금 더 찾아봅니다.
const RAF_TIMEOUT = 100;
//...
options.diffed = vnode => {
if (oldAfterDiff) oldAfterDiff(vnode);
const c = vnode._component;
if (c && c.__hooks) {
if (c.__hooks._pendingEffects.length) afterPaint(afterPaintEffects.push(c));
c.__hooks._list.forEach(hookItem => {
if (hookItem._pendingArgs) {
hookItem._args = hookItem._pendingArgs;
}
hookItem._pendingArgs = undefined;
});
}
previousComponent = currentComponent = null;
};
//...
function afterPaint(newQueueLength) {
if (newQueueLength === 1 || prevRaf !== options.requestAnimationFrame) {
prevRaf = options.requestAnimationFrame;
(prevRaf || afterNextFrame)(flushAfterPaintEffects);
}
}
//...
function flushAfterPaintEffects() {
let component;
while ((component = afterPaintEffects.shift())) {
if (!component._parentDom || !component.__hooks) continue;
try {
component.__hooks._pendingEffects.forEach(invokeCleanup);
component.__hooks._pendingEffects.forEach(invokeEffect);
component.__hooks._pendingEffects = [];
} catch (e) {
component.__hooks._pendingEffects = [];
options._catchError(e, component._vnode);
}
}
}
let HAS_RAF = typeof requestAnimationFrame == 'function';
function afterNextFrame(callback) {
const done = () => {
clearTimeout(timeout);
if (HAS_RAF) cancelAnimationFrame(raf);
setTimeout(callback);
};
const timeout = setTimeout(done, RAF_TIMEOUT);
let raf;
if (HAS_RAF) {
raf = requestAnimationFrame(done);
}
}
option.diffed에서 afterPaint메서드를, afterPaint에선 조건부로 flushAfterPaintEffects메서드를, flushAfterPaintEffects에선 afterNextFrame를 실행하고 있습니다.
afterNextFrame메서드에선 setTimeout을 이용해 매크로 태스크 큐를 사용하여 Paint이후에 콜백함수를 실행하도록 하고 있습니다.
의문점 하나는, requestAnimationFrame가 존재하여 prevRaf가 truthy값인 경우, Paint이전에 함수가 실행될 가능성이 있어보인다는 것입니다.
function afterPaint(newQueueLength) {
if (newQueueLength === 1 || prevRaf !== options.requestAnimationFrame) {
prevRaf = options.requestAnimationFrame;
(prevRaf || afterNextFrame)(flushAfterPaintEffects);
}
}
너무 깊게 파진 않겠습니다. 대략적으로 setTimeout을 이용하여 useEffect를 구현한 것을 확인하였습니다.
❗️ 결론!
예상대로 useLayoutEffect는 커밋 이후 실행되는 것을 확인하였고, useEffect의 경우 Macro Task Queue에서 실행되는 것을 확인하였습니다!
그런데.. 또 문제가 발생함.. 다음편에 계속~
Paint 이전 Macro Task가 실행될 가능성과 React의 useEffect
이전 아티클에서 useEffect는 macro Task를 통해 Paint 직후 실행됨을 시사하는 아티클을 작성하였습니다. [React] useLayoutEffect와 useEffectuseLayoutEffect와 useEffect를 사용하면서 느꼈던 의문을 해결하기 위해
lurgi.tistory.com
'개발관련 > React' 카테고리의 다른 글
[Settings] GraphQL과 Supabase, Apollo Client 설정 (0) | 2024.11.26 |
---|---|
크루루 서비스의 PopOverMenu 컴포넌트 개선기(1), createPortal과 합성 이벤트 문제 해결 (1) | 2024.11.23 |
Tanstack Query의 useQuery 캐싱 매커니즘 분석하기 (0) | 2024.11.21 |
크루루 서비스의 PopOverMenu 컴포넌트 개선기(2), 자식 요소 컴포넌트 렌더러 만들기 (4) | 2024.11.16 |
[디버깅] 크루루 서비스의 자동 로그인 문제와 Tanstack Query 캐싱 이슈 해결기 (0) | 2024.11.13 |