[배우는 중] 화면 숫자는 3인데 콘솔엔 0만 찍혔다 — 오래된 클로저
setInterval을 useEffect에 넣었더니 state가 바뀌어도 콜백은 계속 옛날 값만 봤다. "오래된 클로저(stale closure)"에 데이고, 왜 그런지와 빠져나온 길을 남긴다.
배우는 중(Learning log) — 완성된 정리가 아니라, 혼자 공부하다 막힌 지점과 그걸 넘은 과정을 그대로 남기는 시리즈입니다. 지금 제 이해라서 틀릴 수 있고, 나중에 알게 되면 정정 노트를 답니다.
1초마다 카운트를 콘솔에 찍는 타이머 하나.
별거 아니라고 생각했어요.
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => console.log(count), 1000);
return () => clearInterval(id);
}, []);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
버튼을 세 번 눌렀어요.
화면 숫자는 3.
그런데 콘솔은 1초마다 계속 0, 0, 0…
분명 count는 바뀌었는데, setInterval 안의 count만 시간이 멈춘 것 같았어요.
이런 버그가 제일 곤란해요.
화면은 멀쩡하니까, 뭐가 틀렸는지 눈에 안 보이거든요.
한참 뒤에야 이유를 알았습니다.
React 함수 컴포넌트는 렌더될 때마다 함수 전체가 처음부터 다시 실행돼요.
그 순간의 count 값이 그 실행 안에 박제되고요.
useEffect에 넘긴 콜백도 첫 렌더 때의 count(=0)를 붙잡은 채, 빈 배열 [] 때문에 다시 만들어지지도 않아요.
그 콜백은 영원히 첫 렌더의 세계에 삽니다.
사진 같은 거예요.
찍는 순간 인화돼서, 실제 인물이 나이를 먹어도 사진 속 얼굴은 그대로인.
이걸 오래된 클로저(stale closure)라고 부르더라고요.
빠져나오는 길은 세 가지였어요.
하나는 의존성 배열에 count를 넣는 것.
count가 바뀔 때마다 effect가 새로 걸리며 콜백도 최신으로 갱신돼요.
(ESLint의 react-hooks/exhaustive-deps가 이걸 잔소리해줍니다.)
아니면 값을 내가 읽지 않는 것.
setCount(c => c + 1);
“이전 값에 1 더해줘”라고 React에 맡기면, 클로저에 붙잡힌 옛 count가 끼어들 자리가 없어요.
마지막은 useRef에 최신 값을 담아 .current로 읽는 것.
ref는 렌더와 상관없이 같은 상자를 가리키니까요.
돌아보니 제 착각은 단순했어요.
”참조하면 당연히 최신 값이겠지.”
함수 안에 잡힌 변수는 그 함수가 만들어진 순간의 값인데 말이죠.
리렌더는 매번 새 함수를 만들 뿐이고요.
예전에 캡처해둔 스크린샷을 붙여놓고 “왜 실시간이 아니지?”
하던 거랑 비슷했어요.
useEffectEvent라는 정식 해법이 있다던데, 그건 아직 안 써봤어요.
혹시 state는 바뀌었는데 콜백 안에서만 옛날 값이면 — 그 콜백의 의존성 배열부터 보세요.
댓글