리액트 메모리 누수, 컴파일러가 해결하지 못하는 이유

0

리액트를 사용하는 개발자라면 메모리 누수 문제가 얼마나 성가신지 잘 알고 있을 겁니다. 특히 클로저와 메모이제이션 훅인 `useCallback`이나 `useEffect`를 사용할 때, 문제가 발생하기 쉬운데요. 이 글에서는 새로운 리액트 컴파일러가 이 문제를 얼마나 해결해줄 수 있는지, 또 해결하지 못하는 부분은 무엇인지 자세히 살펴보겠습니다.


리액트 컴파일러의 한계, 메모리 누수를 방지하지 못한다

새로운 리액트 컴파일러가 메모리 누수 문제를 완벽하게 해결해 줄 거라고 기대하셨다면, 이 부분에서 조금 실망하실지도 모르겠습니다. [리액트 컴파일러는 특정 조건에서 클로저로 인한 메모리 누수를 방지할 수 있지만, 모든 경우에 적용되는 것은 아닙니다.] 리액트 팀은 컴파일러가 특정 메모이제이션된 값을 캐싱함으로써 클로저로 인한 메모리 누수를 방지할 수 있다고 설명하지만, 여전히 클로저가 참조하는 값에 따라 메모리 누수는 발생할 수 있습니다.

예를 들어, 아래 코드를 보면 메모이제이션된 `useCallback` 함수에서 상태를 변경할 때마다 새로운 `BigObject` 인스턴스가 생성되며, 가비지 컬렉션에 의해 수거되지 않는 것을 확인할 수 있습니다.

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

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

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

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

이 코드를 리액트 컴파일러에서 실행하면, 메모리 스냅샷에서 `BigObject` 인스턴스가 계속해서 할당되는 것을 볼 수 있습니다. 이는 상태 변경에 따라 새로운 인스턴스가 생성되며, 메모리 누수가 발생하게 되는 겁니다.

문제를 해결하기 위한 접근 방식

이 문제를 해결하는 방법 중 하나는 클로저를 완전히 우회하는 것입니다. 예를 들어, `bind` 메서드를 사용하여 함수에 직접 값을 전달하면, 클로저 간에 공유되는 컨텍스트 객체에 의존하지 않게 됩니다. 이렇게 하면 메모리 누수를 방지할 수 있습니다.

아래는 `bind`를 사용한 코드 예시입니다:

import { useState } from 'react';

class BigObject {
  constructor(public state: string) {}
  public readonly data = new Uint8Array(1024 * 1024 * 10);
}

function bindNull<U extends unknown[]>(f: (args: U) => void, x: U): () => void {
  return f.bind(null, x);
}

export const App = () => {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);
  const bigData = new BigObject(`${countA}/${countB}`);

  const handleClickA = bindNull(([count, setCount]) => {
    setCount(count + 1);
  }, [countA, setCountA] as const);

  const handleClickB = bindNull(([count, setCount]) => {
    setCount(count + 1);
  }, [countB, setCountB] as const);

  const handleClickBoth = bindNull(([countA, setCountA, countB, setCountB]) => {
    setCountA(countA + 1);
    setCountB(countB + 1);
    console.log(bigData.data.length);
  }, [countA, setCountA, countB, setCountB] as const);

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

이 코드에서는 메모리 누수가 발생하지 않고, 리액트 컴파일러에서 예상대로 작동합니다.

결론

리액트 컴파일러는 코드 최적화를 통해 성능을 향상시킬 수 있지만, 모든 메모리 누수를 방지할 수는 없습니다. [리액트를 사용할 때는 클로저와 메모이제이션의 사용에 주의하고, 메모리 누수를 예방하기 위한 모범 사례를 따라야 합니다.] 작고 순수한 컴포넌트를 작성하고, 메모리 프로파일러를 사용하여 문제를 식별하는 것이 중요합니다.

이 문제에 대한 깊은 이해와 해결책을 찾으려는 노력은 여러분의 리액트 개발 실력을 한층 더 높여줄 것입니다. 오늘 당장 코드에서 메모리 누수를 점검해 보세요!

참고 자료: Schiener.io, “Sneaky React Memory Leaks: How the React compiler won’t save you”

답글 남기기