
함수가 자신이 정의되었을 때의 외부 변수 값(환경)을 기억하는 클로저의 본질적인 특성 때문에 발생
쉽게 말해, 클로저가 기억하고 있는 외부 변수의 값이, 클로저가 나중에 실행되는 시점의 최신 값이 아니라, 과거 시점의 값일 때 그 클로저를 Stale Closure라고 부른다.
JS의 클로저는 함수가 선언될 때의 렉시컬 환경을 기억한다. React의 함수형 컴포넌트는 렌더링될 때마다 컴포넌트 내부의 함수들이 다시 정의되는데, 만약 특정 함수(클로저)가 재생성되지 않고 이전 렌더링 시점의 메모리 환경을 유지하고 있다면, 그 함수는 이전 시점의 변수(stale data)를 참조하게 된다.
import { useState, useEffect } from 'react';
const StaleClosureExample = () => {
const [count, setCount] = useState<number>(0);
useEffect(() => {
// 이 함수(클로저)는 컴포넌트가 처음 마운트될 때(count가 0일 때) 딱 한 번 생성
const timer = setInterval(() => {
console.log(`Current Count: ${count}`);
// 문제 발생: 여기서 참조하는 count는 영원히 0
}, 1000);
return () => clearInterval(timer);
}, []); // 의존성 배열이 비어있음 -> 이 effect는 재실행되지 않음
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increase</button>
</div>
);
};
count는 0이다. useEffect가 실행되고 setInterval의 콜백 함수(클로저)가 생성된다. 이 클로저는 count = 0인 환경을 캡처한다.setCount가 호출되어 리렌더링이 발생하고 화면상 count는 1, 2로 증가한다.setInterval 내부 함수가 실행된다. 하지만 이 함수는 처음 생성된 그 함수 그대로다.Current Count: 0만 찍힌다. 이것이 Stale Closure다.단순히 useEffect가 count가 바뀔 때마다 함수를 새로 생성하도록 한다.
useEffect(() => {
const timer = setInterval(() => {
console.log(`Current Count: ${count}`);
}, 1000);
return () => clearInterval(timer);
}, [count]); // count가 변할 때마다 effect 재실행 -> 최신 count를 아는 새 클로저 생성
count가 변할 때마다 타이머가 해제(clearInterval)되고 다시 설정(setInterval)되므로 타이머 주기가 불안정해질 수 있다.
특히 이벤트 리스너를 활용해서 특정 컴포넌트를 드래그하는 예시였다면 이벤트 리스너가 해제되고 등록되는 과정이 수십차례는 반복되므로 오버헤드가 커진다. 좋지 않은 해결법이다.
setState에 값을 직접 전달하는 대신, 최신 상태를 인자로 받는 함수를 전달한다.
useEffect(() => {
const timer = setInterval(() => {
setCount((prev) => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
setCount(count + 1) 과 같은 일반 업데이트는 setCount를 호출하는 순간에 JS 엔진이 괄호 안의 count + 1을 먼저 계산한다. 이 코드가 Stale Closure 내부에 있다면, 여기서 참조하는 count는 과거의 값이다. 즉, 리액트에게는 영원히 setCount(1) 이라는 명령만 전달된다.
setCount((prev) => prev + 1) 과 같이 함수형 업데이트를 하면 setCount를 호출할 때 계산하지 않는다. 리액트가 나중에 상태를 업데이트할 때 전달받은 콜백을 실행해서 값을 계산하도록 콜백 함수 자체를 넘긴다. 이렇게 하면 리액트는 내부적으로 상태를 업데이트하는 시점에 자신이 가지고 있는 가장 최신 상태 값을 이 콜백 함수의 인자(prev)로 넣어 실행한다.
즉, 함수형 업데이트는 클로저가 캡처한 외부 변수(count)에 의존하지 않기 때문에 Stale Closure를 피할 수 있다. 업데이트 로직이 외부 변수가 아닌 인자로 들어오는 값만 믿으면 된다.
useRef는 current 프로퍼티에 값을 저장하며, 이 객체 자체의 참조는 변경되지 않지만 내부 값은 언제든 변경 가능하다.
클로저는 ref 객체 자체를 캡처하고, 실행 시점에 ref.current를 읽으면 항상 최신 값을 가져올 수 있다.
const [count, setCount] = useState<number>(0);
const countRef = useRef<number>(count);
// count가 변할 때마다 ref 동기화
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const timer = setInterval(() => {
// ref.current는 항상 최신 값을 가리킴
console.log(`Current Count: ${countRef.current}`);
}, 1000);
return () => clearInterval(timer);
}, []); // 타이머를 재시작하지 않아도 최신 값 접근 가능
특정 좌표에 도달하면 드래그를 멈추는 기능을 예시로 들어본다.
Stale Closure로 일어나는 문제를 가장 직관적으로 이해할 수 있는 예제이기도 하다.
이 코드는 박스가 X 좌표 300px을 넘어가면 드래그가 멈춰야 하지만, 멈추지 않고 계속 움직인다.
import { useState, useEffect } from 'react';
const StaleDragExample = () => {
const [x, setX] = useState(0);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
// ❌ 문제 발생: 이 함수는 컴포넌트가 처음 마운트될 때 생성
// 당시의 x 값인 '0'을 영원히 기억(Capture)
console.log(`현재 x값 감지: ${x}`); // 드래그를 해도 계속 0만 출력됨
// x가 300을 넘으면 멈추라는 로직이 절대 동작하지 않음 (0 < 300 이므로 항상 참)
if (x < 300) {
setX((prev) => prev + e.movementX); // 함수형 업데이트로 이동은 되지만...
}
};
// 마운트 시 단 한 번만 리스너 등록
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
};
}, []); // ⚠️ 의존성 배열이 비어있음 -> handleMouseMove가 갱신되지 않음
return (
<div
style={{
transform: `translateX(${x}px)`,
width: '100px',
height: '100px',
background: 'tomato'
}}
>
{x}px
</div>
);
};
export default StaleDragExample;
useEffect는 의존성 배열이 비어있으므로 컴포넌트가 처음 생성될 때 딱 한 번 실행된다.handleMouseMove 함수가 만들어지는데, 이 함수 내부의 x는 초기값인 0을 참조하는 상태로 닫혀버린다(클로저).setX를 통해 화면상의 박스는 움직이고 상태는 업데이트되지만, 이미 등록된 리스너(handleMouseMove)는 여전히 x = 0이라고 알고 있는 옛날 함수이다.드래그 이벤트처럼 빈번하게 발생하는 이벤트에서 useEffect의 의존성 배열에 x를 넣으면, x가 바뀔 떄마다 리스너를 지웠다 다시 등록하게 된다. 드래그 이벤트는 브라우저의 렌더링 속도(60fps)를 따라가므로 수십 번 발생하고, 리스너를 지웠다 다시 등록하는 과정이 수십 번이 반복되면 성능이 매우 떨어진다.
따라서 useRef를 사용해서 최신 상태를 항상 참조할 수 있게 하는 것이 정석이다.
import { useState, useEffect, useRef } from 'react';
const CorrectDragExample = () => {
const [x, setX] = useState(0);
// ✅ 해결책: 값이 바뀌어도 리렌더링을 유발하지 않는 ref에 최신값을 저장
const xRef = useRef(x);
// x가 변할 때마다 ref 동기화
useEffect(() => {
xRef.current = x;
}, [x]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
// ✅ 리스너 내부에서 state 대신 ref.current를 보면 항상 최신값임
console.log(`현재 x값: ${xRef.current}`);
// 이제 조건문이 정상 작동함 (300 넘으면 멈춤)
if (xRef.current < 300) {
setX((prev) => prev + e.movementX);
}
};
document.addEventListener('mousemove', handleMouseMove);
return () => document.removeEventListener('mousemove', handleMouseMove);
}, []); // 리스너는 한 번만 등록해도 됨
return (
<div style={{ transform: `translateX(${x}px)`, width: '100px', height: '100px', background: 'teal' }}>
{x}px
</div>
);
};
useRef를 사용하여 최신 값을 담아두고, 리스너 내부에서는 리렌더링 없이 최신화되는 ref.current를 참조해서 해결한다.
리액트 컴포넌트는 본질적으로 함수다. state가 변경되어 리렌더링이 일어난다는 것은, 함수가 다시 처음부터 끝까지 실행된다는 것이다.
리액트 입장에서는 매번 새로운 변수와 함수가 생성되는 것이지만, 이들은 서로 다른 메모리 주소를 가진 독립적인 존재들이다.
useEffect에서 addEventListener를 컴포넌트가 마운트될 때(첫 렌더링 직후) 호출한다고 가정한다.
이때 브라우저(DOM)에게 전달된 콜백 함수는 첫 렌더링에서 생성된 콜백 함수의 메모리 주소이다.
브라우저는 리액트가 리렌더링을 하든 말든 관심이 없다. 그저 처음에 전달받은 그 함수 주소를 메모리에 유지하고, 이벤트가 발생할 때마다 계속 실행할 뿐이다.
여기서 사이클의 분리가 발생한다.
x는 계속 업데이트되며 새로운 스냅샷(함수 실행 컨텍스트)을 찍어냄document에 등록된 이벤트 리스너는 여전히 첫 렌더링 시점의 스냅샷에 갇혀있음결국 리스너는 리액트의 최신 렌더링 사이클(최신 state)에 접근할 방법이 없이, 자신이 태어난 시점의 고립된 스코프만 바라보게 되는 것이다.
이는 매번 스냅샷을 새로 찍어내는 리액트의 불변성(Immutability) 원칙과, 한번 생성된 객체나 상태가 계속 유지되고 변경 가능한 DOM의 가변적/영속적(Mutable/Persistent) 특성이 충돌하는 지점이다.
일반 변수나 state는 값이며, 렌더링마다 새로 생성되는 스냅샷에 불과하다.
반면 useRef는 리액트 렌더링 사이클과 무관하게 동일한 메모리 주소를 유지하는 저장소 객체이다.
ref 객체 자체이고, 그 안의 current 값은 계속 덮어씌워지므로 항상 최신 값을 읽을 수 있음이벤트가 발생했을 때만 실행하고 싶지만(Effect 실행 X), 값은 최신값을 쓰고 싶다(State 의존 O)는 모순적인 상황에 처해있다.
의존성 배열에 넣자니 addEventListener가 계속 지워졌다 다시 생겨서 성능이 나쁘고, 안 넣자니 Stale Closure가 발생하고, 그래서 따로 useRef를 사용해 번거롭게 해결해야 한다.
그래서 리액트 팀은 반응형(Reactive)이어야 하는 코드와 반응형이 아니어도 되는 코드를 분리하는 개념을 도입하려 한다. 이는 오랫동안 실험적 기능이었으며 19.2 버전에서 정식 API로 도입되었다. 특히 React 19의 새로운 React Compiler와 함께 사용할 때, 불필요한 재렌더링을 방지하는 효과가 극대화된다고 한다.
이 훅을 사용하면 useRef 없이도 직관적으로 코드를 구성할 수 있다.
import { useState, useEffect, useEffectEvent } from 'react';
function DragExample() {
const [x, setX] = useState(0);
// ✅ useEffectEvent로 감싼 함수는:
// 1. 항상 최신 props/state를 볼 수 있음.
// 2. 하지만 useEffect의 의존성 배열에는 영향을 주지 않음 (stable identity).
const onMove = useEffectEvent((e) => {
console.log(x); // 여기서 그냥 x를 써도 항상 최신값! (Stale Closure 해결)
if (x < 300) setX(prev => prev + e.movementX);
});
useEffect(() => {
document.addEventListener('mousemove', onMove);
return () => document.removeEventListener('mousemove', onMove);
}, []); // ✅ 의존성 배열이 비어있어도 onMove 내부에서 최신 x를 참조함
}
useEffectEvent의 내부 동작 원리는 useRef로 해결했던 방식과 완전히 똑같다.
단지 React가 그 패턴을 내부적으로 추상화해서 감춰준 것이다.
useEffect가 실행될 때 onMove라는 함수 껍데기(stable identity)를 제공onMove 내부가 최신 props과 state를 가리키도록 내부 포인터를 몰래 업데이트useRef를 신경 쓰지 않고 일반 함수처럼 짜면 됨