발행일

자바스크립트로 useEffect 구현해 보기 — 버그 두 개를 품고 있던 코드

자바스크립트로 useEffect 구현해 보기

useEffect의 deps 비교와 클린업을 바닐라 JS로 흉내 내보자는 스터디였습니다. 당시 코드를 싣고, 개고하며 실제로 돌려보고 찾은 버그 두 개와 고친 버전을 붙였습니다.

1. 당시의 구현

class Component {
  constructor() {
    this.effects = []
    this.cleanupFunctions = []
  }

  useEffect(effect, dependencies) {
    const hasNoDeps = !dependencies
    const prevDeps = this.effects.length ? this.effects.map((e) => e.dependencies) : []
    const hasChangedDeps = prevDeps.some(
      (prevDep, index) => JSON.stringify(prevDep) !== JSON.stringify(dependencies[index])
    )

    if (hasNoDeps || hasChangedDeps) {
      this.cleanupFunctions.forEach((cleanup) => cleanup())
      this.cleanupFunctions = []

      const cleanup = effect()
      if (typeof cleanup === 'function') this.cleanupFunctions.push(cleanup)

      this.effects.push({ effect, dependencies })
    }
  }
}

2. 돌려보니 — 버그 두 개

버그 ① 첫 호출에 effect가 실행되지 않는다

deps를 준 첫 호출을 따라가 봅시다. this.effects가 비어 있으니 prevDeps = [], 빈 배열의 .some()항상 false. hasNoDepsfalse(deps를 줬으니까). 결과: effect()가 실행되지 않습니다. React의 useEffect는 첫 렌더에 무조건 한 번 실행되는데, 이 구현은 정반대예요. 사용 예제에서 update()를 수동으로 불러서 로그가 찍혔기 때문에 이 버그가 가려져 있었습니다.

버그 ② 비교 대상이 어긋나 있다 — 배열 전체 vs 항목 하나

두 번째 호출부터는 반대 문제가 터집니다. prevDepseffects.map(e => e.dependencies) — 즉 "deps 배열들의 배열" 입니다. 그런데 비교식은 prevDep(deps 배열 전체)과 dependencies[index](이번 deps의 항목 하나)를 비교해요:

// 실제로 일어나는 비교
JSON.stringify([1, 2]) !== JSON.stringify(1)   // "[1,2]" !== "1" → 항상 true

배열과 스칼라를 비교하니 deps가 안 바뀌어도 항상 "변경됨" 이 됩니다. 매 호출마다 effect가 재실행되니, deps 비교를 만들었는데 deps 비교가 없는 것과 같은 동작이에요. 게다가 this.effects.push는 호출마다 누적이라 "이전 deps"를 찾는 기준 자체가 없었습니다 — 같은 effect의 이전 호출을 식별할 방법(슬롯)이 없었던 거죠.

두 버그의 뿌리는 같습니다. useState 구현 글에서와 똑같이, 훅의 핵심인 "호출 순서 커서"를 빼먹은 것입니다. 몇 번째 useEffect 호출인지 알아야 그 자리의 이전 deps와 비교할 수 있는데, 그 주소 체계 없이 배열에 push만 하니 비교가 성립할 수 없었어요.

3. 커서 모델로 고친 버전

let effectSlots = []   // 슬롯: { deps, cleanup }
let cursor = 0

function useEffect(effect, deps) {
  const i = cursor++
  const prev = effectSlots[i]

  const changed =
    !prev ||                                   // 첫 렌더 → 무조건 실행 (버그 ① 수정)
    !deps ||                                   // deps 없음 → 매번 실행
    deps.some((d, j) => !Object.is(d, prev.deps[j]))  // 항목별 비교 (버그 ② 수정)

  if (changed) {
    prev?.cleanup?.()                          // 이 슬롯의 이전 클린업만 실행
    const cleanup = effect()
    effectSlots[i] = { deps, cleanup: typeof cleanup === 'function' ? cleanup : undefined }
  }
}

function render(ComponentFn) {
  cursor = 0
  ComponentFn()
}

function unmount() {
  effectSlots.forEach((s) => s.cleanup?.())
  effectSlots = []
}

원본과의 차이가 곧 배운 점입니다:

  • 슬롯 인덱스(i)가 "같은 effect의 이전 호출"을 찾는 주소 — 비교가 비로소 성립합니다.
  • !prev가 첫 렌더 실행을 보장합니다.
  • 비교는 JSON.stringify가 아니라 항목별 Object.is — React의 실제 방식이고, stringify는 함수·순환 참조가 deps에 들어오는 순간 깨집니다.
  • 클린업은 전역 목록이 아니라 자기 슬롯의 것만 실행합니다. 원본은 effect A가 재실행될 때 effect B의 클린업까지 전부 불러버리는 구조였어요.

정리

  • 이 글의 원본 코드는 "클린업과 deps 비교라는 개념"은 담았지만, 실행하면 첫 호출엔 안 돌고 그 뒤엔 매번 도는 — 즉 useEffect의 동작은 하나도 재현하지 못하는 코드였습니다. 스터디 글도 돌려보고 실어야 한다는 걸 이 글이 스스로 증명하고 있었어요.
  • useState와 useEffect 흉내 구현이 같은 지점(커서 부재)에서 같은 방식으로 무너졌다는 게 수확입니다. 훅의 본질이 개별 API가 아니라 "호출 순서를 주소로 쓰는 슬롯 체계"라는 뜻이니까요.