[번역] 자바스크립트 가비지 컬렉터 실험

date
Jul 25, 2023
slug
experiments-with-the-javascript-garbage-collector
author
status
Public
tags
summary
type
Post
thumbnail
category
updatedAt
Mar 7, 2024 01:46 PM
원문 : https://dev.to/codux/experiments-with-the-javascript-garbage-collector-2ae3
웹 애플리케이션의 메모리 누수는 광범위하고 디버깅하기 어렵기로 악명이 높습니다. 이를 방지하려면 가비지 컬렉터가 수집할 수 있는 객체와 수집할 수 없는 객체를 결정하는 방식을 이해하는 것이 도움이 됩니다. 이 글에서는 여러분이 의외라고 느낄 수 있는 가비지 컬렉터의 몇 가지 동작 시나리오를 소개하려 합니다.
가비지 컬렉션의 기본에 익숙하지 않다면 Lin Clark의 메모리 관리 초급 과정이나 MDN의 메모리 관리가 좋은 출발점이 될 수 있습니다. 계속 읽기 전에 이 중 하나를 읽어보시기 바랍니다.

객체 삭제 감지

최근에 저는 자바스크립트에서 객체가 가비지 컬렉터에 수집되는 시점을 프로그래밍 방식으로 감지할 수 있는 FinalizationRegistry라는 클래스를 제공한다는 사실을 알게 되었습니다. 이 클래스는 모든 주요 웹 브라우저와 Node.js에서 사용할 수 있습니다.
기본적인 사용 예시를 살펴보겠습니다.
const registry = new FinalizationRegistry(message => console.log(message)); function example() { const x = {}; registry.register(x, 'x has been collected'); } example(); // 얼마 후: "x has been collected"
example() 함수가 반환되면 x 객체에 더 이상 접근할 수 없으므로 삭제할 수 있습니다.
하지만 대부분 즉시 폐기되지는 않습니다. 엔진은 더 중요한 작업을 먼저 처리하거나 더 많은 객체에 접근할 수 없는 상태가 될 때까지 기다렸다가 일괄 처리하도록 결정할 수 있습니다. 하지만 개발자 도구 ➵ 메모리 탭에서 작은 휴지통 아이콘을 클릭하여 가비지 컬렉션을 강제로 처리할 수 있습니다. Node.js에는 휴지통 아이콘이 없지만 --expose-gc 플래그와 함께 실행하면 전역 gc() 함수를 제공합니다.
notion image
FinalizationRegistry를 활용하여 가비지 컬렉터가 어떻게 작동할지 확실하지 않은 몇 가지 시나리오를 살펴보기로 했습니다. 아래 예시를 보고 어떻게 동작할지 직접 예측해 보시기 바랍니다.

예시 1. 중첩된 객체

const registry = new FinalizationRegistry(message => console.log(message)); function example() { 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'); globalThis.temp = x; } example();
여기서 example() 함수가 반환된 후 변수 x가 더 이상 존재하지 않더라도 x가 참조하는 객체는 여전히 globalThis.temp 변수에 의해 유지됩니다. 반면에 z와 y는 글로벌 객체나 실행 스택에서 더 이상 도달할 수 없으므로 수집됩니다. 이제 globalThis.temp = undefined를 실행하면 이전에 x로 알려진 객체도 수집됩니다. 여기서 놀랄 일은 없습니다.

예시 2. 클로저

const registry = new FinalizationRegistry(message => console.log(message)); function example() { 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'); globalThis.temp = () => z.x; } example();
이 예시에서는 globalThis.temp()를 호출하면 여전히 x에 접근할 수 있습니다. 하지만 더 이상 접근할 수 없음에도 불구하고 z와 y는 수집되지 않습니다.
한 가지 가능한 이론은 z.x가 프로퍼티 조회이므로 엔진이 조회를 x에 대한 직접 참조로 대체할 수 있는지를 모른다는 것입니다. 예를 들어 x가 게터인 경우 어떻게 될까요? 따라서 엔진은 z에 대한 참조를 유지해야 하고 결과적으로 y에 대한 참조를 유지해야 합니다. 이 이론을 테스트하기 위해 globalThis.temp = () => { z; };로 예시를 수정해 보겠습니다. 이제 z에 접근할 수 있는 방법은 분명히 없지만 여전히 수집되지 않습니다.
가비지 컬렉터가 temp에 할당된 클로저의 렉시컬 스코프에 z가 있다는 사실에만 주의를 기울이고 그 이상은 보지 않기 때문이라고 생각합니다. 전체 객체 그래프를 탐색하고 아직 '살아있는' 객체를 표시하는 작업은 성능이 매우 중요한 작업으로, 속도가 빨라야 합니다. 가비지 컬렉터가 이론적으로 z가 사용되지 않는다는 것을 알아낼 수 있다고 해도 비용이 많이 듭니다. 그리고 코드에는 일반적으로 그냥 잠자고 있는 변수가 포함되지 않기 때문에 특별히 유용하지 않습니다.

예시 3. Eval

const registry = new FinalizationRegistry(message => console.log(message)); function example() { const x = {}; registry.register(x, 'x has been collected'); globalThis.temp = (string) => eval(string); } example();
여기서도 temp('x')를 호출하여 전역 범위에서 x에 접근할 수 있습니다. 엔진은 eval의 렉시컬 스코프 내에 있는 어떤 객체도 안전하게 수집할 수 없습니다. 그리고 eval이 어떤 인자를 받는지 분석하려고 시도하지도 않습니다. globalThis.temp = () => eval(1)과 같은 무해한 것조차 가비지 컬렉터에 수집되는 것을 방지합니다.
globalThis.exec = eval과 같이 eval이 별칭 뒤에 숨어 있다면 어떻게 해야 할까요? 아니면 명시적으로 언급되지 않은 채 사용된다면 어떨까요? 예를 들어 다음과 같은 코드를 작성할 수 있습니다.
console.log.constructor('alert(1)')(); // alert 박스를 엽니다.
모든 함수 호출은 eval을 숨기고 있을 가능성이 있으며, 그 어떤 것도 안전하게 수집할 수 없다는 뜻일까요? 다행히도 아닙니다. 자바스크립트는 direct eval과 indirect eval을 구분합니다. 직접 eval(string)을 호출할 때만 현재 렉시컬 스코프에서 코드를 실행합니다. 하지만 eval?.(string)과 같이 조금이라도 덜 직접적으로 호출하면 전역 범위에서 코드가 실행되며 둘러싸는 함수의 변수에 접근할 수 없습니다.

예시 4. DOM 엘리먼트

const registry = new FinalizationRegistry(message => console.log(message)); function example() { 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'); globalThis.temp = x; } example();
이 예시는 첫 번째 예시와 다소 유사하지만 일반 객체 대신 DOM 엘리먼트를 사용합니다. 일반 객체와 달리 DOM 엘리먼트에는 부모 및 형제자매에 대한 링크가 있습니다. temp.parentElement를 통해 z에 접근할 수 있고, temp.nextSibling을 통해 y에 접근할 수 있습니다. 따라서 세 요소는 모두 살아 있습니다.
이제 temp.remove()를 실행하면 x가 부모로부터 분리되었으므로 y와 z가 수집됩니다. 하지만 x는 여전히 temp에서 참조하고 있기 때문에 수집되지 않습니다.

예시 5. 프로미스

경고: 이 예시는 비동기 연산 및 프로미스와 관련된 시나리오를 보여주는 좀 더 복잡한 예시입니다. 이 예시는 건너뛰고 아래 요약으로 넘어가셔도 됩니다.
이행되지 않거나 거부된 프로미스는 어떻게 되나요? .then의 전체 체인이 연결된 채로 메모리에 계속 떠돌아다니게 될까요?
현실적인 예로, 다음은 리액트 프로젝트에서 흔히 볼 수 있는 안티 패턴입니다.
function MyComponent() { const isMounted = useIsMounted(); const [status, setStatus] = useState(''); useEffect(async () => { await asyncOperation(); if (isMounted()) { setStatus('Great success'); } }, []); return <div>{status}</div>; }
asyncOperation()이 이행되지 못하면 이펙트 함수는 어떻게 될까요? 컴포넌트가 마운트 해제된 후에도 계속 프로미스를 기다릴까요? isMounted와 setStatus를 계속 유지할까요?
이 예시를 리액트를 사용하지 않는 더 기본적인 형태로 축소해 보겠습니다.
const registry = new FinalizationRegistry(message => console.log(message)); function asyncOperation() { return new Promise((resolve, reject) => { /* 절대 이행되지 않음 */ }); } function example() { const x = {}; registry.register(x, 'x has been collected'); asyncOperation().then(() => console.log(x)); } example();
앞서 가비지 컬렉터는 어떤 종류의 정교한 분석도 수행하지 않고 단지 객체에서 객체로 이동하는 포인터를 따라 '활성 상태'를 판단하는 것을 보았습니다. 따라서 이 경우 x가 수집된다는 것은 놀라운 일이 아닐 수 없습니다!
무언가가 여전히 프로미스 resolve에 대한 참조를 보유하고 있을 때 이 예시가 어떻게 보일지 살펴봅시다. 실제 시나리오에서는 setTimeout() 또는 fetch()가 될 수 있습니다.
const registry = new FinalizationRegistry(message => console.log(message)); function asyncOperation() { return new Promise((resolve) => { globalThis.temp = resolve; }); } function example() { const x = {}; registry.register(x, 'x has been collected'); asyncOperation().then(() => console.log(x)); } example();
여기서 globalThis는 temp 를 유지하고, resolve 를 유지하고, .then(...) 콜백을 유지하며, x를 유지합니다. globalThis.temp = undefined를 실행하자마자 x를 수집할 수 있습니다. 참고로 프로미스 자체에 대한 참조를 저장해도 x가 수집되는 것을 막지는 못합니다.
리액트 예시로 돌아가서, 무언가가 여전히 프로미스 resolve에 대한 참조를 보유하고 있다면 컴포넌트가 마운트 해제된 후에도 이펙트와 렉시컬 스코프에 있는 모든 것이 살아 있을 것입니다. 프로미스가 이행되거나 가비지 컬렉터가 더 이상 프로미스의 resolve 및 reject에 대한 경로를 추적할 수 없을 때 수집됩니다.

결론

이 글에서는 FinalizationRegistry와 이를 사용해 객체가 수집되는 시점을 감지하는 방법을 살펴봤습니다. 또한 가비지 컬렉터가 메모리를 회수하는 것이 안전할 때도 메모리를 회수하지 못하는 경우가 있다는 사실도 확인했습니다. 그렇기 때문에 가비지 컬렉터가 할 수 있는 일과 할 수 없는 일을 알아두는 것이 도움이 됩니다.
자바스크립트 엔진마다 심지어 같은 엔진의 버전마다 가비지 컬렉터의 구현이 크게 다를 수 있으며, 외부에서 관찰할 수 있는 차이점도 존재한다는 점에 주목할 필요가 있습니다.
실제로 ECMAScript 사양은 특정 동작을 규정하는 것은 말할 것도 없고 구현에 가비지 컬렉터도 요구하지 않습니다.
그러나 위의 모든 예시는 V8(Chrome), JavaScriptCore(Safari), Gecko(Firefox)에서 동일하게 작동하는 것으로 확인되었습니다.