본문 바로가기

Backend/NestJS

NestJS - 대충 서비스 만들어보기 (14)

이번 포스팅에선 NestJS 의 인터셉터에 대해 보려고 합니다만.. 미들웨어, 가드, 예외 필터, 인터셉터, 파이프 등 뭔가 비슷해 보이는

것들이 참 많네요. Express 로 개발시엔 미들웨어로 대부분 다 처리했던 것 같긴 합니다만, 이것들을 약간 구분하기 위해 NestJS 의 request lifecycle 에 대해 먼저 보도록 하겠습니다 (링크)

 

- Request Lifecycle (요청 생명주기)

 

요청에 대한 생명주기는 들어온 요청이 처리되고 최종 응답되기까지 어떠한 컴퍼넌트들을 거쳐 처리되는지를 말합니다.

 

 

요청이 들어왔을 때 처리되는 컴퍼넌트의 순서를 NestJS 공식 페이지 내용을 참고해 도식화한 것 입니다.

요청 -> 미들웨어 -> 가드 -> 인터셉터(pre-controller) -> 파이프 -> 컨트롤러 -> 서비스(없을 수도 있음) ->

인터셉터(post-controller) -> 예외필터 -> 응답의 순서로 진행되며, 컨트롤러 전의 컴퍼넌트들에선

전역 -> 컨트롤러 -> 라우터 의 순서를 따라가고 컨트롤러 이후 컴퍼넌트들에선 라우터 -> 컨트롤러 -> 전역 순서로 그 반대입니다.

 

미들웨어는 전역으로 바인딩 된 미들웨어를 먼저 실행하고 그 후 바인딩 된 순서대로 모듈 미들웨어를 실행합니다.

여러가지가 섞여있으면 루트 모듈에 바인딩 된 미들웨어를 실행하고 그 후는 imports 에 정의된 순서대로 실행하는 식입니다.

 

가드 또한 전역 가드를 먼저 시작하고 컨트롤러 가드, 라우터 가드가 그 뒤를 잇습니다. app.useGlobalGuard() 를 사용했거나 모듈을 통해서 가드를 제공하는 경우엔 전역 가드로 볼 수 있으며, class 또는 특정 라우터에 UseGuards 데코레이터를 사용한 경우는 컨트롤러 가드 또는 라우터 가드로 보면 됩니다.

 

인터셉터 또한 가드와 순서는 동일하지만 이는 pre-controller 단계에서고, post-controller 에선 이것과 반대의 순서를 이룹니다.

 

...

export class TmpController {
  ...
  
  @UsePipes(RouteTmpPipe)
  @Patch(':id')
  updateSomething(
    @Body() body: UpdateSomethingDTO,
    @Param() params: UpdateSomethingParams,
    @Query() query: UpdateSomethingQuery,
  ) {
    return ...;
  }
}

 

파이프도 동일하고 그 순서에 특별할건 없지만 파이프가 적용된 매개변수가 여러개일때는 정의된 순서의 역순으로 적용된다는 점입니다.

예를 들면 위와 같은 코드에서 RouteTmpPipe 파이프를 적용할 때 매개변수는 query -> params -> body 순서대로 적용됩니다.

이는 컨트롤러 파이프였어도 똑같습니다.

 

예외필터는 라우터 -> 컨트롤러 -> 전역 순서로 동작하지만 다른 애들과 좀 다른 점은 하나의 필터가 예외를 잡았으면 다른 필터가 동일한 예외를 잡을 수 없습니다. 예를 들어 라우터에 설정된 예외필터가 SomethingException 라는 에러를 캐치했으면, 컨트롤러 및 전역 예외필터는 이와 동일한 에러를 잡을 순 없다는 거죠. 이미 처리된 예외에 대해 뒤에 있는 필터가 또 처리를 하는건 이상하기 때문에

어찌보면 당연한 것 같습니다.

 

- 인터셉터

 

NestJS 의 인터셉터는 요청과 응답을 가로채 변형을 할 수 있는 컴퍼넌트입니다. (링크)

인터셉터는 AOP 의 영향을 상당히 많이 받은 컴퍼넌트이며, 아래와 같은 것들을 할 수 있습니다.

 

  • 메서드 실행 전 후 추가 로직 바인딩
  • 함수 반환 결과를 변경
  • 함수에서 던져진 예외를 변경
  • 함수 기본 동작을 확장
  • 특정 조건에 따라 함수 재정의 (캐싱 목적 등)

사실 지금까지 해놓은 광고 서버에 딱히 인터셉터를 갖고 할건 없습니다.

그래서 실제로 추가하진 않겠지만 인터셉터를 사용하는 예시만 2개 정도 보고 넘어가려 합니다.

 

- 인터셉터 활용 : 로깅

 

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> | Promise<Observable<any>> {
    const { method, url } = context.getArgByIndex(0);
    console.info(`${new Date()}: Request to ${method} ${url}`);

    return next
      .handle()
      .pipe(
        tap(() =>
          console.info(`${new Date()}: Response from ${method} ${url}`),
        ),
      );
  }
}

 

지난 포스팅에서 예외필터를 위치시켰던 src/aop 디렉토리에 logging-interceptor.ts 라는 파일을 생성하고 위와 같이 코드를

작성했습니다. 해보려고 한 것은 요청을 처리하기 전, 후에 로그를 출력하는 것입니다.

방식은 위와 같이 NestInterceptor 를 구현하면 되며 코드상 return next.. 가 호출되기 전 영역이 요청을 처리하기 전 부분에 해당되고 .pipe(...) 부분이 요청을 처리한 후에 인터셉터가 동작하는 부분입니다. 로깅과 같이 side effects 를 수행할 수 있도록 허용된 rxjs 의
tap operator 를 사용해 간단한 console log 를 찍도록 넣어봤습니다.

 

import {
  ...
  UseInterceptors,
} from '@nestjs/common';
...

@Controller('ads')
export class AdsController {
  ...

  @UseInterceptors(LoggingInterceptor)
  @Get()
  findAll() {
    console.info('@@@ controller');
    return this.adsService.findAll();
  }

  ...
}

 

특정 라우터에 인터셉터를 적용한 코드입니다. 실제로 구현한 NestInterceptor 에서 요청 전 후를 구분해 실행되는지를 확인해보기 위해

컨트롤러에도 간단한 로그를 하나 넣어봤습니다. 이제 이 라우터로 요청을 보내봅니다.

 

 

요청 처리 전 인터셉터 처리 로그 -> 컨트롤러 -> 요청 후 인터셉터 처리 로그가 의도한대로 남는 것을 확인했습니다.

이렇게 간단한 로깅을 넣을 수도 있을테고 지금 생각나는 것은 전역으로 인터셉터를 적용해 각각의 라우터가 요청을 처리해 응답하기까지 걸리는 시간등을 시각화하기 위해 메트릭으로 남기는 작업도 인터셉터에서 처리할 수 있을 것 같습니다.

 

- 인터셉터 활용 : 응답 변형

 

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> | Promise<Observable<any>> {
    return next.handle().pipe(
      map((data) => {
        return { data };
      }),
    );
  }
}

 

원래라면 로깅용 인터셉터는 아니라서 파일명 + 클래스명을 변경해야하지만 예시로만 볼거라 그대로 재활용했습니다.

이번엔 라우터가 전달한 응답을 data 라는 키안에 넣어서 응답하도록 형태를 조금 변형하는 코드입니다.

 

 

먼저 위는 인터셉터를 적용하기 전의 응답입니다.

 

 

인터셉터를 적용 후 요청을 보내보면 원래의 응답이 data 키에 지정되어서 오는 것을 확인할 수 있습니다.

이것을 활용해 라우트에서 처리 도중 던져진 예외도 인터셉터가 잡아서 변환을 할 수 있습니다. 다만 이 부분은 예외필터라고 더 적합한

컴퍼넌트가 있기 때문에 예시로도 보지는 않을겁니다.

인터셉터로 이런 것들을 할 수 있으니 서버 구성중 필요한 부분에 적절히 활용해보시기 바랍니다. AOP 의 영향을 많이 받은 만큼 이런 컴퍼넌트들을 잘 사용하면 부가적으로 필요한 부분에 의해 메인 로직이 더럽혀지는 꼴은 보지 않고 깔끔하게 코드를 짤 수 있을 것 같습니다.