NestJS HTTP Module (feat. Observable이 뭐야?)

haaaalin·2024년 1월 15일
post-thumbnail

NestJS를 이용해 서버를 개발하다, 다른 외부 API에 request 전송이 필요했습니다. 따라서, NestJS에서 axios를 래핑하여 정의한 HttpModule을 제공하고 있다기에 해당 HttpModule을 사용하기 위해 레포지토리를 구경하게 되었습니다.
살펴보니 각 axios가 제공하는 post, get과 같은 메서드를 감싸, Observable이라는 타입을 반환하고 있었습니다. 이 Observable 이라는 타입은 대체 무엇이길래 한 번 더 감싸서 return 하고 있는지 궁금해져서 학습하기 시작했습니다! 🔥

✨ HttpModule의 Observable 사용

HttpModule에서 provider로 제공 중인 HttpService가 axios를 래핑하고 있었습니다. 한 번 간단히 살펴보겠습니다.

HttpService가 제공하는 메서드

아래는 HttpService가 제공하는 axios 래핑 메서드 중 하나인 get()입니다. url과 AxiosRequestConfig를 인수로 받아, 다시 makeObservable() 메서드의 인자로 axios 인스턴스의 get 메서드와 함께 전달하고 있습니다.

get<T = any>(
url: string,
config?: AxiosRequestConfig,
): Observable<AxiosResponse<T>> {
	return this.makeObservable<T>(this.instance.get, url, config);
}

그렇다면 makeObservable() 어떤 로직을 수행하고 있는 걸까요?

makeObservable()

  • new Observable<AxiosResponse<T>>() 부분을 보면, Observable 객체를 생성해 return 하고 있다는 것을 알 수 있습니다.
  • CancelTokenSource: CancelToken.source() 팩토리를 사용해, 취소 토큰을 만들 수 있으며, CancelToken의 cancel() 을 호출하면 요청을 취소할 수 있습니다.
  • 그 외의 아래 코드를 보면 next(), complete()와 같은 메서드를 호출하는데, 이들은 Observable과 더불어 RxJS에서 제공하는 메서드입니다. 아래에서 무엇인지 알아본 후, 이 코드를 다시 전체적으로 알아보겠습니다.
  protected makeObservable<T>(
    axios: (...args: any[]) => AxiosPromise<T>,
    ...args: any[]
  ) {
    return new Observable<AxiosResponse<T>>(subscriber => {
      const config: AxiosRequestConfig = { ...(args[args.length - 1] || {}) };

      let cancelSource: CancelTokenSource;
      if (!config.cancelToken) {
        cancelSource = Axios.CancelToken.source();
        config.cancelToken = cancelSource.token;
      }

      axios(...args)
        .then(res => {
          subscriber.next(res);
          subscriber.complete();
        })
        .catch(err => {
          subscriber.error(err);
        });
      return () => {
        if (config.responseType === 'stream') {
          return;
        }

        if (cancelSource) {
          cancelSource.cancel();
        }
      };
    });
  }

🧷 RxJS 라이브러리

Reactive Extensions For JavaScript 라이브러리입니다. Reactive Extensions는 ReactiveX 프로젝트에서 출발한 리액티브 프로그래밍을 지원하기 위해 확장했다는 뜻입니다.
RxJS에서는 이벤트 스트림을 Observable이라는 객체로 표현해, 비동기 이벤트 기반의 프로그램 작성을 돕습니다.

리액티브 프로그래밍이란?

이벤트나 배열 같은 데이터 스트림을 비동기로 처리해, 유연하게 반응하는 프로그래밍 패러다임입니다.

  • Pull 시나리오: 외부에서 명령해, 응답받고 처리합니다. 데이터를 가지고 오기 위해서는 계속 호출해야 합니다.
  • Push 시나리오: 외부에서 명령 후, 기다리지 않고, 응답이 오면 그때 반응하여 처리합니다. 따라서 데이터를 가지고 오기 위해 구독해야합니다.
    => Reactive Programming은 이벤트 발생 시 그때그때 처리할 수 있는 Push 시나리오를 채택하고 있습니다.

📍 Observer pattern

객체의 상태 변화를 관찰하는 Observer의 목록을 객체에 등록해 상태 변화가 있을 때마다 메서드 등을 통해 객체가 직접 목록의 각 observer에게 통지하는 디자인 패턴입니다. pub/sub 형태를 생각하면 쉽게 이해할 수 있습니다.

Observable이란?

특정 객체를 관찰하는 Observer에게 여러 이벤트나 값을 보내는 역할을 합니다. 즉, Observer는 Observable이 전달하는 값의 consumer인 셈이죠. Observable이 전달하는 알림(next, error, complete)에 대해 각각 callback을 제공합니다.

Observable LifeCycle

  1. 생성 - Observable.create(): 어떠한 이벤트도 발생하지 않습니다.
  2. 구독 - Observable.subscribe(): 이벤트를 구독할 수 있습니다.
  3. 실행 - observer.next(): 이벤트를 구독하고 있는 대상에게 값을 전달합니다.
  4. 구독 해제 - observer.complete(): 구독하고 있는 모든 대상의 구독을 종료합니다.

1️⃣ Observable 생성

Observable을 생성할 때는 보통 생성자를 이용하거나 create 함수를 사용해 생성할 수 있습니다. (그 외에도 방법이 존재합니다. 링크) 아래는 Observable을 생성 시 Observer가 subscribe 할 때 실행할 onSubscription function을 인자로 전달해야 합니다.

Observable 생성 함수인 create()

static create: Function = <T>(subscribe?: (subscriber: Subscriber<T>) => TeardownLogic) => {
    return new Observable<T>(subscribe);
};

Observable 생성 예시

const observable = Observable.create(function (observer) {
  observer.next(1);
  observer.next(2);
  observer.next(3);
  observer.complete();
});

2️⃣ Observable 구독

생성한 Observable을 구독할 때에는 Observable.subscribe() 메서드를 호출하면 됩니다. 이때, subscribe 함수에 인자로 subscriber를 전달해야 하는데요.

Observable을 구독하기 위해 아래와 같이 코드를 작성합니다. Observable가 보내는 이벤트나 값을 처리하기 위해 next(), error(), complete() 와 같이 콜백함수를 갖고 있는 Subscriber 객체를 아래와 같이 subscribe() 메서드 인자로 전달합니다.

observable.subscribe({  
	next(x) {  
	console.log('got value ' + x);  
	},  
	error(err) {  
	console.error('something wrong occurred: ' + err);  
	},  
	complete() {  
	console.log('done');  
	},  
});

이렇게 Subscriber 객체를 이용해 subscribe를 하게 되면, Observable를 생성할 당시에 인수로 전달했던 onSubscription 함수가 Subscriber 객체를 인자로 받아 실행됩니다.

Subscriber class

https://github.com/ReactiveX/rxjs/blob/master/packages/rxjs/src/internal/Observable.ts

export class Subscriber<T> extends Subscription implements Observer<T> {
  //...
  next(value: T): void {
    if (this.isStopped) {
      handleStoppedNotification(nextNotification(value), this);
    } else {
      this._next(value!);
    }
  }

  error(err?: any): void {
    if (this.isStopped) {
      handleStoppedNotification(errorNotification(err), this);
    } else {
      this.isStopped = true;
      this._error(err);
    }
  }

  complete(): void {
    if (this.isStopped) {
      handleStoppedNotification(COMPLETE_NOTIFICATION, this);
    } else {
      this.isStopped = true;
      this._complete();
    }
  }

  unsubscribe(): void {
    if (!this.closed) {
      this.isStopped = true;
      super.unsubscribe();
      this._onFinalize?.();
    }
  }
  
  //...
}

3️⃣ Observable 실행

앞서 예시로 작성했던 Observable 객체를 아래처럼 구독했다고 가정해봅시다.

observable.subscribe({  
	next(x) {  
	console.log('got value ' + x);  
	},  
	error(err) {  
	console.error('something wrong occurred: ' + err);  
	},  
	complete() {  
	console.log('done');  
	},  
});

아래 코드를 실행해보면, 다음과 같은 출력 결과가 나타납니다.

got value 1
got value 2
got value 3
done

4️⃣ Observable 구독 해제

complete() 함수를 호출하면, Observable의 구독이 해제됩니다. 따라서 만약에 Observable을 아래와 같이 생성했다면, complete() 이후로는 구독이 해제되기 때문에 아래의 코드는 실행되지 않습니다.

const observable = Rx.Observable.create(function (observer) {
  observer.next(1);
  observer.complete();

  //complete 이후는 실행되지 않는다.
  observer.next(4);
  observer.error(new Error("error!!"));
  observer.complete();
});

✨ 간단하게 정리하자면

Observable: Observer가 구독할 이벤트를 정의하고, 이벤트 동작 중간 중간 Observer에게 알림을 보낼 로직을 추가해놓습니다.
Observer: Observable이 보내는 알림에 대해 각각 어떻게 처리할지에 대한 콜백 함수를 정의해놓으면 됩니다.

🔖 HttpModule의 Observable 다시 살펴보기

  protected makeObservable<T>(
    axios: (...args: any[]) => AxiosPromise<T>,
    ...args: any[]
  ) {
    return new Observable<AxiosResponse<T>>(subscriber => {
      const config: AxiosRequestConfig = { ...(args[args.length - 1] || {}) };

      let cancelSource: CancelTokenSource;
      if (!config.cancelToken) {
        cancelSource = Axios.CancelToken.source();
        config.cancelToken = cancelSource.token;
      }

      axios(...args)
        .then(res => {
          subscriber.next(res);
          subscriber.complete();
        })
        .catch(err => {
          subscriber.error(err);
        });
      return () => {
        if (config.responseType === 'stream') {
          return;
        }

        if (cancelSource) {
          cancelSource.cancel();
        }
      };
    });
  }
  • makeObservable() 함수는new Observable(subscriber => { ... }) 방식으로, Observable을 생성해 return 하고 있습니다.
  • 내부 로직에서는 cancelToken에 관한 로직 처리후, 사용자가 호출한 axios 메서드를 호출하고 있습니다.
  • axios 함수가 실행 완료된 후, then()이 실행되어 subscriber의 next()와 complete()가 차례로 실행돼, 구독이 해제됩니다.
  • 만약 error가 발생한다면, subscriber의 error()가 호출됩니다.
  • 밑에서 return 하고 있는 함수는 구독 취소(unsubscribe) 시에 호출되는 clean up 함수입니다.

Http request 메서드의 return 값

이제 HttpModule에서 제공하는 함수인 makeObservable을 이해했습니다. 하지만, 이해가지 않는 부분이 있습니다.
맨 위에서 살펴봤던 함수의 return 값이 makeObservable()입니다. 즉, Observable을 반환하고 있는 거죠.

  get<T = any>(
    url: string,
    config?: AxiosRequestConfig,
  ): Observable<AxiosResponse<T>> {
    return this.makeObservable<T>(this.instance.get, url, config);
  }

여기서 T는 Observable이 반환하는 value 타입입니다.

따라서, HttpModule을 이용해 Http Request를 전송한 후 값을 받기 위해서는 아래처럼 pipe() 함수를 이용해야 합니다.

const request = this.http
     .get('https://catfact.ninja/fact')
     .pipe(map((res) => res.data?.fact))
     .pipe(
       catchError(() => {
         throw new ForbiddenException('API not available');
       }),
     );

pipe()는 Operator를 Observable에 적용하기 위한 연산자입니다.

해당 메서드의 return 값에 대한 문제점..?

인프랩 기술블로그를 보니, Http Module이 제공하는 메서드의 반환 값에 대한 문제점을 언급하고 있었습니다. 해당 내용은 다음과 같습니다.
https://tech.inflab.com/20230723-pure-http-client/

요약

  • Observable을 반환하기 때문에, 쉽게 체이닝을 할 수 있는 장점이 있지만, 일회성 HTTP 요청이라면 Promise를 반환하는 것이 더 유용하다고 생각했고, Promise를 반환하도록 변경하는 방법은 내부적으로 Promise -> Observable -> Promise로 변환하는 불필요한 과정이 추가된다고 합니다.
  • AxiosResponse를 반환하는 것 또한 axios 라이브러리에서 제공하는 타입이기 때문에 결국 axios에 의존할 수 밖에 없다는 결론을 내렸습니다. axios도 언젠가 deprecated 될 가능성이 있어, 추후에 교체할 상황이 발생한다면 쉽지 않을 수 있다는 점을 언급했습니다.

따라서 따로 custom module을 만들어 사용하고 있다고 합니다.

마무리

일단 현재 HttpModule을 적용해 놓은 서버에서는 Observable을 반환하는 게 이득일지, 아니면 Promise를 반환하는 게 이득일지 아직은 제대로 판단하지 못했고, Observable에 익숙하지 않아 현재는 Http Service의 axiosRef를 이용해 axios의 메서드를 직접 호출하고 있습니다. 따라서, 반환값을 Promise로 받고 있습니다.

그렇다면, 사실상 axios를 직접 사용해도 될거라 생각도 했지만 일단 NestJS가 지향하는 module 개념을 사용하며 axios를 사용할 수 있어, HttpModule을 사용하고 있습니다. 추후에 프로젝트 추가 개발이 필요해 프로젝트 사이즈가 커질 경우, 논의해 인프랩 기술블로그를 참고해 custom module을 생성해보려고 합니다. ✨

profile
한 걸음 한 걸음 쌓아가자😎

0개의 댓글