React|Custom Hooks

useInterval & useTimeout: 리액트의 클로저 함정 탈출하기

1
useInterval & useTimeout: 리액트의 클로저 함정 탈출하기

크리스가 간단한 '카운트다운 타이머'를 만들고 있다.

1초마다 숫자가 1씩 줄어드는 기능이다. 리액트 초보 시절을 벗어난 크리스는 자신 있게 useEffect와 setInterval을 조합했다.

typescript
// ❌ 크리스의 멈춰버린 타이머
function Timer() {
  const [count, setCount] = useState(10);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // 디버깅
      setCount(count - 1);
    }, 1000);

    return () => clearInterval(id);
  }, []); // 마운트 될 때 한 번만 실행

  return <h1>남은 시간: {count}</h1>;
}

기대와 달리 화면의 숫자는 9로 한 번 바뀌더니 영원히 멈춰버렸다. 콘솔을 확인해 보니 10, 10, 10...만 무한히 찍히고 있다.

이것이 바로 리액트 개발자라면 누구나 한 번쯤 겪는 악명 높은 "Stale Closure(상한 클로저)" 문제다.

오늘은 자바스크립트의 클로저 특성과 리액트의 생명주기가 충돌하는 지점을 이해하고, 이를 useRef로 우아하게 해결하는 useInterval 훅을 만들어본다.

1. 문제의 원인: 시간이 멈춘 공간

count가 줄어들지 않을까?

useEffect의 의존성 배열(deps)이 []로 비어있기 때문이다.

  • 컴포넌트가 처음 렌더링 된다 (count = 10).
  • useEffect가 실행되고 setInterval이 등록된다.
  • 이때 등록된 콜백 함수 () => setCount(count - 1)처음 생성된 시점의 환경(Scope)을 기억한다(Closure).
  • 이 환경 속에서 count는 영원히 10이다.
  • 1초 뒤에도, 10초 뒤에도 타이머는 10 - 1, 즉 9를 세팅할 뿐이다.
  • 잘못된 해결책: 의존성 배열에 추가하기

    "그럼 [count]를 의존성에 넣으면 되잖아요?"

    typescript
    useEffect(() => {
      const id = setInterval(...);
      return () => clearInterval(id);
    }, [count]); // count가 바뀔 때마다 재실행

    작동은 한다. 하지만 이것은 매초마다 타이머를 해제(clear)하고 다시 등록(set)하는 셈이다.

    타이머의 정밀도가 떨어지는 것은 물론이고, 만약 인터벌 간격이 짧다면 성능 문제도 생긴다. 우리는 "타이머는 유지하되, 내부 로직만 최신 상태를 알게 하고" 싶다.

    2. 해결책: useRef로 최신 상태 납치하기

    이 문제를 해결하기 위해 리액트 팀의 Dan Abramov가 제안한 패턴이 바로 useInterval이다.

    핵심 아이디어는 "변하는 것(Callback)과 변하지 않는 것(Interval)을 분리하자"는 것이다.

  • setInterval은 한 번만 실행되어야 한다.
  • 하지만 그 안에서 실행될 콜백 함수는 매번 최신 상태를 알아야 한다.
  • useRef는 값이 바뀌어도 리렌더링을 유발하지 않으며, 언제든 current를 통해 최신 값을 꺼내볼 수 있다.
  • useInterval 구현

    typescript
    // useInterval.ts
    import { useEffect, useRef } from 'react';
    
    export function useInterval(callback: () => void, delay: number | null) {
      // 1. 최신 콜백을 저장할 ref 생성
      const savedCallback = useRef(callback);
    
      // 2. 리렌더링 될 때마다 ref를 최신 콜백으로 업데이트
      // (이 과정은 렌더링에 영향을 주지 않는다)
      useEffect(() => {
        savedCallback.current = callback;
      }, [callback]);
    
      // 3. 인터벌 설정
      useEffect(() => {
        // delay가 null이면 타이머 정지 (선언적인 정지 기능)
        if (delay === null) return;
    
        const tick = () => {
          // 타이머는 항상 'ref.current'를 실행한다.
          // ref는 항상 최신 콜백을 담고 있다!
          savedCallback.current();
        };
    
        const id = setInterval(tick, delay);
        return () => clearInterval(id);
      }, [delay]); // delay가 바뀔 때만 타이머 재설정
    }

    이 코드는 일종의 마법이다. setIntervaltick이라는 함수를 계속 호출하지만, tick은 내부적으로 savedCallback.current를 참조한다. 우리는 savedCallback.current만 렌더링 시점에 몰래 바꿔치기해 주면 된다.

    3. 실전 적용: 크리스의 타이머 부활

    이제 크리스는 복잡한 useEffect와 클로저 고민 없이 직관적으로 코드를 짤 수 있다.

    typescript
    // Timer.tsx
    import { useState } from 'react';
    import { useInterval } from './useInterval';
    
    function Timer() {
      const [count, setCount] = useState(10);
      const [isPlaying, setIsPlaying] = useState(true);
    
      // ✅ 마치 setInterval을 선언적으로 쓰는 느낌!
      useInterval(() => {
        setCount(count - 1);
      }, isPlaying ? 1000 : null); // null을 넘기면 일시정지 된다.
    
      return (
        <div>
          <h1>{count}</h1>
          <button onClick={() => setIsPlaying(!isPlaying)}>
            {isPlaying ? '일시정지' : '재개'}
          </button>
        </div>
      );
    }

    보너스 기능으로 delaynull을 넘겨 타이머를 일시정지하는 기능까지 쉽게 구현했다. 코드가 훨씬 읽기 좋아졌다.

    4. 자매품: useTimeout

    같은 원리로 setTimeout을 훅으로 만든 useTimeout도 유용하다.

    일정 시간 뒤에 딱 한 번 실행되어야 하는데, 중간에 조건이 바뀌면 취소하거나 시간을 변경해야 할 때 유용하다.

    typescript
    // useTimeout.ts
    import { useEffect, useRef } from 'react';
    
    export function useTimeout(callback: () => void, delay: number | null) {
      const savedCallback = useRef(callback);
    
      useEffect(() => {
        savedCallback.current = callback;
      }, [callback]);
    
      useEffect(() => {
        if (delay === null) return;
        const id = setTimeout(() => savedCallback.current(), delay);
        return () => clearTimeout(id);
      }, [delay]);
    }

    핵심 정리

  • 문제: useEffect 안에서 setInterval을 쓰면 Stale Closure 때문에 초기 상태값만 계속 참조하는 버그가 발생한다.
  • 해결: useRef를 사용하여 "최신 콜백 함수"를 저장해 두고, 타이머는 항상 ref.current를 호출하게 만든다.
  • useInterval: 선언적인 방식으로 인터벌을 관리할 수 있게 해주는 커스텀 훅이다. delaynull로 설정하여 타이머를 쉽게 멈출 수 있다.
  • 교훈: 리액트의 렌더링 흐름과 자바스크립트의 시간 비동기 흐름이 어긋날 때는 Ref가 그 둘을 이어주는 다리가 된다.
  • 시간을 다루는 법을 익혔으니, 이제 공간(화면)을 다룰 차례다.

    "모바일 화면에서는 사이드바를 숨기고 싶어요."

    CSS 미디어 쿼리(@media)만으로는 자바스크립트 로직(렌더링 여부)을 제어할 수 없다. JS에서 화면 크기 변화를 실시간으로 감지하는 훅을 만들어보자.

    "useMediaQuery & useWindowSize: 자바스크립트로 반응형 디자인 제어하기" 편에서 계속된다.


    🔗 참고 링크

  • Dan Abramov - Making setInterval Declarative
  • usehooks-ts: useInterval
  • 댓글 (0)

    0/1000
    댓글을 불러오는 중...