useEffect cleanup과 타이머 — 의존성 배열이 지우는 예약

만든 것3분조회

개인적으로 만든 React 웹 앱에서, 화면에 별이 순서대로 하나씩 켜지는 인트로 애니메이션을 넣었습니다. setTimeout 으로 별을 하나씩 켜지게 해뒀는데, 정작 실행하면 별이 하나도 안 켜졌습니다. 코드는 맞아 보이는데 아무 일도 안 일어났습니다.

타이머까지 정리한 애니메이션 effect

문제는 useEffect cleanup 이었습니다.

useEffect(() => {
  animRef.current = requestAnimationFrame(draw)
  return () => {
    cancelAnimationFrame(animRef.current)
    timersRef.current.forEach(clearTimeout)   // ← 여기
  }
}, [localPhase])

이 effect 는 캔버스 애니메이션을 시작하고, cleanup 에서 애니메이션 프레임을 취소합니다. 그런데 cleanup 이 타이머(timersRef)까지 같이 지웁니다.

핵심은 의존성 배열 [localPhase] 입니다. localPhase 가 바뀔 때마다 React 는 이전 effect 의 cleanup 을 돌리고 effect 를 다시 실행합니다. 별을 켜는 과정에서 phase 가 바뀌는데, 그때마다 cleanup 이 돌면서 아직 실행도 안 된 타이머들을 전부 clearTimeout 해버립니다.

즉 별을 켜려고 예약한 타이머가, phase 가 바뀌는 순간 다 취소됐습니다. 그래서 별이 안 켜졌습니다. 에러도 안 났습니다 — 타이머가 조용히 사라진 것뿐이었습니다.

cleanup 대상 분리

애니메이션 프레임은 phase 가 바뀌면 취소하는 게 맞습니다. 하지만 타이머는 phase 전환을 넘어서 살아남아야 합니다. 둘을 섞어놨던 게 문제였습니다.

애니메이션 effect 는 프레임만 정리하게 남깁니다.

useEffect(() => {
  animRef.current = requestAnimationFrame(draw)
  // 여기서는 애니메이션 프레임만 취소한다 — 타이머는 phase 전환을 넘어 살아야 한다
  return () => cancelAnimationFrame(animRef.current)
}, [localPhase])

타이머 정리는 컴포넌트가 사라질 때만 하도록 별도 effect 로 뺍니다. 의존성 배열이 비어 있으니([]) 이 cleanup 은 unmount 때 딱 한 번만 돕니다.

// 언마운트 때만 타이머 정리
useEffect(() => {
  return () => { timersRef.current.forEach(clearTimeout) }
}, [])

그리고 새 챌린지를 시작할 때, 이전에 남은 타이머가 있으면 그때 비웁니다.

const startChallenge = () => {
  timersRef.current.forEach(clearTimeout)
  timersRef.current = []
  // ... 별 켜는 타이머 예약
}

이렇게 나누면 타이머의 수명이 명확해집니다. 새 챌린지를 시작할 때와 컴포넌트가 사라질 때만 비우고, phase 전환은 타이머를 안 건드립니다.

effect 분리 기준 — 정리 대상의 수명

useEffect 의 cleanup 은 “이 effect 가 만든 것” 만 정리해야 합니다. 애니메이션 effect 의 cleanup 에 타이머 정리를 끼워 넣은 게 실수였습니다. 그 effect 는 localPhase 마다 다시 도는데, 타이머는 그 주기와 수명이 다릅니다.

의존성 배열이 있는 effect 의 cleanup 은 “그 의존성이 바뀔 때마다” 돈다는 걸 잊기 쉽습니다. 서로 수명이 다른 것들을 한 effect 에 묶으면, 하나의 주기가 다른 것의 수명을 망칩니다. 정리 대상마다 effect 를 나누는 게 안전합니다.

  1. 불러오는 중