Angular 프로젝트에서 RxJS Observable 메모리 누수 해결

문제 상황

대시보드 페이지를 오래 사용하면 브라우저가 점점 느려지는 이슈가 접수됐다. Chrome DevTools의 Performance 탭으로 확인해보니 메모리 사용량이 계속 증가하고 있었다.

문제는 HTTP 폴링을 구현한 부분이었다. 5초마다 서버에서 데이터를 가져오는 로직에서 Observable 구독을 해제하지 않아 컴포넌트가 destroy된 이후에도 계속 실행되고 있었다.

export class DashboardComponent implements OnInit {
  ngOnInit() {
    // 문제가 있던 코드
    Observable.interval(5000)
      .switchMap(() => this.http.get('/api/stats'))
      .subscribe(data => this.stats = data);
  }
}

해결 방법

1. 수동 구독 해제

가장 기본적인 방법은 Subscription을 저장했다가 ngOnDestroy에서 해제하는 것이다.

export class DashboardComponent implements OnInit, OnDestroy {
  private subscription: Subscription;

  ngOnInit() {
    this.subscription = Observable.interval(5000)
      .switchMap(() => this.http.get('/api/stats'))
      .subscribe(data => this.stats = data);
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }
}

2. takeUntil 패턴

여러 구독이 있을 때는 takeUntil을 사용하는 패턴이 깔끔했다.

export class DashboardComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();

  ngOnInit() {
    Observable.interval(5000)
      .switchMap(() => this.http.get('/api/stats'))
      .takeUntil(this.destroy$)
      .subscribe(data => this.stats = data);
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

3. AsyncPipe 활용

template에서 직접 처리할 수 있다면 AsyncPipe가 자동으로 구독 해제를 해준다.

export class DashboardComponent implements OnInit {
  stats$: Observable<any>;

  ngOnInit() {
    this.stats$ = Observable.interval(5000)
      .switchMap(() => this.http.get('/api/stats'));
  }
}
<div *ngIf="stats$ | async as stats">
  {{ stats | json }}
</div>

결론

프로젝트 전체를 점검해서 구독 해제가 필요한 부분들을 수정했다. 특히 interval, fromEvent 같은 오래 지속되는 Observable은 반드시 해제가 필요하다는 것을 팀원들과 공유했다. 이후 ESLint 규칙에 RxJS 관련 체크를 추가하는 것도 고려 중이다.