Part 2. 리액트 동작 원리와 상태 관리
2

useEffect의 의존성 배열 거짓말: Stale Closure 문제 해결과 useLayoutEffect가 필요한 순간

useEffect의 의존성 배열 거짓말: Stale Closure 문제 해결과 useLayoutEffect가 필요한 순간

크리스가 만든 타이머 앱이 이상하다. 1초마다 숫자가 올라가야 하는데, 1에서 멈춰버리거나, 갑자기 숫자가 널뛰기를 한다.

// ❌ 버그가 있는 타이머
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // 계속 0만 출력된다!
      setCount(count + 1);
    }, 1000);

    return () => clearInterval(id);
  }, []); // 의존성 배열이 비어있다 (문제의 원인)
  
  return <div>{count}</div>;
}

VS Code의 린터(Linter)는 노란 줄을 띄우며 경고한다.

"React Hook useEffect has a missing dependency: 'count'. Either include it or remove the dependency array."

하지만 크리스는 생각한다.

"아니, count를 넣으면 useEffect가 계속 재실행돼서 타이머가 초기화되잖아? 그냥 무시해야지."

이 순간이 바로 리액트 초보자가 가장 많이 빠지는 함정, 의존성 배열의 거짓말Stale Closure(상한 클로저) 문제가 발생하는 지점이다.

1. 거짓말의 대가: Stale Closure

위 코드에서 setInterval 내부의 화살표 함수는 컴포넌트가 처음 렌더링 될 때 생성되었다.

이때의 count 값은 0이다. 이 함수는 영원히 count가 0인 세계(클로저)에 갇혀 있다.

  • 첫 렌더링 (count = 0).
  • useEffect 실행. setInterval 시작.
  • 1초 후, setCount(0 + 1) 호출. -> count1이 됨.
  • 리렌더링 (count = 1).
  • 하지만 useEffect는 의존성 배열([]) 때문에 다시 실행되지 않음.
  • setInterval은 여전히 옛날 함수(0을 기억하는 함수)를 계속 돌린다.
  • 1초 후, 또 setCount(0 + 1) 호출. -> count는 여전히 1.
  • 이것이 바로 Stale Closure다. 오래된(Stale) 변수를 참조하고 있는 것이다.

    해결책 1: 의존성 배열을 사실대로 말하기

    린터의 말을 잘 들으면 된다. 하지만 setInterval이 매번 초기화되는 문제가 생긴다.

    // ⚠️ 동작은 하지만 비효율적임
    useEffect(() => {
      const id = setInterval(() => {
        setCount(count + 1);
      }, 1000);
      return () => clearInterval(id);
    }, [count]); // count가 바뀔 때마다 타이머 해제 -> 재설정 반복

    해결책 2: 함수형 업데이트 (Best Practice)

    count 값을 직접 읽지 않고, 리액트에게 "현재 값에서 1 더해줘"라고 명령만 내리면 된다.

    // ✅ 의존성 배열도 깔끔하고, 타이머도 유지됨
    useEffect(() => {
      const id = setInterval(() => {
        setCount(prev => prev + 1); // count를 의존하지 않음!
      }, 1000);
      return () => clearInterval(id);
    }, []);

    2. useLayoutEffect가 필요한 순간

    보통의 사이드 이펙트는 useEffect로 충분하다. 하지만 화면 깜빡임이 발생하는 경우가 있다.

    상황: 툴팁의 위치를 계산해야 한다. 툴팁을 일단 렌더링하고(useEffect), 그 높이를 잰 다음, 위치를 조정(setState)한다.

  • Render: 툴팁이 (0, 0) 위치에 렌더링 됨.
  • Paint: 사용자가 (0, 0)에 있는 툴팁을 봄. (깜빡!)
  • useEffect: 높이 계산 후 (100, 200)으로 이동시킴.
  • Repaint: 툴팁이 제자리로 이동함.
  • 사용자는 툴팁이 순간이동 하는 것을 목격하게 된다. 이때 필요한 것이 useLayoutEffect다.

  • useEffect: Render -> Paint -> Effect 실행 (비동기)
  • useLayoutEffect: Render -> Effect 실행 -> Paint (동기)
  • useLayoutEffect 내부의 코드는 브라우저가 화면을 그리기(Paint) 직전에 동기적으로 실행된다. 여기서 DOM을 조작하면, 사용자는 최종적으로 계산된(이동된) 화면만 보게 된다.

    // tooltip.tsx
    useLayoutEffect(() => {
      const { height } = ref.current.getBoundingClientRect();
      setY(height + 10);
      // 이 계산이 끝날 때까지 브라우저는 화면을 그리지 않고 기다린다.
    }, []);

    핵심 정리

  • Stale Closure: useEffect 안에서 외부 변수를 사용할 때는 반드시 의존성 배열에 넣어야 한다. 안 그러면 옛날 값을 기억한다.
  • 함수형 업데이트: setState(prev => prev + 1)을 사용하면 의존성 배열에서 상태를 제거할 수 있어 더 안전하다.
  • useLayoutEffect: DOM 위치 측정이나 레이아웃 변경으로 인한 깜빡임(Flicker)을 막고 싶을 때 사용한다. 그 외에는 useEffect를 쓰는 것이 성능상 좋다.
  • 이제 훅의 함정을 피하는 법을 배웠다. 그런데 상태가 단순히 숫자 하나가 아니라, 복잡한 객체나 여러 개의 값이 얽혀 있다면? useState만으로는 감당하기 힘들 때가 온다.

    "useState vs useReducer: 상태가 복잡해지는 순간 선택해야 할 도구" 편에서 계속된다.


    🔗 참고 링크

  • useEffect 완벽 가이드 (Overreacted)
  • React Docs - useEffect
  • React Docs - useLayoutEffect