JS 가비지 컬렉터 이해하기

우동이·2023년 4월 5일
1

JavaScript

목록 보기
2/9

  • 웹 어플리케이션 내부에서 메모리 누수는 범위가 크고 추적하기가 어려워서 나중에 대응하는데 어려움이 존재합니다.
  • 그래서 가비지 컬렉터가 어떻게 동작하는지 이해한다면 충분히 도움이 될 수 있을 것이라고 생각합니다!

객체 삭제 감지

  • 자바스크립트는 객체가 가비지 컬렉터에 수집되는 시점을 감지하는 FinalizationRegistry API를 제공합니다.
  • 실제로는 가비지 컬렉터가 수집을 해도 즉시 폐기하지는 않고 더 중요한 작업을 먼저 수행한 다음 나중에 일괄 처리한다고 합니다.
const registry = new FinalizationRegistry((message) => console.log(message));

function removeObject() {
  const x = {};
  registry.register(x, "x has been collected");
}

// 시간이 지난 후 "x has been collected" 출력
removeObject();
  • 만약 강제로 빠르게 처리하고 싶으면 개발자 도구 > 메모리 > 휴지통 아이콘 클릭을 통해 제거할 수 있습니다.

시나리오

1. 중첩된 객체

const registry = new FinalizationRegistry((message) => console.log(message));

function nestedObject() {
  const x = {};
  const y = {};
  const z = { x, y };

  registry.register(x, "x has been collected");
  registry.register(y, "y has been collected");
  registry.register(z, "z has been collected");

  window.temp = x;
}

nestedObject();

결과

  • window.temp가 참조하고 있는 x는 가비지 컬렉터에 수집되지 않으며 zy만 참조하는 값이 없음으로 가비지 컬렉터에게 수집됩니다.
  • 즉 알아서 최적화를 해주고 있다는 것을 볼 수 있습니다!
  • 그리고 다시 window.temp = undefined를 실행하면 x가비지 컬렉터의 수집 대상으로 변경됩니다.

2. 클로저

const registry = new FinalizationRegistry((message) => console.log(message));

function closer() {
  const x = {};
  const y = {};
  const z = { x, y };

  registry.register(x, "x has been collected");
  registry.register(y, "y has been collected");
  registry.register(z, "z has been collected");

  // 클로저 이용
  window.temp = () => z.x;
}

closer();

결과

  • 당연히 window.temp에 저장되어 있는 x가바지 컬렉터에게 수집 대상에서 제외됩니다.
  • 하지만 더 이상 접근할 수 없는 yz는 마찬가지로 가비지 컬렉터에게 대상에서 제외됩니다.
  • 자바스크립트 엔진은 z.x로 접근하게 되면 x에 대한 직접 참조가 아닌 z를 통해 참조하는 것으로 판단하여 z에 대한 참조를 유지해야 하고 결국 y의 참조도 유지하게 되는 것입니다.
  • 또한 window.temp = () => z; 이렇게 z를 클로저로 참조할 경우 마찬가지로 모두 수집하지 않습니다.
  • 정리하자면 가바지 컬렉터는 참조하고 있는 객체를 판별할 때 대체할 수 있는 참조값을 모른다는 것이고 해당 기능을 수행하지 않는다는 것을 알 수 있습니다.

3. Eval

  • eval 은 자바스크립트에서 제공하는 함수로 문자로 표현된 자바스크립트 코드를 실행하는 함수이빈다.
// eval(string) =>  output: 4
console.log(eval('2 + 2'));
const registry = new FinalizationRegistry((message) => console.log(message));

function executionEval() {
  const x = {};

  registry.register(x, "x has been collected");

  window.temp = (string) => eval(string);
}

executionEval();

결과

  • x를 참조하는 곳이 없더라도 가비지 컬렉터x를 수집하지 않습니다.
  • 그러다 보니 windonw.temp('x')를 통해 x를 접근할 수 있게 됩니다.
  • 이런 현상이 발생한 이유는 내부에 eval 함수가 렉시컬 스코프에 존재할 경우 어떤 객체도 수집하지 않습니다.

eval을 사용할 때 가비지 컬렉터가 동작하게 할 수 있을까?

  • eval의 특징을 사용하면 가능합니다.
  • 자바스크립트의 evaldirect evalindirect eval로 구분됩니다.
    • direct eval: 직접 eval을 호출, 지역 스코프를 참조
    • indirect eval: 다른 변수에 담아서 eval을 참조 형태로 호출, 무조건 글로벌 스코프를 참조
const registry = new FinalizationRegistry((message) => console.log(message));

function executionEval() {
  const x = {};

  registry.register(x, "x has been collected");

  // 변수에 한번 담아서 사용할 경우 x는 가비지 컬렉터가 수집합니다.
  const evalFunction = eval
  window.temp = (string) => evalFunction(string);
}

executionEval();

4. DOM 엘리먼트

const registry = new FinalizationRegistry((message) => console.log(message));

function domElement() {
  const x = document.createElement("div");
  const y = document.createElement("div");
  const z = document.createElement("div");

  z.append(x);
  z.append(y);

  registry.register(x, "x has been collected");
  registry.register(y, "y has been collected");
  registry.register(z, "z has been collected");

  window.temp = x;
}

domElement();

결과

  • 일반적인 객체와 다르게 부모인 z엘리먼트와 자식인 xy는 서로를 참조하고 연결되어 있기 때문에 모두 가비지 컬렉터에 수집되지 않습니다.
  • 이유는 당연히 z 엘리먼트에서 xy를 접근할 수 있기 때문입니다.
  • 만약에 window.temp.remove()를 통해 부모를 제거한다면 바로 xy가비지 컬렉터가 수집합니다.

5. 프로미스

const registry = new FinalizationRegistry((message) => console.log(message));

function asyncOperation() {
  return new Promise((resolve, reject) => {
    // 아무것도 실행하지 않음
  });
}

function promise() {
  const x = {};

  registry.register(x, "x has been collected");

  // then이 실행되지 않음
  asyncOperation().then(() => console.log(x));
}

promise();

결과

  • 앞에서 가바지 컬렉터는 정밀하게 분석을 하지 않는다는 것을 알게 되었는데 여기서는 신기하게도 x가비지 컬렉터가 수집합니다.

만약 resolve에 대한 참조를 가지고 있다면?

  • 여기서는 신기하게도 resolve를 실행하지 않더라도 a가비지 컬렉터가 수집하지 않습니다.
  • window.temp()를 실행하게 되면 x가 출력되는 것을 볼 수 있습니다.
  • 즉 정리하자면 Promise가 이행되거나 가비지 컬렉터가 더 이상 프로미스의 resolvereject에 대한 경로를 추적할 수 없을 때 수집합니다.
const registry = new FinalizationRegistry((message) => console.log(message));

function asyncOperation() {
  return new Promise((resolve, reject) => {
    window.temp = resolve;
  });
}

function promise() {
  const x = {};

  registry.register(x, "x has been collected");

  asyncOperation().then(() => console.log(x));
}

promise();

React에서의 안티패턴

  • 리액트에서 명시하고 있는 안티 패턴에 대한 내용과도 연관이 있습니다.
  • 무언가가 계속 resolve를 참조하고 있다면 컴포넌트가 마운트 해제된 이후에도 useEffect와 렉시컬 스코프에 있는 모든 것들이 가비지 컬렉터에 수집되지 않고 살아있게 되면서 메모리 누수가 발생합니다.
  • asyncOperation가 이행되지 않는다면 컴포넌트가 unMounted 되더라도 isMountedsetStatus는 계속 유지됩니다.
function MyComponent() {
    const isMounted = useIsMounted();
    const [status, setStatus] = useState('');

    useEffect(async () => {
   
        await asyncOperation();
        if (isMounted()) {
            setStatus('Great success');
        }
    }, []);

    return <div>{status}</div>;
}

정리하기

  • 가비지 컬렉터가 메모리를 회수하는 과정에서 여러 시나리오들을 이해하고 사전에 방지한다면 웹 어플리케이션에 대한 성능을 더 끌어올릴 수 있을 것입니다.
  • 그리고 자바스크립트 엔진마다 또는 다른 버전마다 가비지 컬렉터가 수행하는 시나리오가 다를 수 있습니다. 하지만 여기서 소개된 시나리오는 V8(Chrome), JavaScriptCore(Safari), Gecko(Firefox)에서 동일하게 작동합니다!

참고

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

0개의 댓글

관련 채용 정보