본문 바로가기

Angular

RxJS: Why memory leaks occur when using a Subject (한글)

이 글은 아래 원문을 번역한 글로 의역이 있을 수 있습니다. 정확한 의미를 파악하고 싶으신 분은 원문을 참고해주시기 바랍니다.

원문: https://indepth.dev/posts/1433/rxjs-why-memory-leaks-occur-when-using-a-subject

저작권 정보: https://indepth.dev

 

※ RxJS의 용어나 관용적으로 쓰는 표현들은 따로 번역하지 않았습니다. (ex. Subject, Observable..)

Suscriber는 구독자로 번역하였습니다.


RxJS 관련 자료들을 읽다 보면 ‘unsubscribe’, ‘memoery leaks’, ‘subject’와 같은 단어들을 흔히 볼 수 있습니다. 이 글에서는 해당 사실에 대해서 따져보려 합니다. 그리고 그 끝에서 당신은 왜 메모리 릭이 발생하는지, 그리고 왜 unsbscibe() 함수로 간단하게 그 문제가 해결되는 지에 대한 더 나은 통찰력을 얻게 될 겁니다.

이걸 읽는 독자는 RxJS 와 친숙해야 하지만, 필요한 개념들은 글을 읽으면서 명확해질 것입니다. 만약 RxJS의 subjects에 관해 더 배우고 싶다면 여기에서 시작해주세요.

 

이 글은 이 스택오퍼플로우 답변에서 영감을 얻었습니다.

문제 이해

RxJs 라이브러리는 어떤 라이브러리나 프레임워크에 결합하거나 그 자체만으로도 사용될 수 있지만, 우린 이 문제를 Angular의 맥락에서 논의할 것입니다. 어떤 경우에도 그 개념을 적용할 수 있기 때문에 문제가 되지는 않을 겁니다.

 

하지만 먼저 순수한 RxJS 에서 메모리 릭의 발생을 증명해 보고 그것에 대해 점점 확장해 나가 보겠습니다.

const source = new Subject();

let s = source.subscribe(v => console.log("subscriber 1: ", v));

source.next("1"); // logs: subscriber 1: 1

// 이것 아무것도 하지 않을 겁니다.
s = null

// 이 전에 구독을 해지 하지 않았다는 것을 주목하세요.
s = source.subscribe(v => console.log("subscriber 2: ", v));

source.next("2");
// logs:
// subscriber 1: 2 // !!! - 이건 여기 있어선 안되죠..!
// subscriber 2: 2

위 코드 조각의 StackBlitz 앱을 여기에서 찾을 수 있습니다.

앵귤러에서 컴포넌트에 서비스를 주입하고, 해당 서비스들이 노출시키고 있는 observable 프로퍼티들을 구독하는 것은 흔한 패턴입니다.

class Service {
    private usersSrc = new Subject();
    users$ = this.usersSrc.asObservable();
}

저런 서비스들은 아래처럼 사용 될 수 있습니다.

class FooComponent {
    constructor (private service: Service) { }
      ngOnInit () {
       this.subscription = this.service.users$.subscribe(nextCb, errorCb, completeCb)
     }
    }
}

알다시피, SubjectObservable의 특수한 타입이고 몇 가지 매우 흥미로운 특징들을 가집니다. 우리에게 가장 관련이 있는 사실은 그것이 구독이 될 수 있고 구독자들의 리스트를 유지한다는 것입니다.

 

구독 과정은 그것 자체만으로도 기사를 쓸만한 주제지만, 지금은 subject를 이해하고, subject.next() 함수가 호출되면, 그게 등록된 모든 구독자들에게 값을 보낼 것인지가 중요합니다. 왜냐하면 구독자는 subscribe(nextCb, ...) 함수가 호출될 때 등록이 되고, source로부터 받는 값들은 nextCb 에서 이용될 수 있기 때문입니다.

 

즉, 모든 콜백들은 그 subject 안에 보관되고 있습니다.

 

이제, 컴포넌트가 destroy 될 때 (예: 다른 라우트로 이동되는 것 때문이라던지), 만약 우리가 this.subscription.unsubscribe() 를 호출하지 않았다면, 구독자는 여전히 그 구독자 리스트의 일부일 것입니다.

이것은 중요합니다. 왜냐하면 다음번에 컴포넌트가 ngOnInit 을 통해 생성되면, 새로운 구독자가 그 구독자들 리스트에 추가될 것이기 때문입니다. 하지만 예전 그 구독자는 this.subscription.unsubscribe() 가 불리지 않는다면 여전히 거기 있을 겁니다.

 

메모리 릭이 발생한 경우를 단순화한 버전은 다음과 같습니다.

// 서비스에서 사용되는 Subject 
let src = {
 subscribers: [],

 addSubscriber(cb) {
   this.subscribers.push(cb);
   return this.subscribers.length - 1;
 },

 removeSubscriber(idx) {
   this.subscribers.splice(idx, 1);
 },
 next(data) {
   this.subscribers.forEach(cb => cb(data));
 }
};

단순화된 버전의 컴포넌트는 다음과 같습니다.

class Foo {
 subIdx: number;
 constructor() {
   this.subIdx = src.addSubscriber(value => {
     console.log(value);
   });
 }

 onDestroy() {
   // unsubscribe() 와 동일
   src.removeSubscriber(this.subIdx);
 }
}

// 새로운 컴포넌트 생성
let foo = new Foo(); // Foo {subIdx: 0}

// 구독자들에게 data 를 보냅니다.
src.next("sending data for the first time");
// console output: `sending data for the first time`

// onDestroy 없이 컴포넌트를 소멸시킵니다.
foo = null;

// 구독자는 여전히 존재합니다. 
src.next("sending data for the second time");
// console output: `sending data for the second time`

// Foo 에 새로운 인스턴스를 추가가합니다. - Foo {subIdx: 1}
// 이 시점에 새로운 구독자가 생성됩니다.
foo = new Foo();

src.next("sending data for the third time");
// console output: `sending data for the third time`
// console output: `sending data for the third time`

위의 코드를 이 StackBliz 에서 가지고 놀 수도 있습니다.

 

src.next('test2') 이후에, 우리는 'foo'가 여전히 두 번 로깅되고 있음을 볼 수 있습니다. 이것은 메모리 릭을 나타냅니다. 매우 유사한 일들이 Subjects와 그들의 구독자들에게 발생하고 있습니다.

 

보통, 이런 종류의 문제는 source 가 무한할 때 발생합니다. (컴포넌트에 의해 사용되는 글로벌 서비스 같은 것들은 complete/error 가 없을 것입니다.)

그러나 구독해지를 할 필요가 없는 경우들도 존재합니다. 예를 들어, 컴포넌트가 소멸되면서 Subject가 null 이 되는 경우입니다. ActivatedRoute 또는 폼 컨트롤의 Subject(valueChanges, statusChanges) 에서 발생합니다.

그러나 구독 해지를 할 필요가 없는 경우도 있습니다. 예를 들어, Subject를 감싸고 있던 컨테이너가 소멸되거나 널아웃(nulled out) 되어서 더 이상 Subject 를 참조할 수 없는 경우입니다. 이런 경우는 컴포넌트가 소멸될 때 ActivatedRoute 또는 폼 컨트롤의 Subject 들 (valueChanges, statusChanges) 에게 발생합니다.

 

※ ActiavetedRoute에서 발생하는 예시에 관해서는 글의 댓글에 자세히 써져 있습니다. 글을 이해하는 데에 도움이 될 것 같아 댓글 코멘트 해석도 첨부합니다.)

Hi @gogumachu

라우터 상황이라 함은, 저는 아래 detach 메서드를 가리켜 말한 겁니다.

// 역자 : 실제 angular의 router_outlet.ts 코드 일부이다.
detach(): ComponentRef<any> {
    if (!this.activated) throw new Error('Outlet is not activated');
    this.location.detach();
    const cmp = this.activated;
    this.activated = null;
    this._activatedRoute = null;
    this.detachEvents.emit(cmp.instance);
    return cmp;
  }​

해당 함수는 현재 route 가 소멸되고 새로운 것으로 교체될 때 불립니다. 그 결과로써는 내비게이션이 성공하게 됩니다.

보면 알 수 있듯이, BehaviorSubjects 를 프로퍼티로 가지고 있는 현재 activated 된 라우트는 널 아웃(nulled out) 됩니다. 제가 단어를 좀 더 정확하게 썼어야 했다는 것은 인정합니다. Subject 그 자체가 아니라, Subject 프로퍼티를 가지고 있는 컨테이너가 널 아웃되어야 합니다.

하지만 어쨌든, 요점은 Subject 프로퍼티가 더 이상 참조될 수 없다는 것은 그것이 어떠한 알림도 보낼 수 없고, 그러므로 Subject 인스턴스와 그리고 그것의 프로퍼티들은 안전하게 가비지 콜렉티드 (garbage-collected) 될 수 있습니다.

혼란스럽게 해서 죄송해요. 가능한 한 빨리 수정할게요. 고마워요!

결론

이 짧은 글로 인해, 왜 RxJs의 Subjects를 이용할 때 메모리 릭이 발생하는 이유가 좀 더 명확해졌길 바랍니다. 요약하자면, 이 모든 것은 Subject 가 구독자들을 리스트의 도움으로 트랙킹하고 있다는 사실 때문이라는 것으로 귀결됩니다. 구독자가 더 이상 리스트에 있어선 안될 때는 unsubscribe() 메서드가 호출되어야 합니다.

그렇지 않으면 우리가 기대하지 않는 결과가 나올 것입니다.

 

읽어줘서 감사합니다!

'Angular' 카테고리의 다른 글

Rxjs Bad Practice (한글)  (0) 2022.03.23
The Last Guide For Angular Change Detection You'll Ever Need (한글)  (0) 2021.08.25