React 메모리 누수 - useCallback과 클로저의 활용

우동이·2024년 7월 29일
3

React

목록 보기
6/7

1. 클로저란?

클로저란 함수에서 반환된 내부 함수가 스코프를 기억하여 해당 스코프 외부에서 호출되어도 해당 스코프에 접근할 수 있는 함수를 의미합니다.

function createCounter() {
  let count = 0;
  return function () {
    count++;
    console.log(count);
  };
}

const counter = createCounter();
// 클로저를 통해 counter 변수 참조
counter(); // 1
counter(); // 2

만약 중첩된 구조로 클로저가 구현되어 있다면 가장 안쪽에 있는 클로저 함수는 모든 스코프를 참조하고 있기 때문에 모든 변수가 메모리에 존재하며 가비지 수집 대상에서 제외됩니다.

function first() {
  const firstValue = 1;
  
  function second() {
    const secondValue = 2;
    
    function third() {
      console.log(firstValue, secondValue);
    }
    return third;
  }
  return second();
}

const fn = first();
fn(); // 1, 2

실제 fn 함수의 스코프 현황

2. React에서의 클로저 활용

  • React에서 함수형 컴포넌트 또는 훅을 구현할 때 클로저에 크게 의존합니다. 클로저는 렌더링할 때마다 재생성되고 이전 클로저는 가비지 컬렉션되어 메모리 문제가 크게 발생하지 않습니다. 그러나 애플리케이션이 커지고 함수가 많아지면 렌더링 최적화를 위해 다양한 메모이제이션 기능을 사용하게 되는데, 이는 불필요한 메모리 누수를 유발할 수 있습니다.
import { useState, useEffect } from "react";

function App({ id }) {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    // 클로저를 이용해 count 변수를 계속 참조
    setCount(count + 1);
  };

  useEffect(() => {
    // 클로저를 이용해 전달받은 props id 참조
    console.log(id); 
  }, [id]);

  return (
    <div>
      <p>{count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}

3. useCallback와 클로저

  • 일반적으로 useCallback을 활용하면 handleClick가 참조하고 있는 counter값이 변동이 없다면 handleClick 함수는 재생성 하지 않으며 리렌더링을 방지할 수 있습니다.
import React, { useState, useCallback } from "react";

function App() {
  const [count, setCount] = useState(0);// useCallback을 이용한 최적화: count가 변경되지 않으면 함수를 재생성 하지 않음
  const handleClick = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  return (
    <div>
      <p>{count}</p>
      <ChildComponent onClick={handleClick} />
    </div>
  );
}

하지만 아래와 같이 클로저를 활용하는 함수가 여러 개 존재할 경우 문제가 발생할 수 있습니다.

  • handleClick() 함수는 count 변수에 대한 클로저를 생성합니다.
  • handleClick() 함수의 스코프를 확인하면 bigData에 접근하지 않더라도 handleClick()이 bigData에 대한 참조를 유지합니다.
  • 이는 클로저의 특징 때문입니다. 모든 클로저는 생성된 시점부터 공통의 컨텍스트를 공유합니다.
  • handleButtonClick() 함수가 bigData의 클로저를 생성하고, bigData는 공통 컨텍스트 객체에 의해 참조됩니다.
  • handleClick() 함수는 공통 컨텍스트를 참조하기 때문에 bigData가 가비지 컬렉션되지 않으며, 사용하지 않는 데이터까지 클로저로 가지고 있게 됩니다. 이 참조는 count가 변경되어 handleClick() 함수가 다시 생성될 때까지 유지됩니다.
import React from "react";
import { useState, useCallback } from "react";
import "./App.css";
import ChildComponent from "./component/ChildComponent";

const createBigData = () => {
  return new Uint8Array(1024 * 1024 * 10);
};

function App() {
  const [count, setCount] = useState(0);
  const bigData = createBigSata();

  const handleClick = useCallback(() => {
    setCount(count + 1);
  }, [count]);

  const handleButtonClick = () => {
    console.log(bigData);
  };

  return (
    <div>
      <button onClick={handleButtonClick} />
      <ChildComponent onClick={handleClick} />
    </div>
  );
}

export default App;
  • handleClick 함수의 스코프 현황

4. 클로저 체이닝

  • 아래의 코드 처럼 useCallback으로 최적화한 여러 개의 이벤트 핸들러 함수가 존재하고 이 함수를 참조하는 다른 함수가 존재할 경우 클로저 체이닝 현상이 발생하며 메모리 누수에 아주 취약한 상태가 됩니다.
import React from "react";
import { useState, useCallback } from "react";
import "./App.css";

const createBigData = () => {
  return new Uint8Array(1024 * 1024 * 10);
};

function App() {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);
  const bigData = createBigData();

  const handleClickA = useCallback(() => {
    setCountA(countA + 1);
  }, [countA]);

  const handleClickB = useCallback(() => {
    setCountB(countB + 1);
  }, [countB]);

  const handleClickBoth = () => {
    console.log(bigData);
    handleClickA();
    handleClickB();
  };

  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>
  );
}

export default App;

1 ) handleClickA Click

  • handleClickA 함수를 처음 클릭하면 countA가 변경되므로 handleClickA 함수가 재생성되어 handleClickA-1 함수가 만들어집니다.
  • 하지만 countB는 변경되지 않았으므로 handleClickB-0 함수는 재생성되지 않습니다.
  • 여기서 중요한 부분은 handleClickB-0은 여전히 이전의 AppScope-0에 대한 참조를 유지합니다.
  • 새로운 handleClickA-1함수는 AppScope-1을 참조합니다.

2 ) handleClickB Click

  • handleClickB 함수를 처음 클릭하면 countB가 변경되므로 handleClickB 함수가 재생성되어 handleClickB-1 함수가 만들어집니다.
  • 하지만 countA는 변경되지 않았으므로 handleClickA-1 함수는 재생성되지 않습니다.
  • handleClickA-1 함수는 AppScope-1을 참조하게 되고 handleClickB-1 함수는 AppScope-2를 참조하게 되면서 무한으로 클로저 체이닝이 발생하게 되고 bigData 때문에 메모리가 무한으로 늘어나게 됩니다.

3) 실제 메모리 모습

4) 정리하기

  • 단일 컴포넌트에서 서로 다른 useCallback 함수들이 클로저 스코프를 통해 서로와 다른 데이터를 참조할 수 있다는 문제가 있습니다. 이러한 클로저들은 useCallback 함수들이 재생성될 때까지 메모리에 유지됩니다. 컴포넌트에 useCallback 함수가 여러 개 있으면 메모리에 무엇이 보관되고 언제 해제되는지 이해하기 매우 어려워지며 해당 문제 겪을 가능성이 높아집니다.

5. 메모리 누수를 피하는 방법

1) 클로저 스코프를 가능한 작게 유지하기

  • 더 작은 컴포넌트를 생성하거나 커스텀 훅을 활용한다면 새 클로저를 생성할 때 스코프를 작게 유지할 수 있습니다.

2) 메모제이션한 함수를 다른 함수에서 참조하지 않기

  • 여러 개의 함수들을 작성하고 서로 호출하는 경우, useCallback를 적용한 함수를 참조하게 된다면 클로저 체이닝이 발생할 수 있습니다.

3) 불필요한 메모이제이션 피하기

  • useCallbackuseMemo는 불필요한 재렌더링을 피하기 위한 훌륭한 도구이지만, 그만큼 비용이 따르기 때문에 렌더링으로 인한 성능 문제가 발생했을 때만 사용해야 합니다.

4) 큰 객체에는 useRef 사용하기

  • 메모리를 많이 사용하는 큰 객체인 경우 useRef를 사용하여 생명주기를 직접 관리합니다.

6. 결론

  • 클로저는 React에서 함수가 마지막으로 렌더링될 때의 props와 state를 기억할 수 있게 해주는 중요한 장치입니다.
  • 그러나 큰 객체와 결합된 useCallback 같은 메모이제이션 기술은 예상치 못한 메모리 누수를 일으킬 수 있습니다.
  • 이를 방지하기 위해 클로저 스코프를 작게 유지하고, 필요하지 않은 경우 메모이제이션을 피하며, 큰 객체에는 useRef를 사용하는 것이 좋습니다.

7. .참고

profile
아직 나는 취해있을 수 없다...

0개의 댓글