[NestJS] Request-Timeout handling with Interceptor & RxJS

DatQueue·2023년 4월 11일
12

NestJS _TIL

목록 보기
12/12
post-thumbnail

💥 시작하기에 앞서

공부 중인 나로써는 대용량 데이터및 대규모 트래픽을 경험해보기 쉽지않다. 엇비슷하게 많은 양의 데이터를 조회하는 요청을 경험해보고자 일전에 "100000"건의 데이터를 더미데이터로 생성함으로써 요청에 대한 응답을 받는 과정을 수행해보았었다. 그 당시엔 페이지네이션을 통해 응답 데이터를 분할해서 조회해보았지만, 문득 해당 데이터를 전부 한번에 받게 되면 어떨까 생각하였다.

물론, 100000건의 데이터라는 것이 그리 많은 양의 데이터는 아니지만 한번에 조회하는 것은 일반적인 요청에 비해 소요시간이 많이 드는 작업이란 것은 확실하다. 실제로 본인의 네트워크 및 여러 부가적 상황에 따라 3s에서 길게는 10~15s의 시간이 소요되는 것을 확인할 수 있었다.

일반적으론 위에서 언급한 것처럼, "페이지네이션"이라는 작업을 통해 데이터를 불러오는 것이 일반적이지만, 특정 작업 (마이그레이션, 외부API 연동, ... 여러 case)에 따라 요청에 따른 응답 시간이 지연되는 경우는 충분히 일어나게 된다. 더군다나, 특정 요청의 데이터양은 많지 않더라도 특정 API 요청에서 "동시 다발적으로" 많은 유저가 몰릴 경우 서버 부하에 따라 응답 시간이 지연되게 된다.

여태 "유저"의 입장이었던 나로써는 클라이언트단에서 일련의 "유저 경험"을 위한 작업에 따라 "로딩 중"인 페이지를 보게 되었고, 혹은 특정 시간 이상 시 "리다이렉트" 과정을 통해 다른 페이지로 이동하는 경우도 경험할 수 있었다. 혹은, 특정 모달을 띄움으로써 유저로써 "응답 지연"임을 확인할 수 있게끔 알려주기도 한다.

흔히, 어디선가 들어본 "408 Request Timeout"이 이에 대한 에러 핸들링이다.

그럼 이때, "서버"의 경우엔 어떠한 일련의 처리를 수행하는지 궁금증이 생기기 마련이다.

조금 더 명확히 말하자면, 클라이언트가 위의 같은 일련의 처리를 수행할 수 있도록 서버측에선 지연이 발생하고 있다는 응답을 보내주어야할 것이다. 계속해서 응답 지연이 일어남에도 불구하고 이러한 처리를 해주지 않는다면, 서버의 부하는 계속해서 증가할 것이고 다른 작업및 요청에도 영향을 끼칠 것이다.

결국, 이는 "유저 경험"과 치밀하게 연관이 있다. 우리는 이러한 처리를 "Timeout Exception Handling" 이라 부른다.

물론 해당 처리는 클라이언트에서도 중요한 작업이지만, 서버측에서도 이에 못지 않게 중요한 과정에 해당한다.

이번 포스팅에선 "Timeout Exeception Handling" 을 구현해보는 것은 물론이지만, "RxJS"란 것에 대해서도 알아볼 예정이다.

"RxJS"를 통한 "리액티브 프로그래밍(Reacitve Programming)"을 사용해 더 효율적인 핸들링이 가능하다는 것 또한 함께 알아보고자 한다.

갑자기 왠 "RxJS"냐 할 수 있지만, 왜?(why) 해당 라이브러리 및 개념을 설명하게 되었는지 이번 글을 통해서 알게 될 것이다.


💥 NestJS가 바라본 RxJS

Timeout 예외 처리를 구현하는데 있어서 요청 응답 주기에 접근해야하므로 우린 nodejsnestjs에선 "Middleware"를 통해 이를 수행할 수 있다.

직접적으로 NestMiddleware를 통한 미들웨어로써 구현하게 되면 AppModule에 불러옴으로써 전역적으로 적용시킬 수 있다는 장점이 있지만, 다른 enhancer와 충돌이 생길 수도 있으며 동시에 각 라우트 핸들러 마다 개별적으로 적용하는데 있어 불편함이 있다.

여태껏 NestJS에서 MiddlewareInterceptor를 소개하고 어떤 것을 지양해야하는지 많이 다뤄보았었고, 이번에도 역시 타임아웃 핸들링을 하는데 있어 Interceptor로써 구현하기로 하였다.


> 공식문서에서 제시하는 TimeoutInterceptor

아래는 "NestJS" 공식문서에서 예시로써 소개하는 TimeoutInterceptor이다.

" The possibility of manipulating the stream using RxJS operators gives us many capabilities. Let's consider another common use case. Imagine you would like to handle timeouts on route requests. When your endpoint doesn't return anything after a period of time, you want to terminate with an error response. The following construction enables this: "

해당 문구에선 "경로 요청"의 시간 제한(timeout)에 대한 예시를 제시하며 RxJS를 활용하여 스트림을 조작하는 기능과 관련된 사례라 언급한다.

// timeout.intercept.ts

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      timeout(5000),
      catchError(err => {
        if (err instanceof TimeoutError) {
          return throwError(() => new RequestTimeoutException());
        }
        return throwError(() => err);
      }),
    );
  };
};

해당 TimeoutInterceptor는 전역, 혹은 특정 컨트롤러 혹은 특정 라우트 핸들러 함수에 적용되어 요청에 대한 응답과정이 5000ms를 초과할 경우 catchError를 통해 RequestTimeoutExceptinon(408 Request Timeout)를 보내준다. 즉, 타임아웃 핸들링을 하는 것이다.

물론, 이번 포스팅에선 TimeoutInterceptor의 기능에 초점을 둘 것이지만 RxJS의 사용또한 짚고 넘어가고자 한다.


여태껏, 이전의 포스팅들에서 Interceptor를 다룰 시 인터셉터를 NestJS의 LifeCycle의 측면에서만 바라보았지 RxJS를 사용함으로써 구현하게 되는 Reactive Programming 측면에서 바라본 적은 없었다.


위에서 보다 시피, intercept()함수가 반환하는 Observable부터 타임아웃을 처리하는데 사용하는 timeout, throwError, catchError 등등의 연산자들이 rxjs 라이브러리에서 불러온 것을 확인할 수 있다.

그럼 도대체 RxJS는 무엇일까?


> RxJS에 대해 알아보자 (Reactive X)

해당 개념에 대해 너무 깊게 들어가면 끝도없다. 반응형 프로그래밍이란 개념이 등장하고, 이를 함수형 및 선언형 프로그래밍과도 연관지을 수 있다. 동시에 사용할 수 있는 수 많은 오퍼레이터가 존재하고, 이를 전부 다뤄보며 알아가기엔 지금 당장은 무리이다.

해당 파트에선 간단히 이를 알아보고, 우리의 NestJS - Interceptor에 적용할 수 있을정도만 경험해보고자 한다.

✔ RxJS란?

정확히 말하면 Reactive X 라이브러리이며, 이를 사용할 수 있는 언어에 따라 RxJava, RxJS, Rx.NET, RxCpp 등으로 나뉘는 것이다. 즉, RxJS는 자바스크립트를 통해 작성된 ReactiveX 라이브러리이다.


✔ Observable과 Observer를 통해 알아보는 Reactive Programming

먼저 그전에 바탕이 되어야하는 개념이 있다. "리액티브 프로그래밍"은 "비동기 데이터 스트림"을 활용한 프로그래밍이다.

"Data Stream""일련의 데이터가 연속적으로 흐르는 데이터 흐름" 이라 할 수 있고, 이는 데이터를 순차적으로 처리하고 저장하기 위해 사용된다.

"순차적"이라는 것에 초점을 둘 필요가 있다. 다시 말해, "시간"의 흐름에 따라 데이터가 정렬된다는 것이다.

아주 간단한 코드를 통해 알아보자.

import { interval, take } from "rxjs";

const observable$ = interval(1000).pipe(take(4));
const observer = {
   next: (item: number) => console.log(item),
   error: (err: number) => console.log(err),
   complete: () => console.log('complete'),
};
observable$.subscribe(observer);

단순히, 0부터 시작해 정수형 값을 1씩 늘려가며 4개의 숫자를 1000ms 간격으로 반환하는 코드이다.

결과는 1초 간격으로 0,1,2,3 이 출력될 것이다.

여기서 핵심은 ObserverObservable"subscribe"하면서 next, error, complete 키워드를 사용하여 이벤트를 처리한다는 것이다.

만약 결과 값을 0, 1, 2, 3이 아닌 각 수의 제곱 수인 0, 1, 4, 9로 받고 싶다면 어떻게 처리해줄까? 아래와 같이 처리해줄 수 있다.

import { interval, take, map } from "rxjs";

const observable$ = interval(1000).pipe(
    take(4),
    map(item => item * item), // `rxjs`의 map() 함수를 적용시킨다.
 );
const observer = {
    next: (item: number) => console.log(item),
    error: (err: number) => console.log(err),
    complete: () => console.log('complete'),
};

observable$.subscribe(observer);

observablepipe() 내부에서, map(item => item * item)이란 코드, 즉 map() 연산자를 사용함으로써 원하는 구현을 할 수 있게 되었다. 위 코드에 한해서는, 자바스크립트 내장 함수인 map()과 동일한 기능이지만 이는 rxjs에서 제공하는 연산자이며 엄연히 다르다.

JS의 map()은 배열을 대상으로 작동하고, 새로운 배열을 반환한다. 반면에 RxJS의 map() 연산자는 Observable을 대상으로 작동하며, 새로운 Observable을 반환한다. 더하여 RxJS에서 이러한 연산자는 데이터 스트림에서 발행되는 각 데이터에 대해 순차적으로 적용되며, 다양한 처리를 할 수 있다.

위의 예시를 물론, 아래와 같이 기존 JS 문법 만으로도 충분히 구현할 수 있다.

const intervalId = setInterval(() => {
  for (let i = 0; i < 4; i++) {
    console.log(i * i);
  }
  clearInterval(intervalId);
}, 1000);

무언가 의문이 생길 것이다.

얼핏 보더라도 일반적 JS 문법만으로 구현한 코드가 RxJS를 사용한 것보다 더 짧고, 간결해보인다.

하지만 코드의 양이 작다고, 코드가 짧다고 해서 코드가 깔끔하고 가독성이 좋은 것은 아니다.

위의 코드는 아주 단순한 연산을 위한 코드여서 와닿지 않겠지만, let i = ?과 같이 코드에 노출된 변수들은 더 복잡한 코드에 있어, 분명한 위험 요소로 작용될 수도 있다. (멀티스레드 환경에서 동시접근에 의한 오류, 상태값 관리 측면에서의 오류 ... 등의 위험이 존재한다.) 하지만, rxjs를 사용하면 "선언적인" 방식으로 코드를 작성할 수 있으며 굉장히 직관적이다 할 수 있다.


※ 참고

"" "' "'
Observable"lazy" 하다. 누군가 구독(subscribe)을 해야 발행을 시작하게 된다.
"" "' ""

위는 리액티브 프로그래밍에서 굉장히 중요하게 언급되는 문구이다.
Observable은 "lazy"하다는 특징을 가진다. Observable이 발행(publish)을 시작하려면, 해당 Observable을 구독(subscribe)해야 한다. 즉, Observable은 데이터를 먼저 생성하거나 발행하지 않고, "구독" 될 때 비로소 이를 수행한다. "pull"이 아닌 "push"방식으로 동작한다는 것이 이런 의미이다.

이러한 특징은 Observable이 비동기적으로 데이터 스트림을 처리할 때 매우 유용하다.

단순한 처리 뿐만 아니라, "HTTP 요청"에 대해서도 그 결과를 받아오는 과정을 Observable을 통해 수행할 수 있다. 추후, 우리가 다룰 부분과 연관지어 가장 의미있는 예시가 아닐까 싶다.
일반적으로 HTTP 요청은 비동기 처리되고, Observable을 이용하면 해당 비동기 처리를 일반 콜백 함수를 사용하여 처리하는 것 보다 훨씬 직관적이고 가독성있게 처리할 수 있다. 또한, 에러처리에 있어서도 쉽게 구현할 수 있다.

이렇게 "lazy"한 Observable(Pub)과 이를 구독하는 Observer(Sub)의 관계 속에서 다양한 오퍼레이터와 함께 효과적인 비동기 데이터 스트림 처리를 수행할 수 있게 되는 것이다.


> NestJS의 Interceptor는 왜? rxjs를 사용하는가?

우린 NestJS의 인터셉터와 관련된 몇 가지 포스팅을 다루며 해당 enhancer는 "요청과 응답의 처리를 가로채어 일련의 작업을 수행" 한다는 것을 알고 있다.

또한, 모든 경우가 그렇진 않겠지만 흔히 요청, 응답에 접근하는 과정에 있어서 "비동기 처리"에 해당하는 작업이 대부분이다.

일전에 우리가 응답 객체를 제어하기 위해 (원하는 JSON-Serializing을 위해) 아래와 같은 인터셉터를 만들어 본 경험이 있었다.


NestJS에서 응답 객체에 어떻게 접근할까? -- 해당 포스틱 클릭 ✔


// reponse-serialize.intercept.ts

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { instanceToPlain } from "class-transformer";
import { Observable, timeout } from "rxjs";
import { map, tap } from "rxjs/operators";
import logger from "src/test-api/utils/log.util";
import { User } from "src/user/model/user.entity";

@Injectable()
export class ResponseSerializeInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
      logger.debug(`Request Accepted`);
      return next.handle().pipe(
        map((data) => {
          if (isUser) {
            const alteredResponse = instanceToPlain(data);
            logger.debug(`Response Altered : ${JSON.stringify(alteredResponse)}`);
            return alteredResponse;
          }else {
            return data;
          }       
        }),
        tap(() => logger.debug(`After Handling, Response Sent`))
    )
  }
}

const isUser = (data: any): data is User => {
  return ('id' in data && typeof data.id === "number") 
    && ('first_name' in data && typeof data.first_name === "string")
    && ('last_name' in data && typeof data.last_name === "string") 
    && ('email' in data && typeof data.email === "string")
    && ('password' in data && typeof data.password === "string")
} 

간단히 설명하자면 User 데이터를 응답받는 과정에서 일련의 작업을 통해 데이터를 가공시키 위한 인터셉터이다.

우린 파이프(pipe()) 안에 rxjsmap()함수를 담아 해당 내부에서 구현하고자 하는 가공의 작업을 취해줄 수 있고, tap()함수를 통해 부가적 기능을 추가할 수 있다.


우리가 다루고자 하는 Timeout 인터셉터 또한 마찬가지다. rxjs에서 제공하는 timeout() 연산자를 사용해 타임아웃 제한시간을 설정할 수 있고, catchError()를 통해 타임아웃 발생 시 에러 처리를 진행할 수 있다.

// timeout.intercept.ts

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      timeout(5000),
      catchError(err => {
        if (err instanceof TimeoutError) {
          return throwError(() => new RequestTimeoutException());
        }
        return throwError(() => err);
      }),
    );
  };
};

이처럼 인터셉터에서 리턴 값으로 Observable을 사용하면 RxJS의 연산자를 활용하여 다양한 방식으로 데이터를 처리할 수 있는 장점이 있다.

흔히, 우리가 인터셉터를 "HTTP 요청과 응답의 흐름을 가로챈다" 라고 표현을 하는데 이는 데이터 스트림의 관점에서 보면 "데이터 흐름을 가로챈다" 라고 볼 수 있는 것이다.

또한, 이러한 관점에서 보면 Observable임을 나타내는 pipe() 함수 부분을 "발행자(Pub)"이라 볼 수 있고, 해당 Observable을 반환하게 되는 intercept()"구독자(Sub)"이라 볼 수 있다. (인터셉터의 명확한 구독자를 정의하긴 애매하지만 위와 같이 생각할 수도 있지 않을까 싶다)


리액티브 프로그래밍을 더 설명하기엔 끝도 없다. 여태 언급한 개념과 접근법을 바탕으로 아래에서 본격적인 TimeoutInterceptor를 만들어 보도록 하자.


💥 TimeoutInterceptor 를 구현해보자

물론, 앞전의 공식문서에서 가져온 TimeoutInterceptor 를 사용해도 무방하지만, 그대로 사용하는 것보단 조금 더 구체화하여 사용하는것이 어떨까 생각이 들었다.

어떻게 Timeout 값을 설정하는지의 관점에 따라 크게 두 가지로 나누어 구현해보았다.


> 정적(Static) TimeoutInterceptor 구현하기

첫 번째는, Static하게 타임아웃값을 받아오는 것이다. 모든 경우에 해당하는 것은 아니겠지만, 특정 요청에 대한 응답을 받는데에 있어서 클라이언트가 "제한시간"을 서버측에 직접 요구할 수 있다. 이럴 경우엔 직접 특정 제한시간을 설정해주어야 한다.

Interceptor 구축하기

StaticTimeoutInterceptor는 아래와 같이 작성할 수 있다.

// static-timeout-handle.interceptor.ts

import { CallHandler, ExecutionContext, HttpException, HttpStatus, Injectable, NestInterceptor } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { catchError, delay, Observable, retry, tap, throwError, timeout } from "rxjs";
import { TIMEOUT_METADATA_KEY } from "../decorators/timeout-handle.decorator";

@Injectable()
export class StaticTimeoutInterceptor implements NestInterceptor {
  constructor(private readonly reflector: Reflector) {}
  intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {

      const timeoutValue = this.reflector.get<number>(TIMEOUT_METADATA_KEY, context.getHandler());
      
      // retry-options
      const retryCount = 3;

      return next.handle().pipe(
        timeout(timeoutValue),
        retry(retryCount),
        catchError((error) => {
          if (error.name === 'TimeoutError') {
            console.log(`Timeout of ${timeoutValue}ms exceeded`);
            return throwError(() => new HttpException('Request Timeout', HttpStatus.REQUEST_TIMEOUT));
          } else {
            return throwError(() => error)
          }
        }),
        tap(() => {
          console.log('Request completed');
        }),
      );
  }
}

해당 인터셉터를 전역적으로 사용할 수도 있겠지만, 특정 라우트 핸들러 요청에만 사용하는 것에 초점을 맞추었다. 모든 http 요청에 대한 연산 시간 및 조건이 동일하지 않으므로, 각 라우트 핸들러 함수마다 서로 다른 타임아웃 시간을 주입해주도록 하였다.

즉, 인터셉터 내 선언한 timeoutValue"커스텀 데코레이터"를 통해 받아오게끔 하였다.

파이프라인 내부에서 "timeout -> retry -> catchError -> tap" 연산자의 순으로 정렬된 것을 확인할 수 있을 것이다.

timeout 연산자에 의해 먼저 제한시간을 설정한다. 만약, 제한 시간내에 요청에 대한 응답이 반환될 경우, 바로 tap 으로 넘어간다. 하지만, timeout 제한 시간내에 응답을 성공시키지 못할 경우 retry를 통해 요청 재시도를 수행하고 이에 따라 catchError 를 통해 에러를 처리한다.

그런데 여기서 retry 연산자에 대해 의문을 가져볼 수 있다. 잠깐, 해당 인터셉터를 주입한 10만건의 상품 데이터 요청건에 대한 로그문을 살펴보자.

제한 시간으로 설정한 300ms 동안 응답이 전달되지 않자, catchError에서 설정해준 에러 문구가 출력된 것을 볼 수 있다. <기존 요청 쿼리문 "1" + 재시도 요청 쿼리문 "3"> 총, "4"번의 요청이 찍힌 것을 알 수 있다.

하지만, 보다시피 재시도 요청을 날렸다고해서 이미 timeout 제한 시간을 넘어버려 에러를 띄운 경우에, 해당 에러가 응답 성공으로 바뀌지는 않는다.

그렇다면 왜? 굳이 retry 연산자를 추가해준 것일까?

일반적으로 timeout이 발생하면 API 호출이 실패하게 된다. 따라서 retry를 시도하더라도 timeout이 발생하는 경우에는 재시도가 성공할 가능성이 매우 낮다.

하지만, retry 연산자를 사용하는 이유는 timeout 발생에 대한 재요청이라기 보다, 서버의 과부하 등 일시적인 문제 때문에 API 호출이 실패할 수 있기 때문이다. 이러한 경우, 일시적인 문제가 해결되면 API 호출이 다시 성공할 수 있다.


가령, 아래와 같이 말이다.

일반적으로는, 응답에 성공할 경우 retry가 수행되지 않는다. 하지만 "네트워크 지연, 서버 과부하 등등"과 같은 일련의 이유로 인해 서버에서 일정 시간 내에 요청을 받지 못할 경우 또한 존재한다. 항상 TimeoutError만 존재하는 것이 아니란 얘기이다.

이처럼, 위와 같이 정확히는 알 수 없는 일련의 이유로 재시도 쿼리 요청이 수행되고, 2번의 재시도 끝에 응답 성공에 이르게 된다.

재시도 횟수를 3으로 설정하였지만, 2번의 추가 쿼리 요청만 온 것에 대한 정확한 이유는 알 수 없지만, 2번의 재시도만에 응답에 성공하였고 추가 재시도는 필요없기 때문에 수행되지 않은 것으로 추측된다.


에러 핸들링 중, catchError() 부분에선 TimeoutError가 아닌 경우에 대해서도 에러처리를 한다. 해당 에러가 앞서 언급한 "네트워크 지연, 서버 과부하 등등의 일시적인 문제" 라고 볼 수 있다.


Custom Decorator 생성

// timeout-decorator.ts

import { SetMetadata } from "@nestjs/common";

export const TIMEOUT_METADATA_KEY = "timeout";

export const TimeoutHandler = (ms: number) => SetMetadata(TIMEOUT_METADATA_KEY, ms);

✔ 라우트 핸들러에 적용하기

  @Get('all')
  @TimeoutHandler(300) // timeout 시간을 300ms로 설정
  async all() {
    return this.productService.all();
  }

※ 주의!

UseInterceptor()가 아닌 커스텀 데코레이터를 통해 인터셉터를 사용 시 NestJS의 모듈은 이를 해석하지 못한다. 즉, 아래와 같이 provider내에 주입시켜 주어야 한다.'

// app.module.ts

@Module({

  // ...
  
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: TimeoutInterceptor,
    },
  ],
})
export class AppModule {}


> 동적(Dynamic) TimeoutInterceptor 구현하기


✔ 정적(Static) 타임아웃 설정의 문제

번째는, Dynamic하게 타임아웃 값을 받아오는 것이다.

앞서 구현해보았던 StaticTimeoutInterceptor는 말 그대로 타임아웃 값을 직접 설정함으로써 나타낼 수 있었다. 이러한 고정 값은, 여러 요청-응답 시도 끝에 얻게된 경험값일 수도 있고, 유저 경험을 위해 클라이언트 측에서 요구한 값일 수도 있다.

하지만, 정적 타임 아웃을 수행할 경우 몇 가지 고려해봐야할 사항이 존재한다.

대용량 레코드 조회, 유저가 몰리는 트래픽 등 충분한 지연이 발생할 것이라 기대되는 요청의 경우, 어느 정도 예상은 할 수 있지만 정확한 지연 시간을 얻기란 쉽지 않다. 네트워크의 상태나 메모리 이슈와 같은 부수적 문제 또한 관여할 수 있기 때문이다.

예를 들어, 실제로 어떤 요청을 수행하는데 있어 "30초"의 시간이 소요되었다고 하자. 하지만, 아무도 해당 시간을 정확히 예측하지 못하였다. 이때, 클라이언트 측에서 "25초"timeout 값을 요구하였다. 그러면 사실 "어짜피" 설정한 제한 시간을 넘어선 요청-응답 수행이므로 timeoutError 오류를 받게 될 것이다.
그렇다고, timeout"10초, 15초 .."로 줄이기엔 너무나 확실한 에러를 받을 것으로 예상되므로 쉽지 않은 결정이다.

이처럼 "불필요한 대기 시간"이 존재하게 된다.

어쩌면 당연할 수 있는 대기 시간이지만, "유저 경험"의 측면에서 불필요하고 좋지 못한 타임아웃 설정이라 할 수도 있다.
(물론 그렇다고 "정적 타임아웃을 쓰면 안된다" 라는 것은 아니다. 상황과 요구에 맞게 필요한 방식을 써야할 것이다.)


✔ 동적(Dynamic)으로 타임아웃 값을 설정해보자.

우린 위에서 언급한 일련의 문제들을 고려하여 "동적"으로 타임아웃 값을 얻고자 한다. "불필요한 대기 시간 개선"을 포함해 "응답 시간 최적화"의 측면에서도 기대를 할 수 있을 것이다.

동적 타임아웃 값을 얻고자 하는데 있어, 실로 다양한 방법이 존재할 것이다. 아직 실무를 경험해보지 못한 나로써는 어떠한 방법이 있는지는 모르겠지만 "이러한 방식으로도 구현해볼 수 있다?" 정도에 초점을 맞추고 수행해보고자 한다.

그럼 어떻게 동적으로 타임아웃 값을 얻고자 하는가?


해당 구현의 방법으로

"서버 자원(Memory, CPU)"

을 고려하여 접근하다.


왜? 서버 자원을 통해 타임아웃에 접근하였는가?

일반적으로 모니터링(Monitoring)과 같은 서버의 성능 측면에서 대표적으로 고려되는 것에 "CPU", "Memory", "Disk", "Network" 가 있다.

하지만, 네트워크에 접근하는 것은 사실상 힘들기 때문에 이를 제외하고 (물론 Disk도 고려하지 않는다) 서버의 성능에 사실상 "척도"라고 할 수 있는 "CPU""Memory"를 통해 timeout 값을 계산해보기로 한다.

우리는 NodeJS(NestJS) 환경에서 CPU와 Memory 사용률에 접근하기 위해, "OS Module"을 사용하기로 한다. 정확히 말해선 os-utils 를 사용할 것이다. 큰 차이는 없지만 조금 더 세세한 표현이 가능하다.


OS | Node.js ✔


그럼 먼저 코드를 알아보자.


DynamicInterceptor 생성

// dynamic-timeout.interceptor.ts

import { CallHandler, ExecutionContext, HttpException, HttpStatus, Injectable, NestInterceptor } from "@nestjs/common";
import { catchError, delay, Observable, retry, tap, throwError, timeout } from "rxjs";
import * as osUtils from "os-utils";

@Injectable()
export class DynamicTimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> | Promise<Observable<any>> {
      const maxTimeout = 3000; // 최대 timeout 설정
      const minTimeout = 1000; // 최소 timeout 설정
	  
      // CPU 사용률 계산
      const cpuUsage: number = await new Promise((resolve) => {
        osUtils.cpuUsage((value) => {
          resolve(value);
        });
      });

      const memoryUsage = 1 - osUtils.freememPercentage(); // 메모리 사용률 계산

      // timeout 값 계산하기
      const timeoutValue = maxTimeout - Math.round((maxTimeout - minTimeout) * ((cpuUsage + memoryUsage) / 2));

       // retry-options
      const retryCount = 3;
  
      return next.handle().pipe(
        timeout(timeoutValue),
        retry(retryCount),     
        catchError((error) => {
          if (error.name === 'TimeoutError') {
            console.log(`Timeout of ${maxTimeout}ms exceeded`);
            return throwError(() => new HttpException('Request Timeout', HttpStatus.REQUEST_TIMEOUT));
          } else {
            return throwError(() => error)
          }
        }),
        tap(() => {
          console.log(`Request completed + ${timeoutValue}ms`);
        }),
      );
  }
}

return 이후의 반환부는 동일하다. 데이터 스트림의 파이프라인 내 Observable의 연산을 통해 요청에 대한 일련의 처리를 진행한다.

주목해 볼 부분은 timeoutValue 값을 도출해 내는 과정이다.

인터셉터의 intercept 메서드에서는 먼저 os 모듈을 사용하여 현재 시스템의 CPU 사용량과 메모리 사용률을 구한다. 해당 사용률과 설정해 둔, 최소 타임아웃(minTimeout)과 최대 타임아웃(maxTimeout)을 통해 최종 timeoutValue를 동적으로 구하게 된다.


그럼 해당 부분에 대해 간단히 알아보자.

  • maxTimeout과 minTimeout은 왜 설정해줄까?
    : 그냥 하나의 방법일 뿐이다. 우린 해당 범위를 설정해줌으로써 타임아웃이 무조건 해당 범위안에서 계산되는 값으로써 도출해낼 수 있다. 결국, 해당 maxTimeoutminTimeout의 값에 따라서 timeoutError를 띄울지, 요청에 대한 응답을 보낼지가 결정된다.
    즉, 이에 따라 적절한 최대 및 최소 값을 설정하는 것이 필요하다.

  • os 모듈을 이용한 사용률 계산

    • osUtils.cpuUsage(callback) : CPU의 사용률을 구하는 것이다.(0~1 사이의 값을 가진다, 콜백을 반환한다.)

    • const memoryUsage = 1 - osUtils.freememPercentage(); : 사용 가능한 메모리 용량을 통해 현재 사용 중인 메모리 사용률을 구할 수 있다. (0~1)

    • timeoutValue 구하기 : 먼저 (cpuUsage + memoryUsage) / 2 부분은 CPU 사용률과 메모리 사용률의 평균값을 계산한 것이다. 이 값을 통해 CPU 사용률과 메모리 사용률 중 어느 쪽이 더 큰 영향을 미치는지 구분하여 timeoutValue를 계산할 수 있다. (0~1 사이의 값이 나올 것이다.)

      그리고 (maxTimeout - minTimeout) * (cpuUsage + memoryUsage) / 2는 timeoutValue가 허용 가능한 범위 내에서 타임아웃 값을 결정하게끔 한다. 정확히 말하면 위 식에서 반올림하여 구한 값이 timeoutValue가 되는 것이다.

      하지만, 우린 해당 값을 또 한번 maxTimeout에서 빼주거나 혹은 minTimeout에서 더해준다. 이는, timeoutValue가 "허용 가능"과는 별개로 "실제" 범위 내에서 위치시켜 주기 위함이다.

      아래의 최종식이 이렇게 도출된다.

     const timeoutValue = maxTimeout - Math.round((maxTimeout - minTimeout) * (cpuUsage + memoryUsage) / 2);

✔ 요청 테스트 하기

위의 코드와 수치(maxTimeout, minTimeout)를 토대로 10만건의 상품 데이터 요청을 날려보면 응답을 잘 받아옴과 동시에

[Nest] 35700  - 2023. 04. 11. 오후 10:02:45     LOG [NestApplication] Nest application successfully started +11ms
query: SELECT `Product`.`id` AS `Product_id`, `Product`.`title` AS `Product_title`, `Product`.`description` AS `Product_description`, `Product`.`image` AS `Product_image`, `Product`.`price` AS `Product_price` FROM `products` `Product`
Request completed + 705ms

tap()에서 받아온 로그를 통해 timeoutValue705ms로 설정되었다는 것을 알 수 있다.

이렇게 우린 "동적"으로 "서버의 상태에 맞게끔" timeout 값을 얻게 될 수 있다.


✔ 추가적 포인트 - CPU와 Memory에 가중치(weights) 부여

우리의 DynamicTimeInterceptor를 조금 더 구체화 시킬 수도 있다.

앞서 언급하였다시피, CPU 사용률과 메모리 사용률은 서버 부하의 중요한 지표 중 하나이다. 이들을 "가중치"로 사용함으로써 timeoutValue를 조금 더 구체화 하는 방법도 있다. CPU 사용률이 매우 높은 애플리케이션의 경우 CPU 가중치를 높게 설정하고, 메모리 사용률이 매우 높은 애플리케이션의 경우 메모리 가중치를 높게 설정하는 것이 적절할 수도 있다.


ex) 
      // CPU 사용률과 메모리 사용률에 대한 가중치를 설정
      const cpuWeight = 0.7;
      const memoryWeight = 0.3;

      // timeout 값 계산하기
      const timeoutValue = Math.round((maxTimeout - minTimeout) * (cpuWeight * cpuUsage + memoryWeight * memoryUsage) + minTimeout);

	  // retry-options
      // cpu 사용률이 높을때 더 많은 재시도를 보낸다.
      const retryCount = Math.floor(cpuUsage * 3) + 1;

하지만 이러한 방식은 모든 서비스에서 적절한 방식은 아니며, 단순히 가중치를 정하기엔 상당히 많은 이해와 경험이 필요하다. 즉, 초기엔 보수적으로 설정하는 것이 바람직할지도 모른다.


💥 생각정리

단일 서버에서 처리하기 어려운 대규모 트래픽이나 데이터 처리 및 무거운 연산을 다루는데 있어 실무에선 여러 대의 서버를 하나의 시스템으로 묶는 "클러스터링(Clustering)" 혹은 부하를 분산시켜주는 "로드밸런싱(Load Balancing)"등 다양한 기술을 사용한다.

"Nginx"와 같은 프록시 서버를 두는 것도 이러한 기술을 위한 방법이다. 어디선가 "504 Gateway timeout"이라는 문구를 본 적이 있을 것이다. 이것은 서버와 클라이언트간 proxy 연결 시간이 default 타임을 넘겨서 발생하는 일종의 "timeoutError"이다. 이렇게 단순 서버-클라이언트의 관계가 아닌 프록시 서버를 두는 입장에서도 timeout과 관련된 핸들링은 항상 고려해 주어야할 부분이다.

아직 위의 기술들을 접하는 것은 나에게 분명한 "Overhead"지만, 이번 포스팅에서 다룬 "TimeoutInterceptor"를 시작으로 이러한 원리에 대해 조금 더 다가갈 수 있었다.

또한, 여태까지 궁금하지만 깊게 찾아본 적이 없었던 "RxJS"에 대해 알아보았고, 동시에 "왜?" NestJS의 Interceptor는 이러한 데이터 스트림을 통한 리액티브 패러다임을 제시하는지에 대해 고민해보는 시간을 가졌다.

그럼 해당 포스팅은 여기서 마무리짓겠다.

추후 수정안 혹은 부가 설명이 있으면 추가해보도록 하겠다.

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

3개의 댓글

comment-user-thumbnail
2023년 4월 11일

글 너무 재미있게 잘 읽었습니다 😀
OS 리소스 상황에 따라 동적으로 timeout 값을 조절하는게 신기하네요!
읽으면서 몇 가지 궁금한 점이 있어서 남겨봅니다 ㅎㅎ

  1. 두 개의 시나리오 모두에서 DB에 read 쿼리를 날려서 10만건의 요청을 테스트하셨는데요. 일반적으로 짧은 시간 내에 DB에 많은 read 요청이 몰리게 되면 애플리케이션 서버가 아닌 DB가 병목 지점이 되어 두 개의 시나리오 간 유의미한 차이가 없었을 것 같기도 합니다.
    실제로 두 시나리오 간 성능을 테스트 툴 등을 사용해 측정한다면 어떤 결과가 나오나요? 예를 들자면..
  • 정적 timeout과 동적 timeout을 비교했을 때, 동일 시간 내 throughput은 동일했나요?
  • 정적 timeout을 적용했을 때와, 동적 timeout을 적용했을 때 10만 건의 요청에 대한 응답 성공 비율은 각각 어땠나요?
  • 정적 / 동적 timeout 방식의 best case, worst case, average case 는 각각 어떻게 다른가요?
  1. 읽기 연산이 아닌 쓰기 연산을 수행했을 때의 결과가 궁금합니다. 또 요청이 리액티브하게 처리되는 도중에 Nest는 트랜잭션을 어떻게 처리하나요?
    가령 Nest의 인터셉터는 들어온 요청에 대해 순서를 가지고 각 인터셉터가 실행되는 것으로 알고 있는데, 트랜잭션을 인터셉터를 통해 처리한다면 트랜잭션의 정합성이 다른 인터셉터에 의해 깨질 수도 있는지 궁금합니다.
1개의 답글
comment-user-thumbnail
2023년 8월 30일

너무 좋은 글입니다!!!!

답글 달기