옵저버 패턴에서의 Memory Leak 해결하기 (WeakRef, FinalizationRegistry)

2023. 9. 15. 01:31카테고리 없음

코드스쿼드 과정 중 바닐라 자바스크립트로 옵저버패턴을 통해 데이터 바인딩을 시도한 적이 있다. 하지만 Observable이 관리하는 observers에 이미 사용되지 않는 observer들이 누적해서 쌓이는 문제가 있었다.

명시적인 unsubscribe로 제거해주면 되지 않을까?

옵저버 패턴의 메모리 누수 (memeory leak)에 대해 검색해 봤을 때 많은 글에서는 다음과 같은 해결법을 말하고 있었다.

참고로 TS를 사용한다면 Updatable 인터페이스를 정의했을테지만, 글의 간략함을 위해 JS로 작성한다.

class Observable {
  constructor() {
    this.observers = [];
  }

  subscribe(observer) {
    this.observers.push(observer);
  }

  unsubscribe(observer) {
    this.observers = this.observers.filter((sub) => sub !== observer);
  }

  notify(){
	this.observers.forEach(observer => observer.update());
  }
}

class Observer{
  update(){
    console.log("업데이트됨")
  }
}

const observable = new Observable();
const observer = new Observer();
observable.subscribe(observer);

// ...

// 특정 시점에서 명시적인 unsubscribe
observable.unsubscribe(observer);

하지만 모든 observer들이 소멸되는 곳에서 해당 로직을 작성해야 하는 점이 불편했고, 다른 개발자들이 내가 만든 Observable을 사용하게 되면 반드시 unsubscribe를 해주어야 메모리 누수가 발생하지 않는다고 말해주어야 하는 문제가 있다고 생각했다.

JS에는 소멸자가 없다

맨 처음 이 문제를 해결하기 위해 학부 과정에서 배운 C++의 소멸자 개념을 생각했다. C++의 소멸자는 해당 인스턴스가 소멸될 때 소멸자 메서드가 자동으로 호출됨으로써 자원을 회수하는 등의 행위를 할 수 있다.

만약 JS에도 소멸자가 존재한다면 이 문제는 아주 간단하게 해결된다. 그저 소멸자에 unsubscribe를 하는 로직만 작성하면 됐기 때문이다.

하지만 JS는 GC가 메모리를 관리해주는 언어이기 때문에 소멸자가 존재하지 않았다.

WeakRef

GC에 의해 반환되는 메모리는 어떤 메모리일까? 물론 GC는 복잡하지만 간단하게 생각해보면 다음과 같다.

  1. 어떤 값은 그래프의 정점(vertex)이다.
  2. 참조 관계는 간선(edge)이다.
  3. 루트 정점(vertex)으로부터 도달할 수 없는 정점(vertex)은 GC에 의해 지워지게 된다.
class Observable {
  constructor() {
    this.observers = [];
  }

  subscribe(observer) {
    this.observers.push(observer);
  }

  unsubscribe(observer) {
    this.observers = this.observers.filter((sub) => sub !== observer);
  }

  notify(){
	this.observers.forEach(observer => observer.update());
  }
}

class Observer{
  update(){
    console.log("업데이트됨")
  }
}

const observable = new Observable();
let observer = new Observer();
observable.subscribe(observer);

// ...

// 특정시점에서 observer는 참조를 끊었다.
observer = null

위 코드에서 어느 특정 시점에서 observer 변수는 자신이 참조하던 Observer 클래스의 인스턴스에 대한 참조를 끊었다. observer 변수가 참조하던 Observer 클래스의 인스턴스를 A라고 해보자.

A는 GC에 의해 수거될까? 수거되지 않는다. 이유는 Observable의 observers에서 여전히 A를 참조하고 있기 때문이다. A는 여전히 Observable에 의해 도달 가능하므로 GC가 메모리를 수거해가지 않는다.

이 문제의 본질은 이와 같이 Observable이 이미 필요가 없어진 Observer를 참조하고 있다는 점이다.

구글에 observer pattern memory leak 이라고 검색해보면 Wikipedia에 Lapsed listener problem이라는 글이 있다.

This can be prevented by the subject holding weak references to the observers, allowing them to be garbage collected as normal without needing to be unregistered.

weak reference를 이용하면 해당 문제를 해결할 수 있다고 한다. weak reference에 또다시 들어가보면 다음과 같은 설명이 나와있다.

a weak reference is a reference that does not protect the referenced object from collection by a garbage collector, unlike a strong reference. An object referenced only by weak references – meaning "every chain of references that reaches the object includes at least one weak reference as a link" – is considered weakly reachable, and can be treated as unreachable and so may be collected at any time.

 

 

JS에는 소멸자 개념은 없지만 ES2021에 추가된 Weak Reference는 존재한다.

mdn에서 WeakRef에 대한 설명을 읽어보면 다음과 같다.

A WeakRef object contains a weak reference to an object, which is called its target or referent. A weak reference to an object is a reference that does not prevent the object from being reclaimed by the garbage collector.

 

문제 해결에 대한 실마리가 생겼다. WeakRef를 이용하여 이 문제를 해결하기 위해 실질적인 WeakRef 객체의 사용법을 알아보자.

const ref = new WeakRef(target);

const obj = ref.deref();

정리하면 다음과 같다.

  1. 생성자의 매개변수로 약한 참조를 할 객체를 넘긴다
  2. deref() 메서드로 WeakRef가 약한 참조를 하던 object를 얻는다. 만약 이미 GC에 의해 제거 되었다면, undefined를 얻는다.

이제 WeakRef를 이용하여 다음과 같이 코드를 수정해보자

class Observable {
  constructor() {
    this.observers = [];
  }

  subscribe(observer) {
    const ref = new WeakRef(observer);
    this.observers.push(ref);
  }

  unsubscribe(observer) {
    this.observers = this.observers.filter((sub) => sub !== observer);
  }

  notify(){
    this.observers.forEach(observer => observer.deref()?.update());
    console.log(this.observers.length)
  }
}

class Observer{
  update(){
    console.log("업데이트됨")
  }
}

const observable = new Observable();
let observer = new Observer();
observable.subscribe(observer);

// ...

// 특정시점에서 observer는 참조를 끊었다.
observer = null

setInterval을 이용하여 1초마다 한번씩 Observable의 notify를 실행해보자

setInterval(()=>observable.notify(), 1000)

다음과 같이 GC가 정상적으로 작동하는걸 볼 수 있다.

 

 

하지만 여전히 문제는 있다. notify 메서드에서 observers.length를 출력해보았더니 여전히 1이다.

weakRef의 타겟 object는 GC에 의해 메모리를 반납하였다. 하지만 당연하게도 Observable의 observers 배열에서 weakRef 자체를 제거하진 않는다.

FinalizationRegistry

observers에서 weakRef객체를 제거하기 위해 GC에 의해 수거되기 직전 또는 직후에 실행가능한 메서드가 필요했다.

이런 또다른 문제를 해결하기 위해 학부때 배웠던 JAVA의 finalize 메서드가 생각났다. JAVA의 동작원리는 공부해보지 않았지만 JAVA 역시도 GC에 의해 메모리가 관리되는 언어이기 때문에 JAVA에 finalize 메서드가 존재한다면 JS에도 비슷한 메서드가 존재하지 않을까?

JS에는 마찬가지로 ES2021에 추가된 FinalizationRegistry가 존재했다.

mdn에서 FinalizatonRegistry에 대한 설명을 읽어보자.

 

FinalizationRegistry provides a way to request that a cleanup callback get called at some point when a value registered with the registry has been reclaimed (garbage-collected).

 

FinalizationRegistry 사용법에 대해 알아보자.

const registry = new FinalizationRegistry((heldValue) => {
  // …
});

registry.register(target, heldValue, unregisterToken);

registry.unregister(unregisterToken);
  1. 생성자에 cleanUp 함수를 매개변수로 전달한다.
  2. register 메서드를 이용하여 객체를 등록한다.  
    1. target: 등록할 (GC에 의해 메모리를 반납할 때 클린업 함수를 실행할) 객체.
    2. heldValue: cleanup 함수에 전달할 값.
    3. unregisterToken: unregister 메서드에서 사용할 토큰
  3. unregister 메서드를 이용하여 객체를 등록 해제할수도 있다.

이 때 register의 target 매개변수는 내부적으로 약한 참조를 한다고 한다. 그렇지 않으면 GC가 되질 않으니 당연한 논리이다.

다시금 코드를 수정해보자

class Observable {
  constructor() {
    this.observers = new Set();
    this.registery = new FinalizationRegistry((ref) => {
      this.observers.delete(ref);
    });
  }

  subscribe(observer) {
    const ref = new WeakRef(observer);
    this.observers.add(ref);
    this.registery.register(observer, ref, ref);

    return ref;
  }

  unsubscribe(ref) {
    this.observers.delete(ref);
	this.registery.unregister(ref);
  }

  notify() {
    for (let ref of this.observers) {
      const observer = ref.deref();
      observer?.update();
    }
    console.log(this.observers.size);
  }
}

class Observer {
  update() {
    console.log('업데이트됨');
  }
}

const observable = new Observable();
let observer = new Observer();
observable.subscribe(observer);

observer = null;

우선, observers가 배열로 있다면 weakRef를 제거하더라도 배열의 길이 자체는 유지가 되므로 배열에서 Set으로 전환하였다. (observers의 특정 observer에 접근하는 시간도 O(N)에서 O(1)로 줄게된다.)

또한 구독을 할때 observers에 weak ref를 추가해줌과 동시에 finalizationRegistry에 해당 observer를 등록해주었다.

이제 setInterval로 1초마다 notify를 해보면, 정상적으로 gc도 작동하고 observers에서 weakRef도 적절히 삭제된 것을 알 수 있다.

setInterval(() => observable.notify(), 1000);

다만 unsubscribe에서 사용하기 위해 subscribe에서 ref를 반환하고 있는데, 다음과 같이 수정하는 것이 더 적절해보인다.

  subscribe(observer) {
    const ref = new WeakRef(observer);
    this.observers.add(ref);
    this.registery.register(observer, ref, ref);

    return {
	  unsubscribe: () => this.observers.delete(ref);
	};
  }