블로그 목록

클로저와 stale closure — React 훅이 옛 값에 갇히는 이유

2026-06-249 min read
JavaScriptClosureReactHooksuseEffect

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 값과, 그 값을 캡처한 자기만의 이벤트 핸들러(클로저) 를 가진다.

리렌더마다 — 새로운 count, 새로운 클로저Render #0count = 0이 렌더의 onClick 클로저() => setCount(0 + 1 )Render #1count = 1이 렌더의 onClick 클로저() => setCount(1 + 1 )Render #2● 현재 화면count = 2이 렌더의 onClick 클로저() => setCount(2 + 1 )리렌더리렌더옛 렌더의 클로저가 나중에 실행되면 → 그 렌더 시점의 count(옛 값)를 본다 = stale closure
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가 된다. "이 콜백은 어느 렌더의 클로저인가"를 물으면 원인과 해법이 함께 보인다.