클로저와 React State
React로 개발하다 보면 분명히 state를 업데이트했는데 이전 값이 찍히는 상황을 만난다. 특히 setTimeout, setInterval, 이벤트 리스너 콜백 안에서 이런 일이 자주 발생한다.
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
alert(count); // 항상 클릭 시점의 count를 보여준다
}, 3000);
};
return <button onClick={handleClick}>{count}</button>;
}
버튼을 눌러서 alert을 예약하고, 3초 안에 count를 5까지 올려도 alert에는 버튼을 눌렀을 때의 값이 표시된다. 이것이 stale closure 문제다. 왜 이런 일이 발생하는지, 어떻게 해결하는지를 이해하려면 JavaScript의 클로저가 React의 렌더링 모델과 어떻게 상호작용하는지 알아야 한다.
클로저가 뭔지 먼저 짚고 가자
클로저는 함수가 자신이 선언된 렉시컬 스코프의 변수를 기억하는 것이다. 함수가 실행 컨텍스트 밖에서 호출되더라도 선언 당시의 스코프 체인에 접근할 수 있다.
function outer() {
let value = 10;
function inner() {
console.log(value); // outer의 value를 기억한다
}
return inner;
}
const fn = outer();
fn(); // 10
outer()가 이미 실행 종료됐는데도 inner()는 value에 접근할 수 있다. inner가 선언 시점의 스코프를 클로저로 캡처했기 때문이다. 이건 JavaScript의 기본 동작이고, 대부분의 경우 편리하게 동작한다.
문제는 클로저가 변수의 참조가 아니라 값의 스냅샷을 캡처할 때 발생한다. 정확히 말하면, 클로저는 변수 자체에 대한 참조를 가지고 있지만, React에서는 매 렌더마다 새로운 변수가 생성되기 때문에 이전 렌더의 변수를 참조하게 되는 것이다.
React 렌더링과 클로저의 충돌
React 함수 컴포넌트는 매 렌더마다 함수가 새로 실행된다. 이게 핵심이다.
function Counter() {
const [count, setCount] = useState(0);
// 이 count는 이번 렌더의 count다. 다음 렌더에서는 새 count가 생긴다.
const handleClick = () => {
console.log(count); // 이 클로저는 "이번 렌더의 count"를 캡처한다
};
return <button onClick={handleClick}>{count}</button>;
}
렌더링 흐름을 단계별로 보면:
- 첫 번째 렌더 (count = 0):
handleClick_v1이 생성되고, 이 함수는count = 0을 클로저로 캡처한다. setCount(1)호출: 리렌더링 발생.- 두 번째 렌더 (count = 1):
handleClick_v2가 생성되고, 이 함수는count = 1을 캡처한다.
각 렌더에서 생성된 함수는 자기 렌더의 count만 안다. handleClick_v1은 영원히 count = 0만 알고 있다. 이건 버그가 아니라 React의 설계다. React는 각 렌더를 하나의 스냅샷으로 취급한다.
동기적인 이벤트 핸들러에서는 이게 문제가 안 된다. 클릭할 때마다 최신 렌더의 핸들러가 실행되니까. 문제는 비동기 콜백에서 발생한다.
Stale Closure가 실제로 문제되는 상황들
setTimeout / setInterval
가장 흔한 케이스다.
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(seconds); // 항상 0이 찍힌다!
setSeconds(seconds + 1); // 항상 0 + 1 = 1로 설정된다!
}, 1000);
return () => clearInterval(id);
}, []); // 의존성 배열이 비어있음
return <div>{seconds}</div>;
}
이 코드에서 setInterval 콜백은 마운트 시점(첫 렌더)에 생성된다. 이 콜백의 클로저는 seconds = 0을 캡처하고 있다. 1초, 2초, 10초가 지나도 이 콜백이 아는 seconds는 0이다. 그래서 setSeconds(0 + 1)이 계속 반복되면서 화면에는 1만 표시된다.
이벤트 리스너
useEffect 안에서 DOM 이벤트 리스너를 등록하는 경우도 마찬가지다.
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
const [message, setMessage] = useState("");
useEffect(() => {
const handleScroll = () => {
// message는 이벤트 리스너 등록 시점의 값
console.log(message); // 항상 ""
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []); // message가 의존성에 없다
return <div>{scrollY}</div>;
}
비동기 요청 후 state 참조
API 호출 후 현재 state를 기반으로 뭔가를 하려고 할 때도 문제가 된다.
function SearchComponent() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const handleSearch = async () => {
const data = await fetchResults(query);
// 이 시점의 query는 fetchResults 호출 시점의 query다
// 사용자가 그 사이에 query를 변경했을 수 있다
console.log(`"${query}"에 대한 검색 결과:`, data);
setResults(data);
};
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<button onClick={handleSearch}>검색</button>
</>
);
}
사용자가 "react"를 검색하고, 응답이 오기 전에 "vue"로 바꾸면, 응답이 도착했을 때 콘솔에는 "react"에 대한 검색 결과:가 찍힌다. handleSearch 클로저가 캡처한 query는 함수 호출 시점의 값이기 때문이다.
해결 방법 1: 함수형 업데이트 (setState 콜백)
setState에 값 대신 함수를 전달하면, React가 현재 최신 state 값을 인자로 넘겨준다.
// ❌ stale closure
setSeconds(seconds + 1); // seconds는 클로저가 캡처한 옛날 값
// ✅ 함수형 업데이트
setSeconds(prev => prev + 1); // prev는 항상 최신 값
아까의 타이머 예제를 고치면:
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSeconds(prev => prev + 1); // 항상 최신 값 기준으로 +1
}, 1000);
return () => clearInterval(id);
}, []);
return <div>{seconds}</div>;
}
이 방법은 state를 이전 state 기반으로 업데이트하는 경우에만 유효하다. state 값을 읽기만 해야 하는 경우(예: 조건 분기, 로깅)에는 이 방법을 쓸 수 없다.
해결 방법 2: useRef로 최신 값 유지
useRef는 컴포넌트의 전체 생명주기 동안 동일한 객체를 유지한다. .current 프로퍼티를 변경해도 리렌더가 발생하지 않으며, 어디서 읽든 항상 최신 값을 가리킨다.
function Timer() {
const [seconds, setSeconds] = useState(0);
const secondsRef = useRef(seconds);
// 매 렌더마다 ref를 최신 state로 동기화
useEffect(() => {
secondsRef.current = seconds;
});
useEffect(() => {
const id = setInterval(() => {
// ref는 항상 최신 값
console.log("현재:", secondsRef.current);
setSeconds(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <div>{seconds}</div>;
}
패턴을 정리하면:
- state와 같은 값을 가진 ref를 만든다.
useEffect에서 매 렌더마다 ref를 state 값으로 동기화한다.- 비동기 콜백에서는 ref.current를 읽는다.
이 방법은 state 값을 읽기만 해야 할 때 특히 유용하다. 함수형 업데이트로는 불가능한, "현재 값을 기반으로 조건 분기"같은 로직을 처리할 수 있다.
useEffect(() => {
const id = setInterval(() => {
if (secondsRef.current >= 60) {
// 최신 값을 기반으로 조건 분기
clearInterval(id);
return;
}
setSeconds(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
해결 방법 3: useEffect 의존성 배열 활용
의존성 배열에 참조하는 state를 넣으면, 해당 state가 변경될 때마다 effect가 재실행되면서 새로운 클로저가 생성된다.
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setTimeout(() => {
setSeconds(seconds + 1);
}, 1000);
return () => clearTimeout(id);
}, [seconds]); // seconds가 바뀔 때마다 새로운 timeout
return <div>{seconds}</div>;
}
이 코드는 setInterval 대신 setTimeout을 사용한다. seconds가 변경될 때마다 이전 timeout을 정리하고 새 timeout을 설정한다. 매번 최신 seconds를 클로저로 캡처하기 때문에 stale closure 문제가 없다.
단점도 있다:
- 타이머 드리프트: 매번 cleanup하고 새로 설정하기 때문에 정확한 1초 간격이 보장되지 않는다.
- 성능: state가 빈번하게 변경되는 경우, effect가 계속 재실행되면서 부하가 발생할 수 있다.
- 무한 루프 위험: 의존성 배열 설정을 잘못하면 무한 리렌더링이 발생한다.
해결 방법 4: useCallback + 의존성
이벤트 핸들러를 useCallback으로 감싸서 의존성이 변경될 때만 함수를 재생성하는 방식이다.
function ChatRoom({ roomId }) {
const [message, setMessage] = useState("");
const sendMessage = useCallback(() => {
// message와 roomId 모두 최신 값
api.send(roomId, message);
}, [roomId, message]);
return <SendButton onClick={sendMessage} />;
}
이건 stale closure 자체를 방지하는 게 아니라, 의존성이 바뀔 때 새 클로저를 만들어서 최신 값을 캡처하게 하는 방식이다. 메모이제이션된 자식 컴포넌트에 콜백을 전달할 때 유용하다.
React 18+ useEvent 제안과 현재 상황
React 팀은 stale closure 문제를 근본적으로 해결하기 위해 useEvent라는 훅을 제안했다. 아이디어는 "항상 최신 props/state를 참조하지만, 참조 동일성은 유지되는 함수"를 만드는 것이다.
// 제안된 API (아직 공식 출시 X)
function Chat({ roomId }) {
const [text, setText] = useState("");
const onSend = useEvent(() => {
// 항상 최신 text와 roomId를 참조
sendMessage(roomId, text);
});
// onSend의 참조는 절대 변하지 않음
// useEffect의 의존성에 넣어도 무한 루프 안 남
}
2026년 현재 아직 공식 릴리즈되지 않았지만, 같은 패턴을 직접 구현할 수 있다:
function useEvent(handler) {
const handlerRef = useRef(handler);
useLayoutEffect(() => {
handlerRef.current = handler;
});
return useCallback((...args) => {
return handlerRef.current(...args);
}, []);
}
이 커스텀 훅의 핵심:
handlerRef는 매 렌더마다 최신 handler로 업데이트된다.- 반환되는 함수는
useCallback([], ...)이므로 참조가 절대 변하지 않는다. - 호출 시점에
handlerRef.current를 실행하므로 항상 최신 클로저를 사용한다.
실전 패턴: 어떤 해결책을 쓸지 판단하기
상황별로 가장 적합한 해결책이 다르다.
state를 이전 값 기반으로 업데이트 → 함수형 업데이트
setCount(prev => prev + 1);
setItems(prev => [...prev, newItem]);
setMap(prev => new Map(prev).set(key, value));
가장 간단하고 가장 먼저 고려해야 할 방법이다. "이전 값 + 변화량"으로 표현 가능하면 이걸 쓴다.
state를 읽기만 해야 함 (조건 분기, 로깅) → useRef
const stateRef = useRef(state);
useEffect(() => { stateRef.current = state; });
// 비동기 콜백에서
if (stateRef.current > threshold) { ... }
여러 state를 조합해서 사용 → useRef 또는 useEvent 패턴
const onTick = useEvent(() => {
if (isPaused) return;
setSeconds(prev => prev + 1);
if (seconds >= maxSeconds) stop();
});
effect가 특정 값 변경에 반응해야 함 → 의존성 배열
useEffect(() => {
const ws = new WebSocket(url);
return () => ws.close();
}, [url]); // url이 바뀔 때만 재연결
흔한 실수와 디버깅
ESLint exhaustive-deps 경고 무시하기
useEffect(() => {
fetchData(userId); // ESLint: userId를 의존성에 추가하세요
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
이 경고를 무시하는 건 대부분 잘못된 선택이다. 경고가 뜨는 건 stale closure가 발생할 수 있다는 뜻이다. 의존성을 추가하거나, 로직을 재구성해야 한다. 정말로 마운트 시점의 값만 필요한 게 확실하다면 ref로 명시적으로 처리하는 게 의도가 드러난다.
객체/배열 의존성으로 인한 무한 루프
function App() {
const options = { page: 1, limit: 10 }; // 매 렌더마다 새 객체
useEffect(() => {
fetchData(options);
}, [options]); // 매 렌더마다 실행됨! (참조가 다르니까)
}
객체나 배열은 매 렌더마다 새로 생성되기 때문에 의존성 비교에서 항상 "변경됨"으로 판단된다. useMemo로 메모이제이션하거나, 원시값으로 분해해서 의존성에 넣어야 한다.
// 방법 1: useMemo
const options = useMemo(() => ({ page, limit }), [page, limit]);
// 방법 2: 원시값으로 분해
useEffect(() => {
fetchData({ page, limit });
}, [page, limit]);
state 업데이트 후 즉시 읽기
const handleClick = () => {
setCount(count + 1);
console.log(count); // 여전히 이전 값!
};
setState는 비동기적으로 동작한다. 호출 즉시 state가 변경되는 게 아니라, 다음 렌더에서 새 값이 반영된다. 업데이트 직후의 값이 필요하면 변수에 미리 저장해두거나 useEffect에서 변경을 감지해야 한다.
const handleClick = () => {
const nextCount = count + 1;
setCount(nextCount);
console.log(nextCount); // 새 값 사용
};
정리
| 문제 | 원인 | 해결 |
|---|---|---|
| setTimeout 안에서 옛날 값 | 콜백이 생성 시점의 state를 캡처 | 함수형 업데이트 or useRef |
| setInterval이 같은 값만 반복 | effect가 한 번만 실행되어 초기값만 캡처 | 함수형 업데이트 |
| 이벤트 리스너에서 stale state | 리스너 등록 시점의 클로저 | useRef or 의존성 배열로 재등록 |
| useEffect 안에서 최신 props 필요 | 의존성 배열에 빠짐 | 의존성 추가 or useRef |
클로저와 React state의 관계를 이해하면, "왜 내 값이 안 바뀌지?"라는 질문에 항상 답할 수 있다. 결국 핵심은 하나다: React 함수 컴포넌트는 매 렌더가 독립적인 스냅샷이고, 비동기 콜백은 자기가 태어난 스냅샷의 값만 안다.