리액트에서 `Stale Closure` 는 잘못 이해되고 있다

이동규 (Justin)·2025년 3월 3일

이것 뭐에요~?

목록 보기
2/3
post-thumbnail

구글에서 stale closure 또는 react stale closure 를 검색하면 등장하는 많은 예제들은 대부분 아래와 비슷한 예제를 제공합니다.

function App () {
	const [count, setCount] = useState(0);
    
    function delayedIncrement () {
      setTimeout(() => setCount(count + 1), 1000);
    }
    
    return <div>
      <div>{count}</div>
      
      <button onClick={delayedIncrement}>더하기</button>
    </div>
}

이 예제를 실행했을 때 발생하는 문제는 버튼을 연속해서 눌렀을 때, 1초 안에 눌린 여러번의 클릭으로 발생한 setTimeout의 콜백이 참조하는 count가 모두 '0' 이라는 것입니다.

뭐 해결책은 간단합니다. 하지만 제가 알고싶은건

왜 0 인가?

입니다. 만약 제가 나중에 면접관이 된다면 이걸 물어보고 싶을 정도입니다.

왜 0일까요?

많은 스택오버플로우 답변들과 미디움 조각글들은 이것을

이건 'stale closure' 라고 하는건데, 클로저가 특정 시점의 값을 캡쳐하기 때문이야

라고 답변하더라고요. 그런데 이것은 틀린 설명이거나 오해를 불러일으키는 설명이라고 생각합니다. 애초에 'stale closure' 라는 이름이 이런 오해를 불러일으키는 것 같기는 합니다.

이런 일이 일어나는 이유를 조금 파헤쳐보기 위해, 가상의 useState와 그것을 사용하는 컴포넌트를 구현해보겠습니다.

function useState (defaultValue) {
  let state = defaultValue;

  const setState = (newValue) => {
    state = newValue;
  };
  
  return [state, setState];
}

(function AgeComponent () {
  const [age, setAge] = useState(34);
  
  const handleClick = () => {
    console.log('Before update: ', age);
    setState(35);
    console.log('After update: ', age);
  };
  
  return <div onClick={handleClick}>나이먹기</div>;
})();

버튼을 누르면 나이를 한 살 더 먹습니다. 그런데 버튼을 눌렀을 때 두 개의 로그에 모두 34가 찍힌다는 것을 발견할 수 있습니다. 이것의 원인과 첫번째 예제의 실행결과의 원인이 동일합니다.

먼저 useState의 구현을 보면 너무나도 간단합니다. 그런데 우리가(사실 제가) 간과하는 부분이 있다면 바로 state가 생성되는 부분입니다.

// 이 코드는,
const [age, setAge] = useState(34);

// 이 코드와 동일하다는 사실 말입니다.
const state = useState(34);
const age = state[0];
const setAge = state[1];

당연한거 아냐? 라고 생각하실지 모르겠지만 클로저가 아니라 바로 이 부분 때문에 바로 문제가 발생합니다. 훨씬 간단하지만 조금 다른 예제로 바꿔보면 바로 이해가 될겁니다.

function getValue () {
  const originalValue = 'A';
  
  setTimeout(() => {
    console.log('log original: ', originalValue)
  }, 1000);
  
  return originalValue;
}

let myValue = getValue();

myValue = 'B';

console.log('log changed: ', myValue);

이 예제를 보고 originalValue가 'B' 로 바뀔거라고 생각하는 분은 거의 없을거라고 생각합니다. 왜냐하면 originalValue는 getValue라는 함수 스코프의 변수이고, myValue는 바깥 스코프의 완전히 새로운 변수로 만들어졌고 거기에 그저 originalValue가 가지고 있던 값을 할당받은 것일 뿐이기 때문이죠.

이 사실을 가지고 age 예제로 돌아가 생각해보면 이제 확실해집니다. useState 내부에서 관리되던 state의 값은 useState 가 호출되면서 state와 관계 없이 바깥 스코프에서 새롭게 생성된 변수 age에 할당되었고, 이후에 setState를 통해 내부 state를 바꿔도 age의 값은 변하지 않겠죠!

이해되시나요? 'stale closure' 라고 불리는 문제는 사실 클로저와는 관계가 없고, 클로저가 특정 시점의 값을 캡쳐한다는 설명은 완벽히 틀린 설명입니다. 클로저는 자신이 생성된 환경과 그 환경에 속해진 변수나 함수를 참조할 수 있을 뿐, 특정한 값을 기억하지는 않습니다. 오히려 변수의 값이 변할 수 있음을 가정하고 그 값을 안전하게 참조하기 위해 사용됩니다.

살펴보았듯 stale closure 라는 용어로 설명되던 문제는 사실 setState를 통해 업데이트되는 state와 전혀 다른 레퍼런스를 가진 변수를 참고하는 것에서 오는 문제였던 것입니다.


아래의 비디오의 내용을 크게 참고했습니다만 나름대로 이해를 위한 조사와 코드스니펫을 추가했습니다. 한 때 굉장히 의뭉스러웠던 내용이라서 비디오에서 힌트를 받고 완벽히 이해하고 설명하기 위해 노력해 보았습니다.

profile
Visual Programming is in Progress..

0개의 댓글