프로필사진
RxJS 'startWith'를 좀 더 똑똑하게 사용하는 팁

2021. 1. 5. 23:14🔴 FE/RxJS

300x250

levelup.gitconnected.com/rxjs-operator-tips-startwith-d67109c8883e

 

RxJS Operator Tips — startWith

Creating awesome by combining startWith and EventEmitter

levelup.gitconnected.com

해당 글은 Wandering Developer님의 'RxJS Operator Tips - startWith'를 번역한 글입니다.


startWith와 EventEmitter를 조합하여 멋진 것을 만들어보자.

RxJS documentation에 따르면,
startWith : source observable에서 값들을 emit 하기 전에 당신이 정해둔 특정 값들을 먼저 emit 한다.


The Problem

프로그래밍을 할 때, 우리는 주로 'why'가 아니라 'what'에 쉽게 사로잡힌다.
먼저 startWith의 정의부터 살펴보자.

만약 내가 sub value 리스트에 대한 seed value 쿼리를 가지고 있다면,
seed가 바뀌지 않은 상태에서 리스트를 업데이트하는 refresh event를 어떻게 subscribe 해야 할까?

위의 내용이 좀 혼란스러우니 하나씩 나눠서 보자.

- supporter system은 오픈티켓 리스트를 보여준다 path/to/tickets
- operator가 티켓을 고른다 /path/to/tickets/:id
- 해당 티켓에 대한 코멘트들이 모두 보인다
- refresh event를 타게 되면, 나는 페이지를 어떻게 refresh 해야 할까?

유저가 페이지를 refresh 할 수도 있다.
하지만 만약 우리가 PWA를 운영한다고 있다면 크롬 브라우저는 가시적이지 않기 때문에 refresh 하는 방법은 확실하지 않다.

Refresh Event를 사용하는 건 단순히 콘텐츠를 refresh 하는 버튼만을 의미하는 것이 아니기 때문에 유용하다.
만약 당신이 웹 소켓 시스템과 같은 것에 페이지를 연결해두었다면, 당신은 외부적인 시그널에 자동으로 리스트를 refresh 해야 하는 경우도 있을 것이다.

나는 startWith()가 얼마나 시간을 단축시켜주고, 명쾌한 솔루션을 만들어주는지 설명하려고 한다.


TLDR

EventEmitter supscription piplines에 startWith를 추가하면 이는 기다림 없이 첫 번째 값을 emit 할 것이다.

좀 더 디테일하게 살펴보자


The Setup

2개의 route(Ticket List, Ticket View)가 있는 앵귤러 프로젝트에서 시작해보자

1) Ticket List

여기 데모에서 ticket list는 굉장히 단순하다. 
티켓 아이디들과(1~9) <a>들로 이루어져 있어서 /tickets/:id로 이동시킨다.

import { Component } from '@angular/core';

@Component({
  selector: 'app-ticket-list',
  template: `
    <h1>ACME Works Ticketing System</h1>
    <p>Select a Ticket</p>
    <ul>
      <li *ngFor="let i of ticketIds">
        <a [routerLink]="i">Select Ticket: {{i}}</a>
      </li>
    </ul>
  `,
  styles: [
  ]
})
export class TicketListComponent {
  readonly ticketIds = [ 1,2,3,4,5,6,7,8,9 ];
}

2) Ticket View

ticket view에서 우리가 위에서 마주했던 문제를 해결할 것이다.
나는 Typescript code를 사용하여 3가지 솔루션을 제시할 것이고, HTML은 모두 동일하다.

레이아웃은 아래 사항들로 이루어져 있다:

- ticket title heading
- previous ticket button
- refresh comments button
- next ticket button
- list of all comments

<h1>Ticket Details for: {{ currentTicketId }}</h1>
<button (click)="nextTicket(-1)">Previous Ticket</button>
<button (click)="refreshRequested.emit()">Refresh</button>
<button (click)="nextTicket(1)">Next Ticket</button>
<ul>
  <li *ngFor="let comment of (commentList$ | async)">
    {{ comment }}
  </li>
</ul>

ticket view는 angular component이기 때문에 ActivatedRoute class를 사용하여 아이디를 가져올 수 있다.
ticket view를 처음 실행하면 이런 모습일 것이다:

export class TicketDetailsComponent {
  constructor(activatedRoute: ActivatedRoute, private router: Router) {
    // Do something with the activated route to extract the ticket id
  }
}

UI Screens

Sample - Fetching Comments

나는 모든 샘플 내에서 티켓의 comment를 가져오는 로직을 동일하게 사용할 것이다.
아래의 샘플에서는 실질적으로 데이터를 가져오지 않는다.
하지만 리턴되는 결과는 Observable<string[]>이며 이는 앵귤러 HttpClient에서 네트워킹할때 가져오는 Observable처럼 사용될 것이다.

fetchComments(ticketId: string) {
  // Usually you would make a HTTP call at this point...

  let commentCount = Math.random() * 12; // Randomly generate a number of comments

  let results : string[] = [];
  for (let index = 0; index < commentCount; index++) {
    results.push(`Ticket ID: ${id}. This is comment number: ${index + 1}`);
  }

  return of(results);
}

The Ugly

위에서 언급했었던 문제는 '어떻게 리스트를 refresh 할 것인가'였다. 
가장 쉬운 해결방안은 단순히 페이지를 refresh 하는 것이다.

export class TicketDetailsUglyComponent {
  
  readonly refreshRequested = new EventEmitter();

  readonly currentTicketId: number;

  readonly commentList$ : Observable<string[]>;

  refreshPage() {
    // UGLY !
    window.location.reload();
  }

  nextTicket(num: number) {
    window.location.href = "/ticket/" + (num + this.currentTicketId);
  }

  constructor(activatedRoute: ActivatedRoute, private router: Router) {
    this.currentTicketId = parseInt(activatedRoute.snapshot.paramMap.get("id"));

    this.commentList$ = this.fetchComments(this.currentTicketId.toString());
    
    // When the Refresh Requested event is fired, refresh the page
    this.refreshRequested.subscribe(() => this.refreshPage());
  }

  fetchComments(id: string) {
    // ...
  }
}

장점
- 리스트를 refresh를 할 수 있다

단점
- 페이지에 속해있는 모든 것, 즉 페이지 전체가 reload 될 것이다
- 추가적인 server load가 필요하다
- 페이지 안에서 쓰는 진행 바 또는 UX를 사용할 수 없다

이는 즉 모든 전체 웹 사이트에 대한 javascript, HTML, CSS가 다시 계산되어야 한다는 것이다.
만약 유저가 다른 것을 하고 있는 중이라면 그가 하고 있는 작업이 날아갈 수도 있다.
이러한 현상은 SPA의 전체 목표에 반할뿐더러 지나치도록 파괴적이어서 대형 망치로 견과를 부수는 격이다.

추가적으로, activatedRoute의 snapshot을 사용함으로써 우리는 앵귤러에서 제공하는 재사용 가능한 컴포넌트를 사용할 수 없게 된다.

The Bad

그다음, 우리는 완벽하지는 않지만 좀 더 나은 방법을 사용할 것이다.
이 예제에서 우리는 BehaviorSubject를 사용하여 티켓 아이디 값을 저장할 것이다.

export class TicketDetailsBadComponent {
  
  readonly refreshRequested = new EventEmitter();

  // Store the currentTicketId in a BehaviorSubject - it must be initialised with a starting value
  readonly currentTicketId = new BehaviorSubject<string>(null);

  readonly commentList$ : Observable<string[]>;

  constructor(activatedRoute: ActivatedRoute, private router: Router) {

    // Subscribe to the 'id' param and set the currentTicketId BehaviorSubject
    activatedRoute.paramMap.pipe(map(i => i.get("id"))).subscribe(i => this.currentTicketId.next(i));
    
    // Create the comment list (but only when the BehaviourSubject is not in it's initial value state)
    this.commentList$ = this.currentTicketId.pipe(filter(i => !!i), switchMap(i => this.fetchComments(i)), shareReplay());
    
    this.refreshRequested.subscribe(() => this.refreshComments());
  }

  refreshComments() {
    // Set the current ticket id to the same value to force a refresh
    this.currentTicketId.next(this.currentTicketId.value);
  }

  nextTicket(num: number) {
    this.router.navigate([ 'tickets', (parseInt(this.currentTicketId.value) + num).toString() ])
  }

  fetchComments(id: string) {
    // ...
  }

}

이 방법이 더 나아 보이긴 한다.
하지만 currentTicketId BehaviorSubject을 기존의 값과 동일한 값으로 리셋해야지만 'refresh'로직을 실행할 수 있다.

이 때문에 pipline은 해당 값이 바뀌었다고 인지하고 강제로 코멘트들을 로딩한다.

장점
- 페이지가 리로드 되지 않는다
- 다른 티켓으로 navigate 될 때 컴포넌트가 재사용된다

단점
- currentTicketId를 사용하는 모든 엘리먼트들은 코멘트가 바뀔 때마다 재계산된다
- 페이지가 로드됐을 때 처음 값이 null인지 filter 해야 한다
- 페이지 로드 & refresh event의 차이가 명확하지 않다

개인적으로, 나는 BehaviorSubjects를 많이 사용한다.
이는 값을 observable으로 전환시키는 좋은 방법이고, 손쉽게 바꿀 수도 있기 때문이다.

그럼에도 불구하고 좀 더 나은 방법이 있다.

The Good (or, possibly the Great)

여기까지 오느라 긴 시간이 걸렸다. 우리는 드디어 startWith를 사용할 것이다.

앵귤러의 EventEmitter는 굉장히 심플한 observable이다.
emit 메소드가 optional 한 값과 함께 call 되었을 경우, 해당 event에 대한 모든 subscriber들은 액션을 취한다.

우리의 목표는 들어오는 티켓 아이디 observable과 Refresh Requested event를 어떻게든 결합하는 것이다.
결과적으로, 티켓 아이디나 refresh event가 발생했을 때, 코멘트 리스트는 업데이트될 것이다.

나는 두 가지의 RxJs 컨셉을 사용하여 이 문제를 해결할 것이다:
1. combineLatest operator를 사용하여 아이디와 event를 listen 한다
2. startWith를 event에 추가하여 inital 값을 갖도록 한다

export class TicketDetailsComponent {

  // The current ticket id
  currentTicketId : string;

  // Observable list of comments
  readonly commentList$ : Observable<string[]>;

  // Event fired when a refresh is requested
  readonly refreshRequested = new EventEmitter();

  constructor(activatedRoute: ActivatedRoute, private router: Router) {
    // Inner observable to get the ticket id
    const ticketId$ = activatedRoute.paramMap.pipe(delay(0), map(i => i.get("id")), tap(i => this.currentTicketId = i));

    // Assign the comment list observable
    this.commentList$ = this._createPipeline(ticketId$);
  }

  nextTicket(num: number) {
    this.router.navigate([ 'tickets', parseInt(this.currentTicketId) + num ]);
  }

  private _createPipeline(seedValue: Observable<string>) {
    
    // Piping in startWith to the event (!!)
    const startWithTrigger = this.refreshRequested.pipe(startWith(true));

    return combineLatest(seedValue, startWithTrigger).pipe(
      map(i => i[0]), // Discard the refresh value, because it's only used to trigger the refresh
      switchMap(i => this.fetchComments(i)), // Your magic for actually fetching the comments
      shareReplay() // Store the value so it isn't re-queried on every subscription
    );
  }

  fetchComments(id: string) {
    /// ...
  }

}

번역을 하다가 잠시 코드가 어떻게 돌아가는지 메모를 해보았다.

화면에서 'refresh' 버튼을 누르면 -> refreshRequested.emit() 함수가 호출되는데
여기서 어떻게 combineLatest()가 실행되는지 잘 이해가 안 됐다.

찾아보니 정답은 'combineLatest'에 있었다.
공식 문서에 따르면, combineLatest는 최신 값들을 보내주기 때문에
지정된 observables (여기서는 seedValue & startWithTrigger) 중 어느 하나가 
새로운 값을 emit 하면 해당 값으로 다시 조합하여 보내준다.

즉,
seedValue는 constructor에서 지정한 'id' observable를 늘 가지고 있고
startWithTrigger은 refreshRequested로써 refresh 될 때마다 새로운 값을 emit 한다.
refresh 할 때마다 combineLatest가 [id, 새로운 refreshRequested 값]을 보내준다는 이야기다.

import { combineLatest, timer } from 'rxjs';

const firstTimer = timer(0, 1000); // emit 0, 1, 2... after every second, starting from now
const secondTimer = timer(500, 1000); // emit 0, 1, 2... after every second, starting 0,5s from now
const combinedTimers = combineLatest(firstTimer, secondTimer);
combinedTimers.subscribe(value => console.log(value));
// Logs
// [0, 0] after 0.5s
// [1, 0] after 1s
// [1, 1] after 1.5s
// [2, 1] after 2s

timer를 건 2개의 observables를 가지고 있는 combineLatest의 예시이다.
처음에는 [0,0]을 보내지만
1초 뒤에는 첫 번째 observable가 새로운 값을 emit 함으로써
[1,0]을 보낸다. 여기서 1은 새로운 값이고, 0은 두 번째 observable의 기존 값이다.
이렇게 항상 최신의 값을 유지하는 것이다.


장점
- 페이지가 리로드 되지 않는다
- 다른 티켓으로 navigate 될 때 컴포넌트가 재사용된다
- refresh event와 티켓 아이디가 변환되는 것을 분리할 수 있다

단점
- ?

combineLatest의 장점은 동일한 pipeline으로 코멘트들을 가져오는 데에 사용할 수 있다는 것이다.
이는 여러 observable를 가지고 하나의 배열로 output을 리턴 시키기 때문에 굉장히 유용한 function이다.
하지만 observable 각각 모두 어떠한 값을 emit 해야 한다.

이 상황에서 startWith를 사용하면 된다. 
startWith는 항상 어떠한 값을 emit 하기 때문에 코멘트들이 적어도 한 번은 로드될 것이다.

결과 Output (클릭해서 봐주세요)

Benefits

제일 큰 장점은 event를 pipeline의 한 부분으로써 접근 가능하도록 한다는 것이다.

우리는 데이터를 로드하고 refresh 해야 하는 경우가 많이 있다.
startWith 기능 없이는 코드를 더 지저분하게 짜야할 것이다.

Conclusion

startWith operator은 많은 시나리오에서 유용하게 사용할 수 있다.
initial value가 없는 observable를 다룰 때 ( of() & from() & BehaviorSubjet 외에 거의 모든 observables) inital value를 가지도록 하면 더 그럴듯할 것이다.

다른 것들과 마찬가지는 이는 주관적인 의견이다.
나는 '알맞은 해결방안을 찾는 것'에 열광하지만 당신에게는 솔루션이 되지 못할 수도 있다.
어쨌거나, RxJs operators은 방대하고 다양하고 많은 시나리오에서 유용하게 사용된다. 

300x250