stale closure problem

hong·2026년 7월 2일

javascript

목록 보기
14/14
post-thumbnail

시작하기 전에

클로저는 함수가 만들어질 때 자기 바깥에 있던 변수를 기억하는 것이다.

예시 :

function outer() {
  const name = ‘뽀야미’;

  function inner() {
    console.log(name);
  }

  return inner;
}

const fn = outer();

fn(); // 뽀야미

outer()는 이미 실행이 끝났으므로 outer 안에 있던 name도 사라질거같지만, fn()을 실행하면 여전히 ‘뽀야미’가 출력된다.
좀 더 풀어 말하면,

function inner() {
  console.log(name);
}

inner 함수는 자기 내부에 name이 없기 때문에, 자신이 만들어진 렉시컬 스코프인 outer의 환경에서 name을 참조한다. (outer 실행이 끝난 뒤에도 inner가 그 환경을 참조하고 있기 때문에 name을 계속 사용할 수 있다.)

(*렉시컬 스코프 : 함수가 어디서 호출되었는지가 아니라, 어디서 작성되었는지에 따라 참조할 수 있는 변수 범위가 결정되는 규칙)

function outer() {
  const name = ‘뽀야미’;
}

inner는 아, 내 바깥에 name이라는 변수가 있네? 하고 그걸 기억한다. 그래서 나중에 outer 함수 실행이 끝난 뒤에도 inner는 name을 계속 사용할 수 있다.
즉, 함수가 태어날 때 주변에 있던 정보를 기억하고 있는 것이다. 여기서 중요한 것은, 클로저는 값을 기억한다기보다 바깥 변수에 접근할 수 있는 연결을 유지하는 쪽에 가깝다는 것이다.

클로저를 이용하면 상태를 숨겨서 관리할 수도 있다.

function createBankAccount(initialMoney) {
  let balance = initialMoney;

  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    },
  };
}

const account = createBankAccount(1000);

account.deposit(500); // 1500
account.withdraw(300); // 1200
account.getBalance(); // 1200

여기서 balance는 바깥에서 직접 못 건드린다.

account.balance; // undefined

개념

함수가 예전 값을 기억한 채로 실행돼서 최신 상태값을 못 보는 문제.
특히 리액트에서 useEffect, setInterval, 이벤트 핸들러, 비동기 콜백 쓸 때 자주 나온다.

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count);
      setCount(count + 1);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return <div>{count}</div>;
}

이런 코드가 있을 때 1초마다 count가 올라가고, console.log(count)도 최신 값을 찍을 것 같지만, 실제로는 console.log(count)가 계속 0만 찍힐 수 있다.

왜냐면 useEffect가 처음 실행될 때 만들어진 setInterval 콜백은 첫 번째 렌더링의 count 값을 참조하기 때문이다.
이후 state가 바뀌어 컴포넌트가 다시 렌더링되어도, 기존 interval 콜백은 새 렌더링의 count가 아니라 처음 렌더링의 count를 계속 바라본다.
그러니 내부적으로는 아래와 같이 동작한다.

// 처음 렌더링 시점 count = 0
const timer = setInterval(() => {
  console.log(0);
  setCount(0 + 1);
}, 1000);

해결방법

함수형 업데이트

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setCount((prev) => {
        console.log(prev);
        return prev + 1;
      });
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return <div>{count}</div>;
}

prev는 React가 넘겨주는 업데이트 직전의 최신 상태값이다. 따라서 클로저가 오래됐더라도 prev를 기준으로 안전하게 다음 상태를 계산할 수 있다.

dependency에 count 넣기

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count);
      setCount(count + 1);
    }, 1000);

    return () => clearInterval(timer);
  }, [count]);

  return <div>{count}</div>;
}

이 방식도 stale closure 문제는 해결된다. count가 바뀔 때마다 effect cleanup이 실행되어 기존 interval을 제거하고, 최신 count를 참조하는 새 interval을 만든다. 다만 interval을 계속 재등록하는 구조라, 단순 카운터에서는 함수형 업데이트가 더 자연스럽다.

useRef 사용

function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() => {
    countRef.current = count;
  }, [count]);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(countRef.current);
      setCount((prev) => prev + 1);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return <div>{count}</div>;
}

useRef는 값이 바뀌어도 컴포넌트를 다시 렌더링하지 않고, 같은 ref 객체를 계속 유지한다. 그래서 오래 살아있는 콜백 안에서도 countRef.current를 통해 최신 count 값을 읽을 수 있다.

예시

requestAnimationFrame에서의 stale closure

참고 : https://handhand.tistory.com/entry/React-Stale-Closure-Problem

requestAnimationFrame은 애니메이션 콜백 예약 API이다. 부드러운 애니메이션을 만들 때 setInterval보다 자주 쓰인다.
목표 숫자에 도달하지 않으면 다시 requestAnimationFrame(increaseNumberAnimation)를 호출하는 구조를 사용했을 때, animationId를 useRef에 저장해서 정리하는 패턴을 이용했다.

드래그 이벤트 리스너에서의 stale closure

참고 : https://velog.io/@jsj9620/React%EC%9D%98-%EC%98%81%EC%9B%90%ED%95%9C-%EC%88%99%EC%A0%9C-Stale-Closure-%EC%99%84%EB%B2%BD-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0

document.addEventListener에 등록된 함수는 리액트 바깥, 즉 브라우저 DOM 쪽에 저장된다.
브라우저는 리액트가 리렌더링되든 말든 관심없고, 처음 전달받은 함수 주소를 계속 실행한다.
그래서 리액트쪽 state는 계속 바뀌는데, DOM 이벤트 리스너는 첫 렌더링 시점의 함수에 갇히는 문제가 생길 수 있다.

이 글의 예시는 mousemove 이벤트에서 x < 300이면 계속 움직이고, 300px을 넘으면 멈추게 하고 싶은 코드가 나오는데, 이벤트 리스너가 처음의 x = 0만 기억해서 조건문이 계속 참이 돼버리는 상황이 생긴다.
setX(prev => prev + e.movementX) 처럼 함수형 업데이트를 써도, 조건문 if (x < 300) 의 x는 여전히 stale 할 수 있다.

이를 해결하기 위해 의존성 배열에 x를 넣으면, x가 바뀔 때마다 removeEventListener하고 다시 addEventListener를 해야한다.
드래그처럼 매우 자주 발생하는 이벤트에서는 이게 성능상 부담이 될 수 있어서, 이런 경우엔 useRef로 최신 값을 읽는 방식이 더 낫다.

useCallback, 비동기 함수, subscription에서의 stale closure

참고 : https://dev.to/wildboar_developer/understanding-stale-closure-in-react-a-common-pitfall-and-how-to-avoid-it-5dih

stale closure는 useEffect 뿐만 아니라 useCallback 안에서도 생길 수 있다.

const [text, setText] = useState('');

const handleLog = useCallback(() => {
  console.log(text);
}, []);

이러면 text는 바뀌어도 handleLog는 처음 렌더링 때의 text를 기억할 수 있다.

const handleLog = useCallback(() => {
  console.log(text);
}, [text]);

이렇게 하면 해결된다.
(다만 dependency에 text를 넣으면 text가 바뀔 때마다 handleLog도 새로 생성된다. 대부분의 경우 문제 없지만, 함수 참조 안정성이 중요한 경우에는 useRef/useEvent/useEffectEvent 같은 패턴을 고려할 수 있다.)

비동기 함수에서도 stale closure가 생길 수 있다.

const handleSubmit = async () => {
  await delay(1000);
  console.log(value);
};

함수가 실행된 시점의 value와, 1초 뒤 실제로 로그를 찍는 시점의 최신 value가 다를 수 있다.
이유는, async 함수 안의 value는 await 이후에도 자동으로 최신 값으로 갱신되지 않는다. 함수가 호출된 렌더링 시점의 value를 계속 참조하는 것이다.
따라서 사용자가 함수가 실행 된 뒤 시간이 흐르는 사이 input을 바꾸거나 state가 바뀌었을 때, 기대한 최신값이 출력되지 않을 수 있다

subscription에서도 stale closure가 발생할 수 있다. 리액트 바깥에 콜백을 맡겨두는 구조라 stale closure가 생기기 쉽다.

(* subscription : 외부 변화가 생기면 알려달라고 구독해두는 것. WebSocket 메시지 수신 / Firebase 실시간 데이터 / 외부 store 구독 같은 것들이 해당.)

profile
프론트엔드 개발을 하고 있습니다 ⌨️

2개의 댓글

comment-user-thumbnail
2026년 7월 2일

화이팅입니다~

1개의 답글