useCallback 의 closure 로 인한 메모리 누수 가능성
https://schiener.io/2024-03-03/react-closures
- 아래와 같은 코드가 있다고 해보자
import { useState, useCallback } from "react";
class BigObject {
public readonly data = new Uint8Array(1024 * 1024 * 10);
}
export const App = () => {
const [countA, setCountA] = useState(0);
const [countB, setCountB] = useState(0);
const bigData = new BigObject(); // 10MB of data
const handleClickA = useCallback(() => {
setCountA(countA + 1);
}, [countA]);
const handleClickB = useCallback(() => {
setCountB(countB + 1);
}, [countB]);
// This only exists to demonstrate the problem
const handleClickBoth = () => {
handleClickA();
handleClickB();
console.log(bigData.data.length);
};
return (
<div>
<button onClick={handleClickA}>Increment A</button>
<button onClick={handleClickB}>Increment B</button>
<button onClick={handleClickBoth}>Increment Both</button>
<p>
A: {countA}, B: {countB}
</p>
</div>
);
};
Initial Render
- 클로저 스코프를 하나 만든다. 해당 스코프는 각 변수에 대한 레퍼런스를 가지고 있다. (handleClickA, handleClickB, bigData)
IncrementA 클릭 시
handleClickA
를 호출한다.- 그리고, countA 가 변경되었으니, handleClickA 는 재생성된다.
- 재생성 될 때 새로운 클로저 스코프가 탄생한다.
- 이 때,
handleClickB
는 변경사항이 없으니, 재생성되지 않는다.- 즉, 새로 생성된 클로저 스코프에서 handleClickB 는 이전의 스코프를 참조한다.
IncrementB 클릭 시
handleClickB
를 호출한다.- 그리고, countB가 변경되었으니, handleClickB가 재생성된다.
- 재생성 될 때 새로운 클로저스코프가 탄생한다.
- 이 때,
handleClickA
는 이전 스코프와 비교하여 변경사항이 없으니, 재생성되지 않는다.- 즉, 새로생성된 클로저 스코프에서 handleClickA가 이번엔 이전의 스코프를 참조한다.
//... 반복
문제점
- 이렇게 클로저 스코프가 계속해서 재탄생되고, useCallback 을 사용했을 경우, 변경되지 않는 값이 있으면 이전 스코프를 참조한다. 즉, 메모리에 계속해서 누적된다.
- 계속 참조하고 있으니 가비지 컬렉팅이 일어나지 않을 수가 있고, 만약 bigObjectData 가 매우 큰 ㄷ데이터라면 메모리 효율이 떨어진다.
해결 방법
-
클로저 스코프를 최대한 작게 잡기 (클로저 주변의 함수 크기를 줄이기)
- 컴포넌트를 더 작게 만들기
- 스코프 내의 들어갈 변수의 수를 줄여준다.
- 커스텀 훅을 만들기
- 그러면 모든 콜백이 훅 함수 내의 스코프만 가질 수 있고, 주로 함수 인자만 스코프 내에 가진다는 것을 의미.
- 컴포넌트를 더 작게 만들기
-
다른 클로저, 특히 메모이제이션된 클로저를 캡쳐하지 않기.
- 서로 호출하는 함수를 만들면, 첫번째 useCallback 을 추가하는 순간, 컴포넌트 스코프 내에서 호출되는 모든 함수들이 메모이제이션 되는 연쇄반응이 일어난다.
-
필요하지 않을 때에는 메모이제이션하지 않기.
-
큰 객체에는 useRef 를 사용하기
- 객체의 생명주기를 직접 관리하고, 적절히 정리해야함을 의미할 수 있다.