생각보다 useRef에서는 렌더링에 대한 고려를 많이 하지 않아서 비교적 구현에 많은 어려움은 없었지만,
이번에는 useEffect를 구현하면서 생각보다 많이 헤맨 부분이 많았다. 하하하 😇😇😇 왜 이렇게 헤맸을까,,를 생각해보며 고고!
우선 useEffect의 구조를 보자.
useEffect(() => {
console.log("useEffect 실행됨! effectValue:", effectValue);
}, [effectValue]);
지금까지 봤던 hook과는 다른 구조이다. 두 가지 파라미터를 받는데, 1) callback 2) dependency 로 이루어져있다.
두 번째 파라미터로 받는 deps 배열에 들어있는 값이 이전 렌더와 달라졌을 때만 콜백을 실행한다. 그리고 콜백이 리턴하는 함수(cleanup)는, 다음 콜백이 실행되기 전에 "이전 걸 정리하는" 역할을 한다.
callback과 cleanup 자체가 헷갈렸었는데, 코드로 보면:
useEffect(() => {
console.log("effect 실행!"); // ① 이 함수 전체가 callback
return () => {
console.log("cleanup 실행!"); // ② 이 안쪽 함수가 cleanup
};
}, [deps]);
useEffect 의 첫 번째 인자로 넘기는 함수 전체.return 하는 함수. callback의 "부산물"이다. callback이 return 안 하면 cleanup은 그냥 undefined 이다.즉, callback은 "실행할 작업", cleanup은 "그 작업을 실행한 결과로 나온, 그 작업을 되돌리는 함수"라는 것이다.
둘은 별개의 함수이고, cleanup은 callback 없이는 존재할 수 없다. (callback을 실행해야만 cleanup이 생긴다).
useEffect 의 흐름을 대략적으로 본다면
1. 컴포넌트 실행 -> useEffect는 "callback, deps"를 저장
2. React가 DOM을 실제로 바꿈
3. 브라우저가 화면을 바뀐 DOM을 반영하여 화면을 그림 (painting)
4. 화면이 그려진 이후 useEffect 콜백 실행
일 것이다.
그럼 이 흐름에 맞춰 구현 가보자고!!
deps가 바뀌었을때만 콜백을 실행한다. (조건부 실행)useState의 states[], useRef의 refs[]와 똑같은 원리로, 렌더 간 정보를 유지할 저장공간이 또 필요했다. 이번엔 deps랑 나중에 나올 cleanup까지 같이 들고 있어야 해서, 슬롯마다 객체 하나씩 저장하는 구조로 만들었다.
let effects = [];
useEffect가 호출되면, 일단 "이 슬롯이 처음 호출되는 건지" 부터 확인한다. 처음이면 비교할 대상 자체가 없으니 무조건 실행해야 한다.
export function useEffect(callback, deps) {
const currentIndex = getNextHookIndex();
if (effects[currentIndex] === undefined) {
// 첫 호출, 비교할 게 없으니 무조건 실행
effects[currentIndex] = { deps };
// (실행 대기열에 등록하는 부분은 다음 섹션에서)
} else if (/* deps가 달랐다면 */) {
// 실행 대기열에 등록 + effects 갱신
}
}
처음엔 else if 조건에 deps 비교를 그냥 Object.is로 넣었는데, 이게 뒤에서 한참 헤매게 만든 원흉이었다. (4번 목표에서 자세히 풀 예정 😇).
useEffect()가 호출되는 시점은 아직 App() 함수가 실행되는 중이다. 즉, DOM이 갱신되기 전이라는 뜻이다. 그런데 앞에서 본 React의 흐름을 떠올려보면, 콜백은 "화면이 그려진 다음"에 실행되어야 한다. 그래서 useEffect() 안에서 콜백을 바로 실행하면 안된다.
대신 "이번에 실행해야할 콜백 목록"을 따로 모아두는 큐를 하나 더 만들었다.
let pendingEffects = [];
useEffect()는 실행 여부만 판단하고, 실제로 실행할 항목은 여기에 push만 한다.
pendingEffects.push({
index: currentIndex,
callback: callback,
cleanup: undefined, // 또는 이전 cleanup
});
그리고 이걸 실제로 실행하는 함수를 따로 만들었다.
export function runPendingEffects() {
for (const effect of pendingEffects) {
// 새 callback 실행 전에 이전 cleanup을 먼저 호출해 정리
if (effect.cleanup) {
effect.cleanup();
}
// callback 실행 후 나온 결과물을 cleanup에 넣기
effects[effect.index].cleanup = effect.callback();
}
// 실행 후 초기화
pendingEffects.length = 0;
}
이 함수는 main.js의 render()에서, DOM이 실제로 갱신된 다음에 호출한다.
function render() {
resetHookIndex();
const app = App();
root.innerHTML = app.html; // DOM 갱신
runPendingEffects(); // 그 다음에 실행
...
}
처음엔 "실행이 필요하다"는 판정을 받으면 그냥 rerender()를 호출해버렸다.
if (effects[currentIndex] === undefined) {
rerender(); // ❌ 무한루프
}
rerender()는 결국 render()를 다시 호출하는 거라, render() → App() → useEffect() → rerender() → render() → ...로 무한히 반복됐다. "실행이 필요하다"는 판정과 "지금 당장 다시 렌더하자"는 전혀 다른 얘기였는데, 그걸 혼동했던 거다. 결국 rerender() 호출 대신 pendingEffects에 등록만 하는 걸로 바꿔서 해결했다.
앞에서 본 것처럼 runPendingEffects() 안에서, 새 callback을 실행하기 전에 직전 cleanup이 있다면 먼저 호출해줘야 한다.
export function runPendingEffects() {
for (const effect of pendingEffects) {
if (effect.cleanup) {
effect.cleanup(); // 이전 정리
}
effects[effect.index].cleanup = effect.callback(); // 실행 후 리턴값을 새 cleanup으로 저장
}
pendingEffects.length = 0;
}
그리고 이 cleanup을 runPendingEffects()에 등록할 때, 어디서 가져오느냐가 중요했다. useEffect() 쪽 코드를 보면:
} else if (!isSameDeps) {
const currentEffect = effects[currentIndex]; // 덮어쓰기 전에 먼저 꺼내둔다
pendingEffects.push({
index: currentIndex,
callback: callback,
cleanup: currentEffect.cleanup, // 직전 실행에서 나온 cleanup
});
effects[currentIndex] = { deps }; // 다 쓰고 난 다음에 덮어쓴다
}
1) 순서 문제
처음엔 effects[currentIndex] = {deps} 로 먼저 덮어쓰고 나서 cleanup을 읽으려고 했다. 그러면 이미 새 값으로 덮어쓴 뒤라 직전 cleanup은 영영 사라진 채였다. 그래서 "이전 값은 먼저 빼두고 -> 그걸로 push -> 그 다음에 덮어쓰기" 순서로 바꿔야 했다.
2) 조건 문제
callback() 실행을 if (effect.cleanup) 블록 안에 넣었던 적도 있다.
// ❌ 이렇게 하면 첫 렌더(cleanup이 없는 경우)에 callback이 한 번도 안 실행됨
if (effect.cleanup) {
effects[effect.index].cleanup = effect.callback();
}
cleanup이 있을 때만 callback을 실행하게 짜놨던 거다. pendingEffects에 들어있다는 것 자체가 "이 콜백은 실행되어야 한다"는 뜻이었는데, 그 실행 자체를 "이전 cleanup이 있는가"라는 별개의 조건에 가둬버린 게 문제였다. "이전 cleanup 호출"과 "새 callback 실행"은 서로 독립적인 동작이어야 한다는 걸 여기서 깨달았다.
deps는 값이 변했는지, 변하지 않았는지의 여부를 판단해야 콜백을 실행할 수 있다.
처음엔 이 부분을 useState에서 처럼 동일하게 Object.is를 사용하여 비교하려 했다.
const isSameDeps = Object.is(effects[currentIndex]?.deps, deps);
그런데 deps의 값이 안 바뀌었는데도 isSameDeps가 계속 false로 나왔다.(???) 콘솔에서 직접 찍어봤다.
Object.is([1, 2], [1, 2]); // false
deps 배열은 App()이 호출될 때마다 [effectValue] 같은 배열 리터럴로 매번 새로 생성된다. 안의 값이 같아도 배열이라는 "그릇" 자체는 매번 다른 참조라서, Object.is로 통째로 비교하면 내용이 똑같아도 항상 false가 나오는 거였다.
(객체/ 배열은 참조 비교, 원시값은 값 비교)
그래서 배열 안의 원소를 하나씩 꺼내서 비교하는 shallowEqual을 따로 만들었다 (src/utils/equality.js).
export function shallowEqual(a, b) {
if (Object.is(a, b)) return true;
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.is(a[key], b[key])) return false;
}
return true;
}
구현은 했으나 설명이 뜨문뜨문 있어서 main.js에 직접 테스트용 코드를 붙여서 확인해보자.
const [effectValue, setEffectValue] = useState(0);
useEffect(() => {
console.log("useEffect 실행됨! effectValue:", effectValue);
return () => console.log("cleanup 실행됨! 이전 effectValue:", effectValue);
}, [effectValue]);
effectValue를 바꾸는 버튼 하나, 안 바꾸는 버튼( 기존에 만들어놓은 useState로 만든 step 버튼) 하나로 나눠서 테스트했다.

(하나씩 늘어나니까 뿌듯쓰~)
effectValue를 바꾸는 버튼을 눌렀을 때 콘솔:
isSameDeps: false가 나오고, 그 직후에 useEffect 실행됨! 로그가 찍힌다. 의도대로 동작!
effectValue와 무관한 step 버튼을 눌렀을 때 콘솔:
isSameDeps: true가 나오고, useEffect 실행됨! 로그는 안 찍힌다. deps가 같으면 콜백이 스킵된다는 게 확인됐다.
effectValue를 다시 바꿔봤을 때:
직전 콜백이 남긴 cleanup이 먼저 호출되고, 그 다음에 새 콜백이 실행된다. 의도한 순서 그대로! 굿굿
아무래도 렌더링과 콜백 클린업에 대한 알고 있던 개념이 모호했던지 이해하고 그걸 구현하는 것에 다른 hook보다 1.5배 정도 시간이 소요된 것 같다. 😇😇😇😇😇
layout 부분이라던가 life cycle 관련해서 더 지식을 채워야겠다는 생각이 들었다!! 좀만 더 힘내보자! 끗!!!!!