약한 참조와 Finalizer


원문: https://v8.dev/features/weak-references
저자: Sathya Gunasekaran @_gsathya, Mathias Bynens @mathias, Shu-yu Guo @_shu, Leszek Swirski @leszekswirski
라이선스: CC BY 3.0

일반적으로 자바스크립트에서 객체의 참조는 강하게 유지된다고 한다. 이 말은 객체에 대한 참조가 있는 한 가비지 컬렉션이 일어나지 않는다는 뜻이다.

const ref = { x: 42, y: 51 };
// `ref`에 접근할 수 있는 (또는 이 객체에 다른 참조가 있는) 한 가비지 컬렉션이 일어나지 않는다.

현재 WeakMapWeakSet은 자바스크립트에서 객체의 약한 참조를 만드는 유일한 방법으로 WeakMap이나 WeakSet에 객체를 키로 추가하면 가비지 컬렉션을 막지 않는다.

const wm = new WeakMap();
{
  const ref = {};
  const metaData = 'foo';
  wm.set(ref, metaData);
  wm.get(ref); // metaData
}
// 블록 스코프 내 `ref`에 대한 참조가 없다면, `wm`의 키로 접근이 가능하더라도
// 가비지 컬렉션이 일어날 수 있다. 

const ws = new WeakSet();
{
  const ref = {};
  ws.add(ref);
  ws.has(ref); // true
}
// 블록 스코프 내 `ref`에 대한 참조가 없다면, `ws`의 키로 접근이 가능하더라도
// 가비지 컬렉션이 일어날 수 있다.

참조:  WeakMap.prototype.set(ref, metaData)ref 객체에 metaData 값을 속성으로 추가한다고 생각할 수도 있다. 객체의 참조를 갖고 있는 한 metaData를 가져올 수 있지만 객체에 참조가 더 이상 없게 된다면 객체가 WeakMap에서 추가되어 참조를 갖고 있더라도 가비지 컬렉션이 일어날 수 있다. 마찬가지로 WeakSet도 모든 값들이 부울인 WeakMap의 특별한 케이스로 생각할 수 있다.

자바스크립트의 WeakMap은 실제로 약하지 않다. 실제로 키가 살아있는 한 그 콘텐츠를 강하게 참조한다. WeakMap은 키가 가비지 컬렉션이 일어난 후에만 해당 콘텐츠를 약하게 참조한다. 이런 처리 방법을 ephemeron 이라 한다.

WeakRef실제로 약한 참조를 제공하는 좀 더 발전된 API로 객체의 수명을 확인할 수 있다. 예제를 통해 알아보도록 한다.

예를 들어, 웹 소켓을 사용하여 서버와 통신하는 채팅 웹 애플리케이션을 만든다고 하자. 그리고 성능 진단을 목적으로 이동에 걸리는 평균 지연 시간을 계산하기 위해 웹 소켓에 이벤트들을 보관하는 MovingAvg 클래스가 있다고 해보자.

class MovingAvg {
  constructor(socket) {
    this.events = [];
    this.socket = socket;
    this.listener = (ev) => { this.events.push(ev); };
    socket.addEventListener('message', this.listener);
  }

  compute(n) {
    // N 이벤트의 평균 이동 시간을 계산한다.
    // …
  }
}

이것은 다음에 나올 이동의 평균 지연 시간에 대해 모니터링을 시작하고 중지할 수 있도록 제어하는 MovingAvgComponent 클래스에서 사용이 된다.

class MovingAvgComponent {
  constructor(socket) {
    this.socket = socket;
  }

  start() {
    this.movingAvg = new MovingAvg(this.socket);
  }

  stop() {
    // 가비지 컬렉터가 메모리를 회수할 수 있도록 허용한다.
    this.movingAvg = null;
  }

  render() {
    // 그리기.
    // …
  }
}

MovingAvg 인스턴스 내 모든 서버 메시지를 보관하는 것이 가장 많은 메모리를 사용하므로 모니터링이 중지되면 가비지 컬렉터가 메모리를 회수하도록 this.movingAvgnull을 할당한다.

하지만, DevTools 내 메모리 패널을 확인해보면, 메모리가 전혀 회수되지 않았다는 걸 발견하게 된다. 경력이 많은 웹 개발자들은 이미 버그를 발견했을 수도 있겠다. 답은 이벤트 리스너이다. 이 또한 강한 참조이므로 명시적으로 제거가 필요하다.

각 구성 요소 간 접근 관계를 그리는 다이어그램을 만들어 보자. start() 호출 후, 그래프는 다음과 같다. 실선 화살표는 강한 참조를 의미한다. MovingAvgComponent 인스턴스에서 실선 화살표를 통해 닿을 수 있는 모든 항목들은 가비지 컬렉션이 불가능하다.

1.png

stop()을 호출한 뒤 MovingAvgComponent 인스턴스에서 MovingAvg 인스턴스로의 강한 참조를 제거했지만 socket의 리스너를 통해서는 제거하지 않았다.

2.png

MovingAvg 인스턴스 내 리스너는 this를 참조하므로 이벤트 리스너가 제거되지 않는 한 전체 인스턴스를 활성 상태로 유지한다.

dispose 메서드를 통해 이벤트 리스너를 수동으로 해제하여 해결한다.

class MovingAvg {
  constructor(socket) {
    this.events = [];
    this.socket = socket;
    this.listener = (ev) => { this.events.push(ev); };
    socket.addEventListener('message', this.listener);
  }

  dispose() {
    this.socket.removeEventListener('message', this.listener);
  }

  // …
}

이 접근법엔 수동으로 메모리를 관리한다는 단점이 있다. MovingAvgComponentMovingAvg 클래스를 사용하는 모든 사용자들은 dispose를 호출해야 한다는 것을 기억하거나 메모리 누수에 시달릴 것이다. 설상가상으로 수동 메모리 관리가 단계적으로 처리되고 있다. MovingAvgComponent의 사용자들은 반드시 stop을 호출해야 한다는 것을 기억하거나 메모리 누수 등을 겪어야 한다. 애플리케이션의 동작은 이 진단 클래스의 이벤트 리스너에 종속되지 않으며 리스너는 메모리 사용 측면에서는 비용이 높지만 계산 측면에서는 그렇지 않다. 우리가 원하는 것은 리스너의 수명이 MovingAvg 인스턴스에 논리적으로 연결되어 가비지 컬렉터에 의해 메모리가 자동으로 회수되는 다른 자바스크립트 객체처럼 MovingAvg를 사용할 수 있도록 하는 것이다.

WeakRef는 실제 이벤트 리스너에 약한 참조를 생성한 다음 외부 이벤트 리스너에 WeakRef를 감싸 이 딜레마를 해결할 수 있다. 이렇게 하면 가비지 컬렉터가 실제 이벤트 리스너와 MovingAvg 인스턴스와 그것의 events 배열 같은 활성 상태 메모리를 정리할 수 있다.

function addWeakListener(socket, listener) {
  const weakRef = new WeakRef(listener);
  const wrapper = (ev) => { weakRef.deref()?.(ev); };
  socket.addEventListener('message', wrapper);
}

class MovingAvg {
  constructor(socket) {
    this.events = [];
    this.listener = (ev) => { this.events.push(ev); };
    addWeakListener(socket, this.listener);
  }
}

참조: WeakRef를 함수에 사용할 때는 주의해서 다뤄야 한다. 자바스크립트 함수는 클로저를 가지는데 함수 내에서 참조된 변수들을 포함하는 외부 환경에 강한 참조를 갖는다. 이런 외부 환경들에는 다른 클로저가 참조하는 변수도 포함될 수 있다. 즉, 클로저를 다룰 때, 메모리는 종종 미묘한 방식으로 다른 클로저에 의해 강한 참조를 갖는다. 이것이 addWeakListener가 별도의 함수로 분리되고 wrapperMovingAvg 생성자의 지역 변수가 아닌 이유이다. V8에서 wrapperMovingAvg 생성자의 지역 변수이고 WeakRef에 감싸진 리스너와 렉시컬 스코프를 공유할 경우 MovingAvg 인스턴스 및 모든 속성들이 wrapper 리스너에서 공유된 환경을 통해 도달할 수 있게 되고 인스턴스가 수거될 수 없게 된다. 코드를 작성할 때 이를 명심해야 한다.

먼저 MovingAvg 인스턴스에 의해 강하게 참조되도록 이벤트 리스너를 만들고 이를 this.listener에 할당한다. 즉, MovingAvg 인스턴스가 활성 상태이면 이벤트 리스너도 활성 상태가 된다.

addWeakListener에서 대상이 실제 이벤트 리스너인 WeakRef를 만들고 wrapper 내에서 deref 한다. WeakRef는 대상에 다른 강한 참조가 없는 경우 대상의 가비지 컬렉션을 막지 않으므로 대상을 가져오려면 수동으로 역참조를 해야 한다. 대상이 회수된 경우 derefundefined를 반환한다. 그렇지 않으면 원래 대상이 반환된다. 이를 listener 함수에서 옵셔널 체이닝을 사용하여 호출한다.

이벤트 리스너가 WeakRef로 감싸져 있으므로 이에 대한 강한 참조는 MovingAvg 인스턴스의 listener 프로퍼티이다. 즉, 이벤트 리스너의 수명을 MovingAvg 인스턴스의 수명에 성공적으로 연결한 것이다.

다시 다이어그램으로 돌아가 보면, 객체 그래프는 WeakRef 사용으로 start()를 호출한 후 다음과 같게 된다. 여기서 점선 화살표는 약한 참조를 나타낸다.

3.png

stop()을 호출한 뒤엔 리스너에 대한 강한 참조가 지워진다.

4.png

결국 가비지 컬렉션이 일어나면, MovingAvg 인스턴스와 리스너가 회수될 것이다.

5.png

여기에도 여전히 문제가 있다. WeakRef로 감싸서 listener에 간접적인 처리를 했지만 addEventListenerwrapperlistener가 누수된 것과 같은 이유로 여전히 누수되고 있다. 물론 전체 MovingAvg 인스턴스 대신 wrapper만 누수되기 때문에 작은 범위긴 하지만 누수는 누수다. 이에 대한 해결책은 WeakRefFinalizationRegistry 같은 기능이다. 새로운 FinalizationRegistry API를 사용하여 가비지 컬렉터가 등록 객체를 제거할 때 실행할 콜백을 등록할 수 있다. 이런 콜백을 finalizer라고 한다.

참조: finalizer는 이벤트 리스너를 가비지 컬렉션이 일어난 직후에 실행되지 않으므로 중요 로직이나 측정에 사용하면 안 된다. 가비지 컬렉션과 finalizer의 시점은 불분명하다. 사실 가비지 컬렉션이 없는 엔진에서는 완벽하게 호환된다. 그러나 탭이 닫힌다거나 워커가 종료되는 등 환경이 없어지지 않는 한 엔진이 가비지 컬렉션이 일어나거나 finalizer를 호출할 것이라 가정하는 것이 좋다. 코드를 작성할 때 이 불확실성을 염두에 두고 개발해야 한다.

내부 이벤트 리스너가 가비지 컬렉션이 일어날 때 socket에서 wrapper를 제거하기 위해 FinalizationRegistry로 콜백을 등록할 수 있다. 최종 구현은 다음과 같다.

const gListenersRegistry = new FinalizationRegistry(({ socket, wrapper }) => {
  socket.removeEventListener('message', wrapper); // 6
});

function addWeakListener(socket, listener) {
  const weakRef = new WeakRef(listener); // 2
  const wrapper = (ev) => { weakRef.deref()?.(ev); }; // 3
  gListenersRegistry.register(listener, { socket, wrapper }); // 4
  socket.addEventListener('message', wrapper); // 5
}

class MovingAvg {
  constructor(socket) {
    this.events = [];
    this.listener = (ev) => { this.events.push(ev); }; // 1
    addWeakListener(socket, this.listener);
  }
}

참조: gListenersRegistry는 finalizer가 실행되도록 하는 전역 변수이다. FinalizationRegistry는 등록된 객체를 활성 상태로 유지하지 않는다. 레지스트리 자체가 회수되는 경우 finalizer가 실행되지 않을 수 있다.

이벤트 리스너를 만들고 이를 this.listener에 할당하여 MovingAvg 인스턴스에서 강한 참조가 되도록 한다 (1). 그다음 수행하는 이벤트 리스너를 WeakRef로 감싸 가비지 컬렉션이 가능하게 만들고 this를 통해 MovingAvg 인스턴스 참조 누수가 일어나지 않게 한다 (2). WeakRefderef 하는 wrapper를 만들고 아직 활성인지 확인한 다음 맞는다면 호출한다 (3). 내부 리스너를 FinalizationRegistry에 등록하고 보유한 값인 { socket, wrapper }를 전달한다 (4). 그런 다음 wrappersocket의 이벤트 리스너로 추가한다 (5). MovingAvg 인스턴스와 내부 리스너가 회수된 후 언젠가 finalizer가 실행되면 보유한 값이 전달될 것이다. finalizer 내에서 wrapper도 제거하여 MovingAvg 인스턴스 사용과 관련된 모든 메모리를 가비지 컬렉션이 가능하게 만든다 (6).

이 모든 기능을 통해 MovingAvgComponent는 메모리 누수나 수동으로 메모리 회수를 할 필요가 없어진다.

과용하지 마라

이런 새로운 기능들에 대해 듣고 나면 모든 것에 WeakRef를 사용하고자 하는 유혹에 빠질 수 있다. 하지만 주의해야 한다. 어떤 것들은 확실히 WeakRef와 finalizer에 적합하지 않다.

보통 가비지 컬렉터가 WeakRef를 수거하는 것에 의존하거나, finalizer가 호출될 것이라 예측되는 시점에 의존하여 코드를 작성하면 안 된다. — 그렇게 될 수 없다! 더욱이 객체가 가비지 컬렉션이 가능한지에 대한 여부는 클로저같이 실제 세부 구현 사항에 따라 달라질 수 있다. 이는 미묘하고 자바스크립트 엔진 간에도 혹은 동일 엔진의 버전 간에도 다를 수 있다. 특히 fianlizer는 더 심하다.

  • 가비지 컬렉션 직후 발생하지 않을 수 있다.
  • 실제 가비지 컬렉션과 동일한 순서로 발생하지 않을 수 있다.
  • 전혀 발생하지 않을 수도 있다. (ex. 윈도우가 닫힌 경우)

따라서 finalizer의 코드 경로에 중요한 로직을 두면 안 된다. 가비지 컬렉션에 대한 응답으로 마무리 작업을 처리하는 데 유용하지만 메모리 사용량을 측정하고 기록하는 데는 안정적으로 사용할 수 없다. 해당 유스 케이스는 performance.measureUserAgentSpecificMemory를 참조하라.

WeakRef 및 finalizer는 메모리를 절약하는 데 도움이 될 수 있고 점진적으로 향상시키고자 하는 의미에서만 가장 잘 동작한다. 이것들은 전문적인 기능이기 때문에 대부분 프레임워크나 라이브러리 내에서 사용이 될 것으로 예상된다.

이진우2021.06.24
Back to list