Rxjs를 사용하면서 많은것들이 Observable stream으로 추상화되다보니 동작이 눈에 보이지 않아 놓치는 문제점들이 많은 것 같다.
Angular 프로젝트에서 아래 내용을 개발하였다.
row$
에 할당한다.history$
에 할당한다.row$
, history$
를 async
파이프를 이용해 구독하고 정보를 화면에 보여준다.scrollTop
값을 query param에 저장하고 있다가, 랜더링이 완료되면 해당 스크롤값을 템플릿에 설정해주는데, 이는 컴포넌트에서 ngAfterViewInit
lifecycle 메서드에서 history$
를 구독하고 defined된 값이 넘어오면 실행한다.컴포넌트와 템플릿의 관련 코드는 대충 아래와 같다.
// 컴포넌트
@Component({
selector: 'app-detail-dialog',
})
export class DetailDialogComponent implements AfterViewInit {
top: number;
row$: Observable<Row>;
history$: Observable<History[]>;
constructor (
private store: Store<AppState>,
private service: HistoryService,
private activatedRoute: ActivatedRoute
) {
this.top = this.activatedRoute.snapshot.queryParams.top as number;
this.row$ = this.store.pipe(
takeUntil(this.unsubscriber),
select(selectSelectedRow),
filter(isDefined)
);
this.history$ = this.row$.pipe(
switchMap(row => this.service.findHistory(row.uid))
)
}
ngAfterViewInit(): void {
// Dialog의 content요소, scrollTop을 가진다.
const matDialogContent = document.querySelector('mat-dialog-content');
if (!matDialogContent) {
throw new Error('mat-dialog-content 랜더링중 문제가 발생하였습니다.');
}
this.history$.pipe(
filter(history => !!history)
)
.subscribe(
() => matDialogContent.scrollTop = this.top;
)
}
}
<!--템플릿-->
<!--...-->
<mat-dialog-content data-testid="dialogContent">
<div data-testid="innerContent">
<h4> Block </h4>
<ngx-json-viewer *ngIf="block$ | async" [json]="block$ | async" data-testid="block"></ngx-json-viewer>
<h4> Block History</h4>
<ng-container *ngIf="(history$ | async) as history else loading">
<ngx-json-viewer *ngIf="history" [json]="(history)"></ngx-json-viewer>
</ng-container>
<ng-template #loading>
<h2>Loading.....</h2>
</ng-template>
</div>
</mat-dialog-content>
<!--...-->
화면에는 문제없이 정보가 정상적으로 출력되고 있었다. 그래서 잊고 있었는데, http요청 캐싱 기능을 구현하던 중 history 요청 API가 두번씩 호출되는걸 인지하였다.
row$
와 history$
에 tap
연산자로 넘어가는 값을 찍어보면 undefined
값을 제외하면 row$
는 4번, history$
는 2번 찍히는것을 확인할 수 있다.
tap
이 여러번 찍히는건 row$
와 history$
가 unicast
로 동작했기 때문이다. Template에서 row$
와 history$
를 각각 2/1번씩 구독하고 Component에서 history$
를 1번 구독했고, history$
는 row$
를 기반으로 만든 옵저버블이므로 총 4/2번의 tap
(side effect)이 호출된 것이다.
row$
의 소스 옵저버블인 Ngrx store
에서 select
한 state
는 상태 변화가 없어도 여러 컴포넌트/서비스 들에서 구독하면 값이 나왔기 때문에 당연히 multicast
로 동작한다고 생각했었다. 이건 맞는 생각이었다.
// Ngrx store.ts
@Injectable()
export class Store<T = object>
extends Observable<T>
implements Observer<Action>
{
constructor(
state$: StateObservable,
private actionsObserver: ActionsSubject,
private reducerManager: ReducerManager
) {
super();
this.source = state$;
}
// ...
export function select<T, K>(
mapFn: (state: T) => K
): (source$: Observable<T>) => Observable<K>;
// ...
}
store
는 select()
로 받은 selector
(==mapFn
)를 이용해 source
의 형태를 바꾼 뒤 반환한다. source
의 타입은 StateObservable
이다.
// Ngrx state.ts
export abstract class StateObservable extends Observable<any> {}
@Injectable()
export class State<T> extends BehaviorSubject<any> implements OnDestroy {
// ...
}
export const STATE_PROVIDERS: Provider[] = [
State,
{ provide: StateObservable, useExisting: State },
];
StateObservable
을 토큰으로 State
객체가 주입되고, State
는 BehaviorSubject
를 상속하는데, BehaviorSubject
는 multicast
로 동작해, 값 방출 이후에 구독해도 구독자는 최신값을 받을 수 있다.
결국 내가 컴포넌트의 생성자에서 store
에서 가져온 state
는 multicast
로 동작하는 것이다. 그런데 왜 row$
와 history$
는 유니캐스트로 동작한것일까?
그것은 내가 multicast 동작에 대해 잘못 이해했기 때문이다. 아래를 보자.
const multicast = interval(1000).pipe(
tap(() => console.log('se')),
shareReplay(1),
);
const sub1 = multicast.subscribe(console.log);
const sub2 = multicast.subscribe(console.log);
위 코드의 결과는 어떨까? 결과는 se11se22...
로 Sideeffect가 한번만 발생했다. 그런데 아래의 결과는 다르다.
const multicast = interval(1000).pipe(
shareReplay(1),
tap(() => console.log('se')),
);
const sub1 = multicast.subscribe(console.log);
const sub2 = multicast.subscribe(console.log);
결과는 se1se1se2se2...
로 shareReplay
연산자 뒤의 tap이 구독 횟수만큼 호출되고있다. 소스 옵저버블이 multicast
로 동작해도, 그 뒤의 연산자들이 어떻게 구성되느냐에 unicast
처럼 side effect
가 구독횟수 만큼 여러번 발생할 수 있는 것이다.
사실 이렇게 써놓고 보면 간단하지만 나는 이걸 헷갈렸고, 삽질을 했다.
가져온 state의 Observable을 컴포넌트 내부에서multicast
로 만들어서 사용하면 된다.
값이 방출되는 시점이 템플릿에서의 구독 시점 이전이라 shareReplay(1)
으로 최신값 1개를 모두 공유하도록 했다. history$
는 두번 구독되는데, http request를 보내는 switchMap
내부 함수가 한번만 호출되기 위해서는 switchMap 뒤에도 반드시 멀티캐스팅 연산자를 붙여줘야한다.
수정한 컴포넌트의 코드는 아래와 같다.
export class DetailDialogComponent implements AfterViewInit {
// ...
constructor (
// ...
) {
// ...
this.row$ = this.store.pipe(
takeUntil(this.unsubscriber),
select(selectSelectedRow),
filter(isDefined),
shareReplay(1)
);
this.history$ = this.row$.pipe(
switchMap(row => this.service.findHistory(row.uid)),
shareReplay(1)
)
}
// ...
RxJs는 편한 만큼 꼼꼼하게 코딩하지 않으면 어디서 메모리 누수나 이런 종류의 실수가 발생하는지 알기가 쉽지 않다. 조금만 이해를 잘못써도 이런 문제가 발생한다. 실수는 막을 수 없으니 유닛테스트를 정말 꼼꼼하게 작성해야 함을 느꼈다.