Vanilla JS로 React Hook 구현하기 - useEffect

q1q1·2026년 6월 23일

Mini React

목록 보기
3/6

생각보다 useRef에서는 렌더링에 대한 고려를 많이 하지 않아서 비교적 구현에 많은 어려움은 없었지만,
이번에는 useEffect를 구현하면서 생각보다 많이 헤맨 부분이 많았다. 하하하 😇😇😇 왜 이렇게 헤맸을까,,를 생각해보며 고고!

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]);
  • callback : useEffect 의 첫 번째 인자로 넘기는 함수 전체.
  • cleanup : callback을 실행했을 때 그 안에서 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 콜백 실행
일 것이다.

그럼 이 흐름에 맞춰 구현 가보자고!!

구현 목표

  1. deps가 바뀌었을때만 콜백을 실행한다. (조건부 실행)
  2. 콜백 실행을 렌더링과 분리한다.
  3. 콜백을 실행하기 전, 이전 cleanup을 먼저 호출해서 정리한다. (효과 정리)
  4. deps 비교는 참조가 아니라 값을 기준으로 한다. (얕은 비교)

1. deps가 바뀌었을때만 콜백을 실행하기

useStatestates[], useRefrefs[]와 똑같은 원리로, 렌더 간 정보를 유지할 저장공간이 또 필요했다. 이번엔 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번 목표에서 자세히 풀 예정 😇).

2. 콜백 실행을 렌더링과 분리하기

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.jsrender()에서, 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에 등록만 하는 걸로 바꿔서 해결했다.

3. 콜백을 실행하기 전, 이전 cleanup을 먼저 호출하기

앞에서 본 것처럼 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;
}

그리고 이 cleanuprunPendingEffects()에 등록할 때, 어디서 가져오느냐가 중요했다. 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 실행"은 서로 독립적인 동작이어야 한다는 걸 여기서 깨달았다.


4. deps 비교는 참조가 아니라 값을 기준으로 하기 (얕은 비교)

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 버튼) 하나로 나눠서 테스트했다.


(하나씩 늘어나니까 뿌듯쓰~)

1) deps가 바뀐 경우 -> 재실행되어야 한다.

  • effectValue를 바꾸는 버튼을 눌렀을 때 콘솔:

isSameDeps: false가 나오고, 그 직후에 useEffect 실행됨! 로그가 찍힌다. 의도대로 동작!

2) deps가 안 바뀐 경우 -> 스킵되어야 한다.

  • effectValue와 무관한 step 버튼을 눌렀을 때 콘솔:

isSameDeps: true가 나오고, useEffect 실행됨! 로그는 안 찍힌다. deps가 같으면 콜백이 스킵된다는 게 확인됐다.

3) cleanup이 새 콜백보다 먼저 실행되는지

  • effectValue를 다시 바꿔봤을 때:

직전 콜백이 남긴 cleanup이 먼저 호출되고, 그 다음에 새 콜백이 실행된다. 의도한 순서 그대로! 굿굿

구현하면서 아쉬웠던 부분

  • unmount 시점의 cleanup 미구현
    React에서 cleanup은 effect가 재실행되기 직전뿐 아니라, 컴포넌트가 사라질 때 (unmount)도 한번 더 호출된다. 그렇지만 지금 만들고 있는 프로젝트는 App()이라는 단일 컴포넌트가 사라지지 않는 구조라서, "컴포넌트가 사라진다"는 개념이 없어서 이 부분은 구현하지 못했다. 구조 자체를 한번 lifecycle 관점으로 보완해도 괜찮을 것 같다는 생각을 했다. (to. 미래의 나)

마무리

아무래도 렌더링과 콜백 클린업에 대한 알고 있던 개념이 모호했던지 이해하고 그걸 구현하는 것에 다른 hook보다 1.5배 정도 시간이 소요된 것 같다. 😇😇😇😇😇
layout 부분이라던가 life cycle 관련해서 더 지식을 채워야겠다는 생각이 들었다!! 좀만 더 힘내보자! 끗!!!!!

profile
끄적끄적

0개의 댓글