useEffect안의setInterval이 항상0만 찍는 경험, 한 번쯤 있을 것이다. 버그가 아니라 클로저가 정상 동작한 결과다. 이 글은 그 원리를 클로저 기초부터 짚는다.
1. 클로저는 "값"이 아니라 "변수"를 기억한다
클로저는 함수 + 그 함수가 선언된 렉시컬 환경의 조합이다. 핵심은 하나다 — 클로저는 그 순간의 값을 복사하는 게 아니라 변수 자체를 참조한다.
function counter() {
let count = 0;
return {
increment: () => ++count,
get: () => count,
};
}
const c = counter();
c.increment();
c.increment();
c.get(); // 2 — increment와 get이 '같은 count 변수'를 공유
counter()는 이미 끝났지만 count는 살아남는다. 반환된 함수들이 그 변수를 참조하고 있어 GC가 회수하지 못하기 때문이다. "같은 변수를 공유한다" — 이 성질이 뒤에서 다룰 모든 함정의 뿌리다.
2. 클래식 함정: var 반복문
이 성질을 가장 잘 보여주는 예제다.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 출력: 3, 3, 3
var는 함수 스코프라 i가 하나뿐이다. 세 콜백이 모두 같은 i를 참조하고, 0초 뒤 콜백이 실행될 때 루프는 이미 끝나 i는 3이다. 각 콜백이 "그때의 값 0, 1, 2"를 복사한 게 아니라, "하나의 변수 i"를 가리킨다는 게 핵심이다.
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 출력: 0, 1, 2
let은 블록 스코프라 매 반복마다 새로운 i 가 만들어지고, 각 콜백이 서로 다른 변수를 캡처한다. 이 "반복마다 독립된 변수"라는 그림을, React는 "렌더마다 독립된 state"로 똑같이 재현한다.
3. React의 멘탈 모델 — 매 렌더는 독립된 클로저다
함수 컴포넌트는 리렌더될 때마다 처음부터 다시 실행된다. 그래서 매 렌더는 자기만의 count 값과, 그 값을 캡처한 자기만의 이벤트 핸들러(클로저) 를 가진다.
function Counter() {
const [count, setCount] = useState(0);
// 이 onClick은 '이번 렌더의 count'를 캡처한 클로저다.
// Render #0의 onClick은 영원히 count=0을 본다.
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
count는 변하는 변수가 아니다. 각 렌더마다 고정된 상수다. Render #0의 세계에서 count는 영원히 0이고, Render #1의 세계에서는 영원히 1이다. 보통은 이게 문제가 되지 않는다 — 클릭하면 새 렌더가 일어나고, 화면은 항상 최신 렌더의 클로저를 쓰기 때문이다.
문제는 옛 렌더의 클로저가 나중까지 살아서 실행될 때 생긴다.
4. stale closure 재현 — setInterval
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 항상 0 ❌
setCount(count + 1); // 항상 0 + 1 = 1 ❌ (1에서 멈춤)
}, 1000);
return () => clearInterval(id);
}, []); // 의존성 빈 배열
return <p>{count}</p>;
}
화면의 숫자는 0 → 1까지만 가고 멈춘다. 왜일까?
useEffect의 의존성이[]라 첫 렌더(Render #0)에서 딱 한 번만 실행된다.- 그 안의
setInterval콜백은 Render #0의 클로저 라count를 영원히0으로 본다. - 1초마다
setCount(0 + 1)→ 항상 1. 새 렌더가 일어나도 interval 콜백은 갱신되지 않는다(effect가 다시 안 돌므로).
즉 오래 살아있는 콜백 + 옛 렌더의 클로저 조합이 stale closure다. 코드가 "틀린" 게 아니라, 첫 렌더의 세계에 갇힌 것이다.
5. 해결책
① 함수형 업데이트 — 가장 깔끔
setCount에 값 대신 함수를 넘기면, React가 항상 최신 state를 인자로 준다. 그러면 클로저가 잡아둔 count를 아예 안 쓴다.
useEffect(() => {
const id = setInterval(() => {
setCount((prev) => prev + 1); // 클로저의 count에 의존하지 않음 ✅
}, 1000);
return () => clearInterval(id);
}, []);
interval을 재생성하지 않으면서도 항상 옳다. "이전 state로부터 다음 state를 계산" 하는 경우의 정석이다.
② 의존성 배열에 추가 — 정직하지만 비용
count를 의존성에 넣으면 값이 바뀔 때마다 effect가 새 클로저로 다시 실행된다.
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, [count]); // count마다 interval을 정리하고 새로 만든다
항상 최신 count를 보지만, 매번 interval을 해제하고 다시 만드는 비용이 있다. lint(exhaustive-deps)가 권하는 정공법이며, effect가 가벼우면 이걸로 충분하다.
③ useRef — 최신 값을 담는 가변 상자
state 외의 최신 값을 콜백에서 읽어야 할 때 쓴다. ref는 렌더와 무관하게 항상 같은 객체라, 클로저가 잡아도 stale 되지 않는다.
const countRef = useRef(count);
countRef.current = count; // 매 렌더마다 최신 값 동기화
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current); // 항상 최신 ✅
}, 1000);
return () => clearInterval(id);
}, []);
다만 ref는 변경해도 리렌더를 일으키지 않으므로, 렌더에 반영돼야 하는 값은 state, 콜백에서 읽기만 하는 최신 값은 ref로 역할을 나눈다.
④ useReducer — 로직을 클로저 밖으로
다음 state가 이전 state와 액션으로 결정되는 복잡한 경우, dispatch는 항상 안정적이라 stale 걱정이 없다.
const [count, dispatch] = useReducer((c) => c + 1, 0);
useEffect(() => {
const id = setInterval(() => dispatch(), 1000); // dispatch는 stale 되지 않음 ✅
return () => clearInterval(id);
}, []);
6. interval만의 문제가 아니다
같은 함정이 오래 사는 모든 콜백에서 반복된다.
// 디바운스된 핸들러 — timer 안의 콜백이 옛 value를 캡처
const debouncedSave = useMemo(
() => debounce(() => save(value), 500), // value가 stale
[], // ❌ value를 안 넣으면 첫 렌더의 value만 저장
);
// 비동기 후 setState — await 사이에 값이 바뀌어도 옛 클로저
async function handleSubmit() {
await api.post(/* ... */);
setItems([...items, newItem]); // items가 stale일 수 있음
// → setItems((prev) => [...prev, newItem]) 로 해결
}
원리는 동일하다. "이 콜백은 어느 렌더의 클로저인가? 그 사이 값이 바뀌는가?" 를 물으면 답이 나온다.
7. 정리 — 판단 체크리스트
| 상황 | 해법 |
|---|---|
| 이전 state로 다음 state 계산 | 함수형 업데이트 setX(prev => …) |
| 최신 값을 effect가 의존 | 의존성 배열에 추가 (effect가 가벼울 때) |
| 콜백에서 최신 값을 읽기만 | useRef |
| 복잡한 상태 전이 | useReducer |
한 줄 정리 — 클로저는 값을 복사하지 않고 변수를 참조한다. React에서 매 렌더는 자기만의 state와 클로저를 가지며, 오래 사는 콜백이 옛 렌더의 클로저를 붙들면 stale closure가 된다. "이 콜백은 어느 렌더의 클로저인가"를 물으면 원인과 해법이 함께 보인다.