Electron Internals: Weak References
가비지 컬렉션을 지원하는 언어인 자바스크립트는 사용자가 직접 리소스를 관리할 필요가 없다. 하지만 Electron은 이러한 환경을 호스팅하기 때문에 메모리와 리소스 누수를 방지하는 데 각별히 주의해야 한다.
이 글에서는 약한 참조(weak reference)의 개념과 Electron에서 리소스를 관리하기 위해 어떻게 활용하는지 소개한다.
약한 참조(Weak References)
자바스크립트에서 객체를 변수에 할당하면 해당 객체에 대한 참조가 추가된다. 객체에 대한 참조가 남아 있는 한, 객체는 메모리에 계속 유지된다. 객체에 대한 모든 참조가 사라지면, 즉 객체를 저장하는 변수가 더 이상 없으면, 자바스크립트 엔진은 다음 가비지 컬렉션에서 해당 메모리를 회수한다.
약한 참조(Weak Reference)는 객체를 가져올 수 있도록 하면서도, 가비지 컬렉션 여부에 영향을 미치지 않는 참조 방식이다. 또한 객체가 가비지 컬렉션될 때 알림을 받을 수 있어, 자바스크립트로 리소스를 관리하는 것이 가능해진다.
Electron의 NativeImage
클래스를 예로 들면, nativeImage.create()
API를 호출할 때마다 NativeImage
인스턴스가 반환되고, 이 인스턴스는 C++에서 이미지 데이터를 저장한다. 인스턴스를 더 이상 사용하지 않고 자바스크립트 엔진(V8)이 객체를 가비지 컬렉션하면, C++ 코드가 호출되어 메모리의 이미지 데이터를 해제한다. 따라서 사용자가 이를 수동으로 관리할 필요가 없다.
또 다른 예로는 윈도우 사라짐 문제가 있다. 이 예제는 윈도우에 대한 모든 참조가 사라졌을 때 윈도우가 어떻게 가비지 컬렉션되는지를 시각적으로 보여준다.
Electron에서 약한 참조 테스트하기
자바스크립트는 약한 참조를 직접 할당할 수 있는 방법을 제공하지 않기 때문에, 순수 자바스크립트에서는 약한 참조를 직접 테스트할 방법이 없다. 자바스크립트에서 약한 참조와 관련된 유일한 API는 WeakMap이지만, 이 API는 약한 참조 키만 생성하기 때문에 객체가 가비지 컬렉션(Garbage Collection)되었는지 알 수 없다.
Electron v0.37.8 이전 버전에서는 내부 v8Util.setDestructor
API를 사용해 약한 참조를 테스트할 수 있다. 이 API는 전달된 객체에 약한 참조를 추가하고, 객체가 가비지 컬렉션되면 콜백을 호출한다:
// 아래 코드는 Electron < v0.37.8 버전에서만 실행 가능.
var v8Util = process.atomBinding('v8_util');
var object = {};
v8Util.setDestructor(object, function () {
console.log('The object is garbage collected');
});
// 객체에 대한 모든 참조를 제거.
object = undefined;
// 수동으로 GC를 실행.
gc();
// 콘솔에 "The object is garbage collected" 출력.
이 API를 사용하려면 --js-flags="--expose_gc"
커맨드 스위치와 함께 Electron을 실행해 내부 gc
함수를 노출시켜야 한다.
이 API는 이후 버전에서 제거되었다. V8은 실제로 소멸자에서 자바스크립트 코드를 실행하는 것을 허용하지 않으며, 이후 버전에서 이를 사용하면 무작위로 크래시가 발생할 수 있기 때문이다.
remote
모듈에서의 약한 참조
Electron은 C++로 네이티브 리소스를 관리할 뿐만 아니라, 자바스크립트 리소스를 관리하기 위해 약한 참조(weak reference)도 필요로 한다. 대표적인 예가 Electron의 remote
모듈이다. 이 모듈은 원격 프로시저 호출(Remote Procedure Call, RPC)을 가능하게 하여, 렌더러 프로세스에서 메인 프로세스의 객체를 사용할 수 있도록 한다.
remote
모듈에서 가장 큰 도전은 메모리 누수를 피하는 것이다. 렌더러 프로세스에서 사용자가 원격 객체를 획득하면, remote
모듈은 렌더러 프로세스에서의 참조가 사라질 때까지 메인 프로세스에서 해당 객체가 계속 살아있도록 보장해야 한다. 동시에, 렌더러 프로세스에서 더 이상 참조가 없을 때 객체가 가비지 컬렉션될 수 있도록 해야 한다.
예를 들어, 적절한 구현이 없다면 다음 코드는 빠르게 메모리 누수를 일으킬 것이다:
const { remote } = require('electron');
for (let i = 0; i < 10000; ++i) {
remote.nativeImage.createEmpty();
}
remote
모듈의 리소스 관리 방식은 간단하다. 객체가 요청될 때마다 메인 프로세스로 메시지를 보내고, Electron은 해당 객체를 맵에 저장한 후 ID를 할당하고, 이 ID를 렌더러 프로세스로 보낸다. 렌더러 프로세스에서는 remote
모듈이 이 ID를 받아 프록시 객체로 감싸고, 프록시 객체가 가비지 컬렉션되면 메인 프로세스로 객체를 해제하라는 메시지를 보낸다.
remote.require
API를 예로 들면, 간략화된 구현은 다음과 같다:
remote.require = function (name) {
// 메인 프로세스에 모듈의 메타데이터를 요청한다.
const meta = ipcRenderer.sendSync('REQUIRE', name);
// 프록시 객체를 생성한다.
const object = metaToValue(meta);
// 프록시 객체가 가비지 컬렉션될 때 메인 프로세스에 객체를 해제하라고 알린다.
v8Util.setDestructor(object, function () {
ipcRenderer.send('FREE', meta.id);
});
return object;
};
메인 프로세스에서는 다음과 같이 처리한다:
const map = {};
const id = 0;
ipcMain.on('REQUIRE', function (event, name) {
const object = require(name);
// 객체에 대한 참조를 추가한다.
map[++id] = object;
// 객체를 메타데이터로 변환한다.
event.returnValue = valueToMeta(id, object);
});
ipcMain.on('FREE', function (event, id) {
delete map[id];
});
약한 값을 가진 맵
이전의 간단한 구현에서는 remote
모듈의 모든 호출이 메인 프로세스에서 새로운 원격 객체를 반환한다. 각 원격 객체는 메인 프로세스에 있는 객체에 대한 참조를 나타낸다.
이 디자인 자체는 문제가 없지만, 동일한 객체를 여러 번 받기 위해 여러 번 호출할 경우 여러 프록시 객체가 생성된다. 복잡한 객체의 경우 이는 메모리 사용량과 가비지 컬렉션에 큰 부담을 줄 수 있다.
예를 들어, 다음 코드를 보자:
const { remote } = require('electron');
for (let i = 0; i < 10000; ++i) {
remote.getCurrentWindow();
}
이 코드는 먼저 프록시 객체를 생성하는 데 많은 메모리를 사용하고, 그 후 가비지 컬렉션과 IPC 메시지 전송을 위해 CPU를 점유한다.
명백한 최적화 방법은 원격 객체를 캐싱하는 것이다: 동일한 ID를 가진 원격 객체가 이미 있다면, 새로운 객체를 생성하는 대신 이전 객체를 반환한다.
이는 JavaScript 코어의 API로는 불가능하다. 일반적인 맵을 사용해 객체를 캐싱하면 V8이 객체를 가비지 컬렉션하는 것을 막는다. 반면에 WeakMap 클래스는 객체를 약한 키로만 사용할 수 있다.
이 문제를 해결하기 위해, 값이 약한 참조인 맵 타입이 추가되었다. 이는 ID를 가진 객체를 캐싱하는 데 완벽하다. 이제 remote.require
는 다음과 같이 동작한다:
const remoteObjectCache = v8Util.createIDWeakMap()
remote.require = function (name) {
// 메인 프로세스에 모듈의 메타 데이터를 반환하라고 요청한다.
...
if (remoteObjectCache.has(meta.id))
return remoteObjectCache.get(meta.id)
// 프록시 객체를 생성한다.
...
remoteObjectCache.set(meta.id, object)
return object
}
remoteObjectCache
는 객체를 약한 참조로 저장하기 때문에, 객체가 가비지 컬렉션될 때 키를 삭제할 필요가 없다.
네이티브 코드
Electron에서 약한 참조(weak references)의 C++ 코드에 관심이 있다면 다음 파일에서 확인할 수 있다.
setDestructor
API 관련 파일:
createIDWeakMap
API 관련 파일: