비동기를 우아하게
처리하기 위한 Observable

나석주 @FEConf 2019

나석주

  • 토스 (비바리퍼블리카)
  • Angular를 좋아하지만, 주로 React를 다룹니다.
  • Web Tech, Open Source ❤️

1. Promise의 한계

2. Observable 소개

3. RxJS로 비동기 프로그래밍

4. Advanced RxJS

웹에서 처리하는 비동기들

  • DOM Events
  • Ajax
  • Animations
  • 시간 (Throttling/Deboucing 등)
  • Websockets, Workers 등
const button = document.getElementById('btn');

button.addEventListener('click', event => {/* ... */});

Callback

Callbacks

Callback Hell

fetchInitialData((error, data) => {
  if (!error) {
    setTimeout(() => {
      fetchData(data, (error, data) => {
        if (error) {
          handleError(error);
        } else {
          fetchOtherData(data, (error, data) => {
            /* ... */
          });
        }
      });
    }, 1000);
  }
});
fetchData(id)
  .then(data => {
    return fetchOtherData(data.id);
  })
  .then(data => {
    return parseData(data);
  })
  .catch(error => {
    handleError(error);
  });

Promises

Promise의 한계

  • 취소 불가능
  • 단일값

왜 취소가 필요한가요?

예: Auto-Complete

시간

"angular"

"react"

Ajax

Calls

UI

입력

"취소가 필요한 경우는 많다."

  • 라우트 변경
  • Impression
  • 사용자가 원할때

Promise의 한계

  • 취소 불가능
  • 단일값 
  • DOM Events
  • Ajax
  • Animations
  • 시간 (Throttling/Deboucing 등)
  • Websockets, Workers 등

대부분의 비동기는 값이 여러개이다.

무엇을 사용해야 할까요?

Observable

  • 비동기로 발생하는 여러 데이터를 다루는 인터페이스
  • "이벤트 스트림"
  • 취소 가능
  • 비동기 흐름을 쉽게 읽을 수 있음

Observable 가볍게 살펴보기

let observable: Observable;
observable.subscribe(observer);
const observer = {
  next: value => console.log('next', value),
  error: err => console.error('error', err),
  complete: () => console.info('complete'),
};

Observable 구독

observable.subscribe(
  value => console.log('next', vlaue),
  err => console.error('error', err),
  () => console.info('complete'),
);
const subscription = observable
  .subscribe(observer);

Observable 구독 취소


subscription.unsubscribe();

Observable 생성

const ob = new Observable(subscriber => {




});
const ob = new Observable(subscriber => {
  subscriber.next('0️⃣');
  subscriber.next('1️⃣');
  subscriber.next('2️⃣');
  subscriber.complete();
});
ob.subscribe({
  next: value => console.log('값: ${value}'),
  complete: () => console.log('✅');
});
// 값: 0️⃣
// 값: 1️⃣
// 값: 2️⃣
// ✅
ob.subscribe({

  
});

Observable

오류 처리

const ob = new Observable(subscriber => {




  



});
const ob = new Observable(subscriber => {
  subscriber.next('0️⃣');
  subscriber.next('1️⃣');
  subscriber.next('2️⃣');
  subscriber.error('💀');
  
  subscriber.next('값이');
  subscriber.next('더 이상');
  subscriber.next('흐르지 않아요.');
});
ob.subscribe({
  next: value => console.log('값: ${value}'),
  error: error => console.log(error),
  complete: () => console.log('✅');
});
// 값: 0️⃣
// 값: 1️⃣
// 값: 2️⃣
// 💀

Observable은 오류 발생 이후에 종료됨

Observable

Cleanup

const ob = new Observable(subscriber => {
  subscriber.next('0️⃣');
  subscriber.next('1️⃣');
  subscriber.next('2️⃣');
  
  subscriber.complete();
  

  
    
});
const ob = new Observable(subscriber => {
  subscriber.next('0️⃣');
  subscriber.next('1️⃣');
  subscriber.next('2️⃣');
  
  subscriber.complete();
  
  return () => {
    console.log('Cleanup!');
  };
});
const subscription = ob.subscribe();
subscription.unsubscribe();
// Cleanup!

Event Listener 해지 또는 Ajax abort 등

Observable.of

Observable.from

Observable.of('hello');
Observable.of(1, 2, 3);
Observable.from([1, 2, 3]);
Observable.from(otherObservable);

Observable Composition

function transform(prev: Observable<number>): Observable<string>;
function transform(prev) {
  return new Observable(subscriber => {
    prev.subscribe(
      value => subscriber.next(`값은 ${value} 입니다.`),
      error => subscriber.error(error),
      complete => subscriber.complete(),
    );
  });
}
transform(Observable.from([1, 2, 3]))
  .subscribe(value => console.log(value));
// 값은 1 입니다.
// 값은 2 입니다.
// 값은 3 입니다.

Observable Composition

Make

Chainable

Observable.prototype.map = mapFn => {
  const source = this;
  
  return new Observable(subscriber => {
    source.subscribe(
      value => subscriber.next(mapFn(value)),
      error => subscriber.error(error),
      () => subscriber.complete(),
    );
  });
};
Observable.from([1, 2, 3])
  .map(x => x * 2)
  .subscribe(value => console.log(value));

// 2
// 4
// 6
let clicks = 0;
let timeoutId;

function handleClick() {
  clicks += 1;
  
  if (!timeoutId) {
    timeoutId = setTimeout(() => {
      timeoutId = null;
      clicks = 0;
    }, 400);
    return;
  }
  
  clearTimeout(timeoutId);
  
  if (clicks <= 2) {
    timeoutId = setTimeout(() => {
      timeoutId = null;
      clicks = 0;
      console.log('더블클릭!');
    }, 400);
  } else {
    timeoutId = setTimeout(() => {
      timeoutId = null;
      clicks = 0;
    }, 400);
  }
}

button
  .addEventListener('click', handleClick);
const clicks = fromEvent(button, 'click');

clicks
  .buffer(clicks.throttleTime(400))
  .map(events => events.length)
  .filter(count => count === 2)
  .subscribe(() => {
    console.log('더블클릭!');
  });

비동기 흐름을 선언적으로 작성

Observable

Composition이 강력한 이유

지금 사용할 수 있나요?

아직 Draft 상태

(tc39 proposal stage1)

       RxJS

  • Observable 구현체 제공
  • Composition 및 생성 유틸리티 제공 (a.k.a operator)
    • Observable을 재활용하여 성능 ⬆
  • Scheduling

Observable 생성 유틸리티

import { of, from, interval, ajax, fromEvent } from 'rxjs';

of(1, 2, 3)
from(Promise.resolve('future'));
interval(1000);
ajax('https://api.example.com');
fromEvent(button, 'click');

...

RxJS Operators

  • map
  • filter
  • buffer
  • flatMap/mergeMap
  • switchMap
  • throttleTime/debounceTime
  • takeUntil
  • take
  • catchError
  • scan
  • ...

Piping?

Observable.from([1, 2, 3])
  .map(x => x * 2)
  .filter(x => x < 5)
  .subscribe();
Observable.from([1, 2, 3]).pipe(
  map(x => x * 2),
  filter(x => x < 5)
).subscribe();
  • prototype을 확장하면 문제가 많다.
    • Global
    • Tree-shaking 이 힘듬

rxjs@<5.5.0

rxjs@>=5.5.0

RxJS 실전 예제

feat. Auto-Complete

Auto-Complete Spec

  • 입력값이 바뀌면 검색결과를 Ajax로 불러와 결과를 화면에 표시한다.
  • 입력값은 150ms 간격으로 Debounce 처리한다.
  • 입력값이 바뀌면 이전 Ajax 요청은 취소시킨다.

RxJS로 짜보자!

???

그림으로 이벤트 흐름을

파악해 봅시다.

(feat. Marble diagrams)

정의 기호

= next

= error

= complete

시간 축
 
이벤트



 
Observable





 

⌨️

⌨️

⌨️

⌨️

"a"

"b"

"c"

"d"

Keyboard events
Input values

= 150ms

"a"

"a"

"b"

"c"

"d"

"d"

"e"

"e"

inner

outer

unsubscribe!

"a"

"b"

"c"

🍎

🍎

🥦

🥦

inner

outer

inner에서 에러가 발생하면
outer도 죽어버림

"a"

"b"

🍎

🍎

switchMap
catchError
outer.pipe(
  switchMap(x => inner(x)),
  catchError(/* ... */)
).subscribe();
outer.pipe(
  switchMap(x => inner(x).pipe(
    catchError(/* ... */),
  )),
).subscribe();

⌨︎

⌨︎

⌨︎

⌨︎

⌨︎

⌨︎

"a"

"b"

"c"

"d"

"e"

"f"

"b"

"c"

"d"

"f"

🍎

🥦

🍎

🥦

switchMap
debounceTime
map

Observable

좋은선택일 수 있는 이유

Pull

  • 외부에 명령하여 응답받고 처리
  • 값을 가지고 오기 위해서는 계속 호출
  • iteration

Push

  • 외부에 응답이 오면 그때 반응하여 처리
  • 값을 가지고 오기 위해서 구독
  • observation

Program

Environment

Mouse Click

Keyboard

I/O

File

Pull

Push

Storage

Websockets

  • DOM Events
  • Animations
  • 시간 (Throttling/Deboucing 등)
  • Websockets, Workers
  • ...

웹에서 다루는 대부분의 비동기는 Push 모델이 적합

Observable이 좋은 선택이 될 수 있다.

결론

  • Observable
    • 비동기로 발생하는 여러 데이터를 처리할 수 있다.
    • 취소 가능
  • Observable composition 💪
    • 비동기 흐름을 선언적으로 작성
  • Observable은 Push형태의 비동기 처리에 적합한 모델
    • 웹의 비동기 대부분이 Push형태이다. 
    • Observable이 비동기 처리에 좋은 선택이 될 수 있다.

은총알은 없다.

Two

More Things

1. RxJS Scheduling and Animation

  • RxJS Scheduler
    • 언제 구독이 시작될지 또는 언제 값이 전달될지 타이밍을 계산
    • 기본값으로 Schduler는 지정되어 있지 않음
  • animationFrameScheduler
    • 다음 requestAnimationFrame에 값이 전달됨
function timeElapsed() {
  const scheduler = animationFrameScheduler;
  
  return defer(() => {
    const start = scheduler.now();

    return range(0, Number.POSITIVE_INFINITY, scheduler).pipe(
      map(() => scheduler.now() - start)
    );
  });
}

// milliseconds 시간동안 0...1 까지의 값이 전달됨
function duration(milliseconds: number) {
  return timeElapsed().pipe(
    map(elapsedTime => elapsedTime / milliseconds),
    takeWhile(timing => timing <= 1)
  );
}
const startOffset = window.scrollY;
const distancePos = 1000;

const direction = distancePos >= startOffset ? 1 : -1;
const distance = Math.abs(distancePos - startOffset);

// 500ms 동안 1000px 위치로 ease-in-out 스크롤 시킴.
// 단, 중간에 사용자가 마우스휠을 하면 애니메이션은 취소됨.
duration(500).pipe(
  map(easeInOut),
  map(timing => timing * distance),
  tap(frame => {
    window.scrollTo(0, startOffset + frame * direction);
  }),
  takeUntil(fromEvent(window, 'mousewheel'))
).subscribe();

2. ESNext pipeline operator

Stage1

function doubleSay (str) {
  return str + ", " + str;
}
function capitalize (str) {
  return str[0].toUpperCase() + str.substring(1);
}
function exclaim (str) {
  return str + '!';
}
let result = exclaim(capitalize(doubleSay("hello")));
result //=> "Hello, hello!"

let result = "hello"
  |> doubleSay
  |> capitalize
  |> exclaim;

result //=> "Hello, hello!"
const ob = Observable.from([1, 2, 3])
  |> filter(x => x % 2 === 1)
  |> map(x => x * 2);

ob.subscribe(val => console.log(val));

// 2
// 6

감사합니다

https://seokju.me

비동기를 우아하게 처리하기 위한 Observable

By Seokju Na

비동기를 우아하게 처리하기 위한 Observable

2019.10.26 FEConf 2019 발표

  • 3,427