RxJS Subscription 효과적으로 관리하기
메모리 누수를 방지하는 구독 관리 패턴과 Angular 16+ takeUntilDestroyed 활용법
읽는 데 70분
- #rxjs
- #angular
- #subscription
- #memory-leak
- #takeUntil
이 문서의 목차
RxJS에서 Observable을 구독하면 데이터 스트림을 받아볼 수 있습니다. 하지만 이 구독을 적절히 해제하지 않으면 메모리 누수가 발생합니다. 컴포넌트가 화면에서 사라져도 구독이 살아있으면 불필요한 콜백이 계속 실행되고, 메모리가 해제되지 않아 애플리케이션 성능이 점차 저하됩니다.
주의
구독 해제를 누락하면 페이지 이동 후에도 이전 컴포넌트의 로직이 실행되어 예측할 수 없는 버그가 발생할 수 있습니다. 특히 SPA에서 페이지를 여러 번 오가면 구독이 중첩되어 메모리 사용량이 급증합니다.
이 문서에서는 구독을 효과적으로 관리하는 여러 패턴을 비교하고, 현재 권장되는 방식을 소개합니다.
Observable 구독이 메모리 누수를 일으키는 메커니즘을 이해하면 해제가 왜 중요한지 알 수 있습니다.
@Component({ template: `<div>{{ data }}</div>` })
export class DataComponent implements OnInit {
data: string;
ngOnInit() {
// interval은 무한히 값을 방출하는 Observable
interval(1000).subscribe(count => {
this.data = `Count: ${count}`;
console.log(this.data);
});
}
}이 컴포넌트가 화면에서 제거되어도 interval 구독은 계속 실행됩니다. 콘솔에는 로그가 끊임없이 찍히고, 컴포넌트 인스턴스는 가비지 컬렉션되지 않습니다. 사용자가 이 페이지를 10번 방문하면 10개의 독립적인 interval이 동시에 돌아가며, 이는 심각한 성능 문제로 이어집니다.
모든 Observable이 구독 해제를 요구하지는 않습니다. Observable이 스스로 완료되는 경우에는 명시적인 해제가 불필요합니다.
정보
자동 완료되는 Observable:
HttpClient요청 (응답 후 자동 complete)take(n),first(),last()등 finite 연산자 사용 시of(),from()등 유한 데이터 소스
// HttpClient - 구독 해제 불필요
this.http.get<User[]>('/api/users').subscribe(users => {
this.users = users;
// HTTP 응답 후 Observable이 자동으로 complete됨
});
// take(1) - 첫 번째 값만 받고 자동 완료
this.userService.currentUser$
.pipe(take(1))
.subscribe(user => {
this.user = user;
});반면 interval()이나 timer() 같은 무한 스트림, 수동으로 complete하지 않는 한 지속되는 Subject와 BehaviorSubject, DOM 이벤트를 감싸는 fromEvent(), 그리고 Router events나 Form valueChanges 같은 Observable은 반드시 구독 해제가 필요합니다.
가장 직관적인 방법은 Subscription 객체를 저장했다가 ngOnDestroy에서 해제하는 것입니다. 하지만 구독이 많아질수록 관리가 어려워집니다.
// ❌ 문제가 있는 패턴: 개별 구독 관리
@Component({ template: `...` })
export class DataComponent implements OnInit, OnDestroy {
private dataSubscription: Subscription;
private conditionSubscription: Subscription;
private anotherSubscription: Subscription;
ngOnInit() {
this.dataSubscription = this.dataService.data$
.subscribe(value => console.log(value));
this.conditionSubscription = this.conditionService.condition$
.subscribe(condition => {
if (condition === 'stop') {
this.dataSubscription.unsubscribe();
}
});
this.anotherSubscription = this.anotherService.stream$
.subscribe(data => this.handleData(data));
}
ngOnDestroy() {
// 각 구독마다 null 체크와 해제 코드 필요
if (this.dataSubscription) {
this.dataSubscription.unsubscribe();
}
if (this.conditionSubscription) {
this.conditionSubscription.unsubscribe();
}
if (this.anotherSubscription) {
this.anotherSubscription.unsubscribe();
}
}
}이 방식에는 몇 가지 문제가 있습니다. 구독을 추가할 때마다 클래스 프로퍼티와 ngOnDestroy 코드를 함께 수정해야 하므로 변경 지점이 분산되고, 구독 해제를 빠뜨리기 쉬우며, 동일한 보일러플레이트가 반복됩니다. 구독이 10개라면 거의 동일한 해제 코드가 10줄이나 필요합니다.
RxJS 구독을 관리하는 주요 패턴을 비교해보겠습니다. 각 패턴마다 장단점이 있으므로 상황에 맞게 선택하면 됩니다.
| 패턴 | 장점 | 단점 | 권장 시점 |
|---|---|---|---|
| 수동 unsubscribe | 명확한 제어 | 보일러플레이트, 실수 가능 | 레거시 코드 |
| takeUntil + Subject | 선언적, 조건 조합 가능 | Subject 관리 필요 | Angular 15 이하 |
| AsyncPipe | 완전 자동, 반응형 | 템플릿에서만 사용 | 템플릿 바인딩 |
| takeUntilDestroyed | 가장 간결, 타입 안전 | Angular 16+ 필요 | 신규 프로젝트 권장 |
// Angular 15 이하 권장 패턴
@Component({ template: `...` })
export class DataComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
ngOnInit() {
this.dataService.data$
.pipe(takeUntil(this.destroy$))
.subscribe(data => this.handleData(data));
this.streamService.stream$
.pipe(takeUntil(this.destroy$))
.subscribe(value => console.log(value));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}// 템플릿에서 AsyncPipe 사용 - 가장 안전
// HTML: <li *ngFor="let item of items$ | async">...</li>
export class ListComponent {
items$ = this.dataService.getItems();
user$ = this.authService.currentUser$;
// 구독 해제 코드 불필요!
// AsyncPipe가 컴포넌트 destroy 시 자동으로 구독 해제
}// Angular 16+ 권장 패턴
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ template: `...` })
export class DataComponent {
constructor() {
// injection context 내부 - 자동 주입
this.dataService.data$
.pipe(takeUntilDestroyed())
.subscribe(data => this.handleData(data));
}
// ngOnDestroy 불필요!
}takeUntil은 종료 신호를 받으면 자동으로 구독을 해제하는 연산자입니다. Subject를 종료 신호로 사용하면 여러 구독을 한 번에 정리할 수 있으며, merge를 활용하면 다양한 종료 조건을 조합할 수도 있습니다.
import { Subject, merge, interval } from 'rxjs';
import { takeUntil, filter } from 'rxjs/operators';
@Component({ template: `...` })
export class DataComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private stopCondition$ = new Subject<string>();
private countLimit$ = new Subject<number>();
ngOnInit() {
// 여러 종료 조건을 merge로 결합
const terminate$ = merge(
this.destroy$,
this.stopCondition$.pipe(filter(v => v === 'stop')),
this.countLimit$.pipe(filter(v => v >= 10))
);
this.dataService.data$
.pipe(takeUntil(terminate$))
.subscribe({
next: value => console.log(value),
complete: () => console.log('구독 종료')
});
}
stopManually() {
this.stopCondition$.next('stop');
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}이 패턴을 사용하면 관리해야 할 구독 객체가 destroy$ 하나로 줄어듭니다. 새로운 구독을 추가할 때도 ngOnDestroy를 수정할 필요 없이 pipe(takeUntil(this.destroy$))만 추가하면 되고, complete 콜백을 통해 구독 종료 시점에 정리 로직을 실행할 수도 있습니다.
주의
takeUntil은 반드시 pipe의 마지막에 위치해야 합니다. 중간에 배치하면 이후 연산자들이 구독 해제 신호를 받지 못해 메모리 누수가 발생할 수 있습니다.
// ❌ 잘못된 위치
source$.pipe(
takeUntil(this.destroy$),
switchMap(id => this.http.get(`/api/${id}`)), // 해제되지 않음!
map(data => data.result)
).subscribe();
// ✅ 올바른 위치
source$.pipe(
switchMap(id => this.http.get(`/api/${id}`)),
map(data => data.result),
takeUntil(this.destroy$) // 마지막에 배치
).subscribe();Angular 16에서 도입된 takeUntilDestroyed는 takeUntil 패턴을 더욱 간결하게 만들어줍니다. 별도의 Subject를 선언하거나 ngOnDestroy를 구현할 필요 없이, 컴포넌트 생명주기와 자동으로 연동됩니다.
팁
takeUntilDestroyed의 동작 원리: 내부적으로 DestroyRef를 주입받아 컴포넌트 파괴 시점을 감지합니다. Injection context 내부에서 호출하면 자동으로 주입되고, 외부에서는 명시적으로 전달해야 합니다.
import { Component, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({
selector: 'app-data',
template: `<p>{{ count }}</p>`
})
export class DataComponent {
count = 0;
private destroyRef = inject(DestroyRef);
constructor(private dataService: DataService) {
// Injection context 내부: DestroyRef 자동 주입
interval(1000)
.pipe(takeUntilDestroyed())
.subscribe(n => this.count = n);
}
ngOnInit() {
// Injection context 외부: DestroyRef 명시적 전달
this.dataService.stream$
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(data => this.handleData(data));
}
private handleData(data: unknown) {
// ...
}
}inject() 함수가 작동하는 범위를 injection context라고 합니다. 클래스 필드 초기화 구문, 생성자, 그리고 runInInjectionContext로 감싼 함수 내부가 이에 해당합니다. 반면 ngOnInit이나 일반 메서드는 injection context가 아니므로 DestroyRef를 명시적으로 전달해야 합니다.
@Component({ template: `...` })
export class ExampleComponent {
private destroyRef = inject(DestroyRef); // ✅ 필드 초기화 - injection context
constructor() {
// ✅ 생성자 - injection context
someObservable$.pipe(takeUntilDestroyed()).subscribe();
}
ngOnInit() {
// ❌ ngOnInit - injection context 아님
// takeUntilDestroyed()만 호출하면 에러 발생
someObservable$
.pipe(takeUntilDestroyed(this.destroyRef)) // ✅ 명시적 전달
.subscribe();
}
}Angular 16부터 도입된 Signals는 RxJS Observable을 구독 없이 사용할 수 있게 해줍니다. toSignal()을 사용하면 Observable을 Signal로 변환하면서 구독과 해제가 자동으로 관리되므로, 앞서 살펴본 구독 관리 패턴이 필요 없습니다.
정보
toSignal() 특징: AsyncPipe처럼 Observable을 구독하고, 컴포넌트가 파괴될 때 자동으로 구독을 해제합니다. 다만 injection context에서 호출해야 하며, initialValue 또는 requireSync 옵션이 필요합니다.
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
@Component({
template: `
@for (user of users(); track user.id) {
<li>{{ user.name }}</li>
}
`
})
export class UserListComponent {
private http = inject(HttpClient);
// toSignal이 구독과 해제를 자동 관리
// takeUntilDestroyed, ngOnDestroy 모두 불필요
users = toSignal(this.http.get<User[]>('/api/users'), {
initialValue: []
});
}디바운싱이나 쓰로틀링처럼 RxJS 연산자가 필요한 경우에는 toObservable()과 toSignal()을 조합한 하이브리드 패턴을 사용합니다. Signal의 간결함과 RxJS의 강력한 연산자를 함께 활용할 수 있습니다.
import { Component, signal, inject } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
@Component({ template: `...` })
export class SearchComponent {
private searchService = inject(SearchService);
// UI 입력은 Signal로 관리
query = signal('');
// 복잡한 비동기 로직은 RxJS로 처리 후 다시 Signal로 변환
results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.searchService.search(q))
),
{ initialValue: [] }
);
// 구독 해제 코드 불필요!
}Signals가 도입되었다고 해서 RxJS가 불필요해진 것은 아닙니다. 각각 적합한 사용 사례가 다르므로 상황에 맞게 선택해야 합니다.
| 상황 | 권장 | 이유 |
|---|---|---|
| 단순 상태 표시 | Signal (toSignal) | 보일러플레이트 없음, 자동 구독 관리 |
| 디바운싱/쓰로틀링 | RxJS | 시간 기반 연산자 필요 |
| 여러 스트림 조합 | RxJS | combineLatest, merge, forkJoin 등 |
| WebSocket/실시간 데이터 | RxJS | 연결 관리, 재연결 로직 필요 |
| 검색 자동완성 | 하이브리드 | Signal → RxJS 연산자 → Signal |
| 폼 입력 검증 | Signal | computed()로 파생 상태 관리 |
팁
2025년 권장 패턴: UI 상태는 Signal로, 복잡한 비동기 이벤트 처리는 RxJS로 관리하고, toSignal()과 toObservable()로 두 세계를 연결하세요.
앞서 언급했듯이 takeUntil을 pipe 중간에 배치하면 이후 연산자의 내부 구독이 해제되지 않습니다. 항상 pipe 체인의 마지막에 위치시켜야 합니다.
destroy$에 next()만 호출하고 complete()를 호출하지 않으면 Subject가 완전히 정리되지 않습니다. 반드시 두 메서드를 함께 호출해야 합니다.
// ❌ 불완전한 정리
ngOnDestroy() {
this.destroy$.next();
// complete() 누락
}
// ✅ 완전한 정리
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}구독 내부에서 또 다른 구독을 생성하면 내부 구독이 해제되지 않을 수 있습니다. switchMap이나 mergeMap 같은 고차 연산자를 사용해야 합니다.
// ❌ 중첩 구독 - 내부 구독 누수 가능
this.userId$.subscribe(id => {
this.http.get(`/api/users/${id}`).subscribe(user => {
this.user = user;
});
});
// ✅ switchMap 사용 - 이전 내부 구독 자동 취소
this.userId$.pipe(
switchMap(id => this.http.get(`/api/users/${id}`)),
takeUntil(this.destroy$)
).subscribe(user => {
this.user = user;
});HttpClient나 take(1)처럼 자동 완료되는 Observable에 takeUntil을 추가하는 것은 불필요합니다. 동작에 해가 되진 않지만 코드 복잡도만 높이므로 피하는 것이 좋습니다.
Signals의 effect() 내부에서 Observable을 구독하면 effect가 재실행될 때마다 새 구독이 생성되어 메모리 누수가 발생합니다. toSignal()을 사용하세요.
// ❌ effect 재실행 시 구독 누적
effect(() => {
this.userId(); // signal 읽기
this.http.get(`/api/users/${this.userId()}`).subscribe(user => {
this.user.set(user);
});
});
// ✅ toSignal 사용
userId = signal(1);
user = toSignal(
toObservable(this.userId).pipe(
switchMap(id => this.http.get(`/api/users/${id}`))
)
);toSignal()은 takeUntilDestroyed()와 마찬가지로 injection context에서만 호출할 수 있습니다. 클래스 필드 초기화나 생성자에서 사용해야 합니다.
// ❌ injection context 외부
ngOnInit() {
this.users = toSignal(this.http.get('/api/users')); // 런타임 에러!
}
// ✅ 필드 초기화
users = toSignal(this.http.get<User[]>('/api/users'), { initialValue: [] });핵심 요약
- Angular 16+ Signals:
toSignal()을 사용하면 구독 관리가 완전히 자동화됩니다. - Angular 16+ RxJS:
takeUntilDestroyed()가 가장 간결하고 안전합니다. - Angular 15 이하:
takeUntil+Subject패턴을 사용하세요. - 템플릿 바인딩:
AsyncPipe를 최우선으로 고려하세요. - 복잡한 비동기 로직: Signal → RxJS → Signal 하이브리드 패턴을 활용하세요.
- HttpClient, take(1): 구독 해제가 불필요합니다.
- takeUntil 위치: 반드시 pipe의 마지막에 배치하세요.
글쓴이 mirunamu00



