6주차 발표자료 전형진 - TECH-SHARING-STUDY/FE_STUDY GitHub Wiki

Observable vs Promise

ObservablePromise는 비동기 데이터를 다루는 방식이 매우 유사합니다.

const promise = new Promise((resolve) => {
  setTimeout(() => resolve("This is Promise"), 1000);
});
// 1초 뒤 출력
promise.then(message => console.log(message));
const observable = new Observable((observer) => {
  setTimeout(() => observer.next("this is Observable"), 1000);
});
// 1초 뒤 출력
observable.subscribe(message => console.log(message));

위 예제를 보면, Promisethen 메소드가 Observablesubscribe 메소드와 대응되고, resolve 함수가 observernext 메소드와 대응되는 구조입니다.

그렇다면, 비동기 처리를 위해서 프로젝트에 굳이 Observable을 도입할 필요가 있는지 고민을 하게 됩니다. 추가적인 라이브러리 설치도 필요없기 때문에 이미 정식 스펙에 들어가 있는 Promise를 사용하는 것이 더 좋을 것입니다.

물론, Observable이 비동기 처리만을 위해 나온 개념이 아니기 때문에 비동기 처리 관점으로만 Observable의 사용 여부를 판단하는 것은 문제가 있습니다. 하지만 이 글에서는 Promise와의 비교를 위해 비동기 처리 관점으로만 Observable을 바라보며 특징을 살펴보겠습니다.

이 고민을 해결하기 위해 ObservablePromise와 무엇이 다르고, 그 차이로부터 얻는 이득이 무엇인지 살펴보겠습니다.

조급한(Eager) 평가 vs 느긋한(Lazy) 평가

첫 번째로 살펴볼 차이점은 PromiseObservable의 평가 시점이 다르다는 점입니다.

Promise는 조급한 평가

const startTime = Date.now();
const timestamp = () => Date.now() - startTime;

const promise = new Promise((resolve) => {
  setTimeout(() => resolve(`[${timestamp()}] this is Promise`), 1000);
});

promise.then(message => console.log(message));
setTimeout(() => {
  promise.then(message => console.log(message));
}, 1000);

// 출력
// [1001] this is Promise (1초 뒤)
// [1001] this is Promise (2초 뒤)

Promise 객체를 생성할 때 넘기는 함수는 Promise 생성 시점에 바로 평가됩니다. 즉, 생성 시점부터 Promise의 상태를 결정하는 로직이 실행된다는 것입니다.

이 함수는 단 한 번만 실행되고 그 결과로 Promise의 상태를 결정합니다. 이미 결정된 상태는 절대 바뀌지 않습니다.

이 뜻은 우리가 어떤 Promise객체의 then메소드를 통해 반환받는 상태는 then메소드 호출하는 시점에 상태를 결정해 반환하는 것이 아니고, 이미 결정된 상태를 반환한다는 뜻입니다.

Observable은 느긋한 평가

const observable = new Observable((observer) => {
  setTimeout(() => observer.next(`[${timestamp()}] this is Observable`), 1000);
});

observable.subscribe(message => console.log(message));
setTimeout(() => {
  observable.subscribe(message => console.log(message))
}, 1000);

// 출력
// [1001] this is Observable (1초 뒤)
// [2008] this is Observable (2초 뒤)

반면, Observable 객체를 생성할 때 넘기는 함수는 생성 시점에 평가되지 않습니다. 즉, 해당 함수는 지연적으로 평가됩니다.

그렇다면 언제 평가가 되는 것일까요? 바로 subscribe 메소드를 호출하는 시점에 평가되어 데이터를 만듭니다. 이 말은 곧 Observablesubscribe메소드가 호출되기 전까지는 아무런 데이터도 만들지 않는다는 뜻입니다.

평가 시점이 다른 것이 왜?

이제 평가 시점이 다른 것이 어떠한 성능 차이를 보이는지 알아보겠습니다. 결론부터 말씀드리자면, 느긋한 평가는 불필요한 연산을 회피할 수 있다는 점에서 조급한 평가보다 성능상 이득을 취할 수 있습니다.

다음은 서버로부터 유저의 정보를 가져와, 유저 3명의 이름만 추출하는 코드입니다. 서버로부터 받은 유저 정보가 100건이라 가정해보겠습니다.

// Promise
let eagerCount = 0;
fetch("http://localhost:4000/users") // (1)
  .then((resp) => resp.json()) // (2)
  .then(R.map((user) => (eagerCount++, user.name))) // (3)
  .then(R.take(3)) // (4)
  .then((threeUsers) => console.log(threeUsers, eagerCount));

// 출력
// [ 'Leta Sanford', 'Germaine Spencer', 'Eloise Mosciski' ] 100

// Observable
let lazyCount = 0;
ajax.getJSON("http://localhost:4000/users") // (1) + (2)
  .pipe(
    mergeMap((users) => from(users)),
    map((user) => (lazyCount++, user.name)), // (3)
    take(3) // (4)
  )
  .subscribe((user) => console.log(user, lazyCount));

// 출력
// Leta Sanford 1
// Germaine Spencer 2
// Eloise Mosciski 3

(1), (2) 연산에서는 Promise와 Observable가 동일하게 동작하기 때문에 성능상 차이가 없습니다. 하지만, (3), (4) 연산에서 두 객체는 다르게 동작합니다.

Promise case

  • (3) => 100건을 다 돌면서, 유저의 이름을 추출합니다.
  • (4) => (3)의 결과 중 앞 3건의 정보를 취합니다.
  • 즉, 실제 최종적인 결과는 앞에 위치한 3건만 필요하지만, (3) 과정에서 100건의 유저정보를 모두 순회했습니다.

Observable case

  • (3) => 평가되지 않고 지연됩니다.
  • (4) => 3건의 유저 이름을 얻기 위해, 지연된 (3) 과정을 앞에 위치한 3건에 대해서만 평가합니다.
  • 즉, 100건의 유저정보를 모두 순회하지 않고, 3건의 유저정보만 순회하여 이름을 얻었습니다.

만약 처리할 데이터의 양이 늘어나거나 (3) 과정의 연산이 무거울 경우, 성능의 차이는 더 벌어질 것입니다.

취소(Cancellation)

Promise와 Observable의 가장 큰 차이는 바로 취소(Cancellation)의 지원 여부입니다. Promise는 상태가 성공 혹은 실패로 결정될 때까지 계속 대기하지만, 중간에 이를 취소할 수 없습니다.

현재 Promise의 취소 기능은 TC39 위원회에 제안된 상태입니다.

https://github.com/tc39/proposal-cancellation

반면, Observable은 취소할 수 있는 인터페이스가 제공됩니다. Observable을 구독하면 반환되는 Subscription 객체를 통해서 취소가 가능합니다. 바로 unsubscribe 메소드를 호출하는 것입니다. 위에서도 말씀드렸지만 누군가가 구독을 해야 Observable이 동작하기 때문에 구독 해지는 곧 Observable의 동작을 취소하는 것과 동일하다고 볼 수 있습니다.

취소가 필요한 이유?

비동기 데이터를 다루는데 취소가 필요한 이유는 불필요한 연산을 막기 위해서입니다. 비동기 데이터가 비로소 준비되어 해당 데이터로 특정 로직을 처리하려는데, 더 이상의 처리가 불필요하고 무의미한 경우가 종종 있습니다.

대표적인 예가 자동완성 UI입니다. 디바운싱을 걸더라도 설정한 기준치보다 느리게 작성하면 여러 개의 요청이 서버로 전달될 것입니다. 하지만 실제로 필요한 것은 마지막 요청에 대한 응답 뿐입니다. 이전 요청에 대한 응답을 처리하는 것은 불필요한 연산 작업입니다. 이럴 때 취소 기능이 빛을 발합니다.

한 가지 더 예를 살펴보겠습니다. 리액트에서 이미 unmount된 컴포넌트에 대한 작업을 수행하는 경우입니다.

fetch(Promise) case

function useGetUsers() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch(GET_USERS)
      .then((resp) => resp.json())
      .then((users) => setUsers(users));
  }, []);

  return users;
}

fetch(Promise) - 정상

success-case

fetch(Promise) - unmount시 에러

error-case

Observable case

function useGetUsers() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    const users$ = ajax.getJSON(GET_USERS);
    const subscription = users$.subscribe(setUsers);

    return () => {
      console.log("취소!");
      subscription.unsubscribe();
    };
  }, []);

  return users;
}

Observable - unmount시 취소를 통해 정상처리

observable-success