💻

숨겨진 React Memory leak (useCallback 과 closures)

Tags
React
Performance
Date
2024/07/26
속성
이 글은 Kevin 님의 글을 번역, 첨삭한 글입니다. 원문을 보려면 아래를 클릭해주세요
저는 비디오 주석을 위한 복잡한 React 앱을 구축하는 AI 스타트업, Ramblr 에서 일하고 있습니다. 최근 개발을 하면서 Javascript closure 와 React 의 useCallback hook 의 조합이 야기한 memory leak 이슈를 겪었습니다.
클로저에 대해 간략하게 설명하겠지만, Javascript 동작 방식에 익숙하다면 해당 부분을 건너뛰어도 됩니다.

Closures

클로저 는 Javascript 의 기본 개념입니다. 클로저를 통해 함수는 함수가 생성될 때 범위에 있던 변수를 기억할 수 있습니다. 간단한 예는 다음과 같습니다.
function createCounter() { const unused = 0; // inner function 에서 사용되지 않는 변수 let count = 0; // inner function 에서 사용되는 변수 return function () { count++; console.log(count); }; } const counter = createCounter(); counter(); // 1 counter(); // 2
JavaScript
복사
위 예에서, createCounter 함수는 count 변수에 접근하는 새로운 함수를 리턴합니다. inner function 이 생성될 때, count 변수가 creaetCounter 의 범위(scope) 내에 있기 때문입니다.
클로저는 함수가 처음 생성될 때 범위 내의 변수에 대한 참조를 보유하는 컨텍스트 객체(context object)를 사용하여 구현이 됩니다. 컨텍스트 개체에 저장되는 변수는 JavaScript 엔진의 구현 세부 사항이며 다양한 최적화가 적용됩니다. 예를 들어 Chrome에서 사용되는 JavaScript 엔진인 V8에서는 사용하지 않는 변수가 컨텍스트 객체에 저장되지 않을 수 있습니다.
클로저는 다른 클로저 내에 중첩될 수 있으므로 가장 안쪽 클로저는 액세스해야 하는 외부 함수 범위에 대한 참조(소위 범위 체인을 통해)를 보유합니다.
function first() { const firstVar = 1; function second() { // firstVar 에 대한 closure const secondVar = 2; function third() { // firstVar, secondVar 에 대한 closure console.log(firstVar, secondVar); } return third; } return second(); } const fn = first(); // return the third function fn(); // logs 1, 2
JavaScript
복사
위 예에서 third() 함수는 범위 체인(scopte chain)을 통해 firstVar 변수에 액세스할 수 있습니다.
앱이 함수(third)에 대한 참조를 보유하는 한 클로저 범위의 변수(firstVar, secondVar)는 가비지 콜렉터가 메모리 회수를 할 수 없습니다. 범위 체인으로 인해 외부 함수(second) 범위도 메모리에 유지됩니다.
관심이 있으시다면 해당 토픽을 읽어보세요. Grokking V8 closures for fun (and profit?) 2012년의 내용이지만 여전히 관련성이 있으며 V8에서 클로저가 작동하는 방식에 대한 훌륭한 개요를 제공합니다.

참고

Closure 와 React

React 로 앱을 만들 때, 함수형 컴포넌트 (최근에는 대부분 함수형으로 만들 것이다)에서 hook, event handler 를 다룰 때 클로저에 많이 의존합니다. 예를 들어 props, state 와 같이 컴포넌트 요소 범위에서 변수에 액세스하는 새 함수를 만들 때마다 클로저를 만들 가능성이 높습니다.
import { useState, useEffect } from "react"; function App({ id }) { const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); // count 변수에 접근하는 closure }; useEffect(() => { console.log(id); // id prop 에 접근하는 closure }, [id]); return ( <div> <p>{count}</p> <button onClick={handleClick}>Increment</button> </div> ); }
JavaScript
복사
대부분의 경우 클로저가 생기는것 자체가 문제가 되지 않습니다. 위의 예에서 클로저는 App을 렌더링할 때마다 다시 생성되며 이전 클로저는 가비지 콜렉터에 의해 메모리가 회수됩니다. 이는 불필요한 할당 및 회수를 의미할 수 있지만 일반적으로는 충분히, 매우 빠릅니다.
그러나 앱이 성장하고 불필요한 리렌더링을 피하기 위해 useMemo 및 useCallback과 같은 메모 기술을 사용하기 시작하면 주의해야 할 몇 가지 사항이 있습니다.

Closure 와 useCallback

memoization hook(useCallback, useMemo 등) 들을 사용할 때, 추가 memory 를 사용하는 대신 더 좋은 렌더링 성능을 얻습니다(trade off). useCallback은 종속성이 변경되지 않는 한 함수에 대한 참조를 보유합니다.
import React, { useState, useCallback } from "react"; function App() { const [count, setCount] = useState(0); const handleEvent = useCallback(() => { setCount(count + 1); }, [count]); return ( <div> <p>{count}</p> <ExpensiveChildComponent onMyEvent={handleEvent} /> </div> ); }
JavaScript
복사
위 예에서 렌더링 비용이 큰 ExpensiveChildComponent 컴포넌트의 리렌더링을 피하고 싶다면, handleEvent 함수를 변경하지 않고 메모리에 유지하고 있으면 됩니다. handleEvent 는 useCallback 에 의해서 count 가 변경될 때만 재할당 되고, 나머지 경우에는 계속 memoize 되고 있습니다. 그러면 ExpensiveChildComponent 컴포넌트를 React.memo 로 감싸면 App 이 리렌더링 될 때마다 ExpensiveChildComponent 가 렌더링 되지 않게 만들 수 있습니다.
그런데 아래의 예를 한 번 볼까요?
import { useState, useCallback } from "react"; class BigObject { public readonly data = new Uint8Array(1024 * 1024 * 10); // 10MB of data } function App() { const [count, setCount] = useState(0); const bigData = new BigObject(); const handleEvent = useCallback(() => { setCount(count + 1); }, [count]); const handleClick = () => { console.log(bigData.data.length); }; return ( <div> <button onClick={handleClick} /> <ExpensiveChildComponent2 onMyEvent={handleEvent} /> </div> ); }
JavaScript
복사

무슨 일이 벌어질까요?

handlerEventcount 변수에 대한 클로저를 생성하므로 컴포넌트의 컨텍스트 객체에 대한 참조를 보유하게 됩니다. 그리고 비록 우리가 handlerEvent 함수에서 bigData에 접근하지 않더라도, handlerEvent는 여전히 컴포넌트의 컨텍스트 객체를 통해 bigData에 대한 참조를 보유할 것입니다.
모든 클로저는 생성된 시점부터 공동 컨텍스트 객체를 공유합니다. handlerClickbigData를 사용하기 때문에, bigData는 이 컨텍스트 개체에 의해 참조됩니다. handleEvent가 참조되는 동안 bigData가 가비지 콜렉팅이 되지 않음을 의미합니다. 이 참조는 count가 변경되고 handlerEvent가 다시 생성될 때까지 유지됩니다.

useCallback + closures + large objects 로 인한 무한 memory leak

위의 모든 내용을 극단적으로 적용한 마지막 예를 살펴보겠습니다. 이 예제는 우리 앱에서 접했던 것의 축소된 버전입니다. 따라서 이 예는 부자연스러워 보일 수도 있지만 일반적인 문제를 아주 잘 보여줍니다.
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> ); };
JavaScript
복사
이 예에는 두 개의 memoize된 이벤트 핸들러인 handlerClickAhandlerClickB가 있습니다. 또한 이벤트 핸들러를 모두 호출하고 bigData의 길이를 기록하는 함수 handlerClickBoth도 있습니다.
"Increment A" 버튼과 "Increment B" 버튼을 번갈아 클릭하면 어떤 일이 일어날지 짐작할 수 있나요?
버튼을 각각 5번 클릭한 후 Chrome DevTools의 메모리 프로필을 살펴보겠습니다.
bigData가 가비지 콜렉팅 되지 않는 것 같습니다. 클릭할 때마다 메모리 사용량이 계속해서 증가하고 있습니다. 우리의 경우 애플리케이션은 각각 크기가 10MB인 11개의 BigObject 인스턴스에 대한 참조를 보유합니다. 하나는 초기 렌더링용이고 다른 하나는 클릭할 때마다 사용됩니다.
보존 트리는 진행 상황을 알려줍니다. 반복되는 참조 체인을 만들고 있습니다.

0. 처음 렌더링

App이 처음 렌더링되면 적어도 하나의 클로저에서 모든 변수를 사용하므로 모든 변수에 대한 참조를 보유하는 클로저 범위를 생성합니다. 여기에는 bigData, handlerClickAhandlerClickB가 포함됩니다. 우리는 이를 handlerClickBoth에서 참조합니다. 클로저 범위를 AppScope#0이라고 부르겠습니다.

1. 클릭 ‘Increment A’

“Increment A”를 처음 클릭하면 countA가 변경된 이후에 handlerClickA가 다시 생성됩니다. 새 항목을 handlerClickA#1이라고 부르겠습니다.
countB가 변경되지 않았기 때문에 handlerClickB#0은 다시 생성되지 않습니다.
그러나 이는 handlerClickB#0이 여전히 이전 AppScope#0에 대한 참조를 보유한다는 것을 의미합니다.
handleClickA#1AppScope#1에 대한 참조를 보유하며, 이는 handleClickB#0에 대한 참조를 보유합니다.

2. 클릭 ‘Increment B

countB를 변경한 후 handlerClickB가 다시 생성되어 handlerClickB#1이 생성됩니다.
countA가 변경되지 않았기 때문에 React는 handlerClickA를 다시 생성하지 않습니다.
따라서 handlerClickB#1AppScope#2에 대한 참조를 보유합니다. AppScope#2handlerClickA#1에 대한 참조를 보유합니다. handlerClickA#1AppScope#1에 대한 참조를 보유합니다. AppScope#1handleClickB#0 에 대한 참조를 보유합니다.

3. ‘Increment A’ 두 번째 클릭

이런 식으로 우리는 서로를 참조하고 가비지 콜렉팅을 하지 않는 끝없는 클로저 체인을 생성할 수 있으며, 동시에 각 렌더링에서 다시 생성되기 때문에 별도의 10MB bigData 객체를 가지고 있습니다.

이것은 일반적인 문제입니다

일반적인 문제는 단일 구성 요소의 다양한 useCallback 후크가 클로저 범위를 통해 서로 및 기타 비용이 많이 드는 데이터를 참조할 수 있다는 것입니다. 클로저는 useCallback 후크가 다시 생성될 때까지 메모리에 유지됩니다. 구성 요소에 하나 이상의 useCallback 후크가 있으면 메모리에 무엇이 보관되어 있는지, 언제 해제되는지 추론하기가 매우 어렵습니다. 콜백이 많을수록 이 문제가 발생할 가능성이 높아집니다.

이것이 우리에게 문제가 될까요?

위와 같은 문제가 발생할 가능성을 높이는 몇 가지 요소가 있습니다.
1.
App component 와 같이 거의 계속해서 사용되는(리렌더링 되지 않아서 가비지 콜렉터가 메모리를 회수하지 못하는) 일부 컴포넌트가 많은 상태를 보유했을 때
2.
useCallback 에 의지해서 리렌더링을 최소화하려고 할 때
3.
memoize된 함수에서 다른 함수를 호출합니다.
4.
이미지 데이터나 큰 배열과 같은 큰 개체를 처리합니다.
큰 개체를 처리할 필요가 없는 경우 문자열이나 숫자를 참조하는 것은 문제가 되지 않을 수 있습니다. 이러한 클로저 상호 참조의 대부분은 속성이 충분히 변경된 후에 지워집니다. 다만 앱이 예상보다 더 많은 메모리를 보유할 수 있다는 점에 유의하세요.

Closure 및 useCallback 으로 인한 memory leak 을 피하려면 어떻게 하는게 좋을까요?

Tip1 - 클로저의 범위를 가능한 작게 유지하세요
JavaScript를 사용하면 캡처되는 모든 변수를 찾아내기가 매우 어렵습니다. 너무 많은 변수를 유지하는 것을 피하는 가장 좋은 방법은 클로저 주변의 함수 크기를 줄이는 것입니다
1.
컴포넌트 크기를 작게 만듭니다. 이렇게 하면 새 클로저를 생성할 때 범위 내의 변수의 수가 줄어듭니다.
2.
custom hook 을 만듭니다. callback 은 후크 함수 범위 내에서만 스코프를 가질 수 있습니다. 이 경우, custom hook 의 인수만 메모리에 가지고 있게 될 것입니다.
Tip2 - 다른 클로저, 특히 memoize 된 클로저를 캡처하지 마세요
서로를 호출하는 더 작은 함수를 작성하는 경우 첫 번째 useCallback을 추가하면 메모할 구성 요소 범위 내에서 호출된 모든 함수의 연쇄 반응이 있습니다.
Tip3 - 꼭 필요한 경우가 아니면 memoization 을 사용하지 않습니다
useCallbackuseMemo는 불필요한 재렌더링을 방지하는 훌륭한 도구이지만 비용이 발생합니다. 렌더링으로 인해 성능 문제가 발견된 경우에만 사용하세요.
Tip4 - 큰 object 의 경우 useRef 를 사용합니다.
useRef 를 사용하면 객체의 수명주기를 직접 처리하고 적절하게 정리해야 함을 의미할 수 있습니다. 최적은 아니지만 메모리 누수보다는 낫습니다.

결론

클로저는 React에서 많이 사용되는 패턴입니다. 이를 통해 함수는 컴포넌트가 마지막으로 렌더링되었을 때 범위에 있었던 props와 state를 기억할 수 있습니다. 특히 큰 객체로 작업할 때 useCallback과 같은 메모 기술과 결합하면 예기치 않은 메모리 누수가 발생할 수 있습니다. 이러한 메모리 누수를 피하려면 클로저 범위를 가능한 한 작게 유지하고, 필요하지 않을 때는 메모를 피하고, 큰 객체의 경우 useRef로 대체할 수 있습니다.

Follow up articles

Follow-up article for React Query users: Sneaky React Memory Leaks II: Closures Vs. React Query.
Interested how the React compiler will handle this?: Sneaky React Memory Leaks: How the React compiler won’t save you.