
클로저는 함수가 만들어질 때 자기 바깥에 있던 변수를 기억하는 것이다.
예시 :
function outer() {
const name = ‘뽀야미’;
function inner() {
console.log(name);
}
return inner;
}
const fn = outer();
fn(); // 뽀야미
outer()는 이미 실행이 끝났으므로 outer 안에 있던 name도 사라질거같지만, fn()을 실행하면 여전히 ‘뽀야미’가 출력된다.
좀 더 풀어 말하면,
function inner() {
console.log(name);
}
inner 함수는 자기 내부에 name이 없기 때문에, 자신이 만들어진 렉시컬 스코프인 outer의 환경에서 name을 참조한다. (outer 실행이 끝난 뒤에도 inner가 그 환경을 참조하고 있기 때문에 name을 계속 사용할 수 있다.)
(*렉시컬 스코프 : 함수가 어디서 호출되었는지가 아니라, 어디서 작성되었는지에 따라 참조할 수 있는 변수 범위가 결정되는 규칙)
function outer() {
const name = ‘뽀야미’;
}
inner는 아, 내 바깥에 name이라는 변수가 있네? 하고 그걸 기억한다. 그래서 나중에 outer 함수 실행이 끝난 뒤에도 inner는 name을 계속 사용할 수 있다.
즉, 함수가 태어날 때 주변에 있던 정보를 기억하고 있는 것이다. 여기서 중요한 것은, 클로저는 값을 기억한다기보다 바깥 변수에 접근할 수 있는 연결을 유지하는 쪽에 가깝다는 것이다.
클로저를 이용하면 상태를 숨겨서 관리할 수도 있다.
function createBankAccount(initialMoney) {
let balance = initialMoney;
return {
deposit(amount) {
balance += amount;
return balance;
},
withdraw(amount) {
balance -= amount;
return balance;
},
getBalance() {
return balance;
},
};
}
const account = createBankAccount(1000);
account.deposit(500); // 1500
account.withdraw(300); // 1200
account.getBalance(); // 1200
여기서 balance는 바깥에서 직접 못 건드린다.
account.balance; // undefined
함수가 예전 값을 기억한 채로 실행돼서 최신 상태값을 못 보는 문제.
특히 리액트에서 useEffect, setInterval, 이벤트 핸들러, 비동기 콜백 쓸 때 자주 나온다.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
이런 코드가 있을 때 1초마다 count가 올라가고, console.log(count)도 최신 값을 찍을 것 같지만, 실제로는 console.log(count)가 계속 0만 찍힐 수 있다.
왜냐면 useEffect가 처음 실행될 때 만들어진 setInterval 콜백은 첫 번째 렌더링의 count 값을 참조하기 때문이다.
이후 state가 바뀌어 컴포넌트가 다시 렌더링되어도, 기존 interval 콜백은 새 렌더링의 count가 아니라 처음 렌더링의 count를 계속 바라본다.
그러니 내부적으로는 아래와 같이 동작한다.
// 처음 렌더링 시점 count = 0
const timer = setInterval(() => {
console.log(0);
setCount(0 + 1);
}, 1000);
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount((prev) => {
console.log(prev);
return prev + 1;
});
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
prev는 React가 넘겨주는 업데이트 직전의 최신 상태값이다. 따라서 클로저가 오래됐더라도 prev를 기준으로 안전하게 다음 상태를 계산할 수 있다.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, [count]);
return <div>{count}</div>;
}
이 방식도 stale closure 문제는 해결된다. count가 바뀔 때마다 effect cleanup이 실행되어 기존 interval을 제거하고, 최신 count를 참조하는 새 interval을 만든다. 다만 interval을 계속 재등록하는 구조라, 단순 카운터에서는 함수형 업데이트가 더 자연스럽다.
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const timer = setInterval(() => {
console.log(countRef.current);
setCount((prev) => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
useRef는 값이 바뀌어도 컴포넌트를 다시 렌더링하지 않고, 같은 ref 객체를 계속 유지한다. 그래서 오래 살아있는 콜백 안에서도 countRef.current를 통해 최신 count 값을 읽을 수 있다.
참고 : https://handhand.tistory.com/entry/React-Stale-Closure-Problem
requestAnimationFrame은 애니메이션 콜백 예약 API이다. 부드러운 애니메이션을 만들 때 setInterval보다 자주 쓰인다.
목표 숫자에 도달하지 않으면 다시 requestAnimationFrame(increaseNumberAnimation)를 호출하는 구조를 사용했을 때, animationId를 useRef에 저장해서 정리하는 패턴을 이용했다.
document.addEventListener에 등록된 함수는 리액트 바깥, 즉 브라우저 DOM 쪽에 저장된다.
브라우저는 리액트가 리렌더링되든 말든 관심없고, 처음 전달받은 함수 주소를 계속 실행한다.
그래서 리액트쪽 state는 계속 바뀌는데, DOM 이벤트 리스너는 첫 렌더링 시점의 함수에 갇히는 문제가 생길 수 있다.
이 글의 예시는 mousemove 이벤트에서 x < 300이면 계속 움직이고, 300px을 넘으면 멈추게 하고 싶은 코드가 나오는데, 이벤트 리스너가 처음의 x = 0만 기억해서 조건문이 계속 참이 돼버리는 상황이 생긴다.
setX(prev => prev + e.movementX) 처럼 함수형 업데이트를 써도, 조건문 if (x < 300) 의 x는 여전히 stale 할 수 있다.
이를 해결하기 위해 의존성 배열에 x를 넣으면, x가 바뀔 때마다 removeEventListener하고 다시 addEventListener를 해야한다.
드래그처럼 매우 자주 발생하는 이벤트에서는 이게 성능상 부담이 될 수 있어서, 이런 경우엔 useRef로 최신 값을 읽는 방식이 더 낫다.
stale closure는 useEffect 뿐만 아니라 useCallback 안에서도 생길 수 있다.
const [text, setText] = useState('');
const handleLog = useCallback(() => {
console.log(text);
}, []);
이러면 text는 바뀌어도 handleLog는 처음 렌더링 때의 text를 기억할 수 있다.
const handleLog = useCallback(() => {
console.log(text);
}, [text]);
이렇게 하면 해결된다.
(다만 dependency에 text를 넣으면 text가 바뀔 때마다 handleLog도 새로 생성된다. 대부분의 경우 문제 없지만, 함수 참조 안정성이 중요한 경우에는 useRef/useEvent/useEffectEvent 같은 패턴을 고려할 수 있다.)
비동기 함수에서도 stale closure가 생길 수 있다.
const handleSubmit = async () => {
await delay(1000);
console.log(value);
};
함수가 실행된 시점의 value와, 1초 뒤 실제로 로그를 찍는 시점의 최신 value가 다를 수 있다.
이유는, async 함수 안의 value는 await 이후에도 자동으로 최신 값으로 갱신되지 않는다. 함수가 호출된 렌더링 시점의 value를 계속 참조하는 것이다.
따라서 사용자가 함수가 실행 된 뒤 시간이 흐르는 사이 input을 바꾸거나 state가 바뀌었을 때, 기대한 최신값이 출력되지 않을 수 있다
subscription에서도 stale closure가 발생할 수 있다. 리액트 바깥에 콜백을 맡겨두는 구조라 stale closure가 생기기 쉽다.
(* subscription : 외부 변화가 생기면 알려달라고 구독해두는 것. WebSocket 메시지 수신 / Firebase 실시간 데이터 / 외부 store 구독 같은 것들이 해당.)
화이팅입니다~