Nest.js 의 Interceptor 와 AOP

김법우·2022년 12월 3일
1

Nest.js

목록 보기
10/10
post-thumbnail

Nest.js 의 interceptor 클래스

들어가며

이전 포스팅에 이어서 Nest.js 의 Interceptor 클래스를 활용해 AOP 를 구현해보고자 한다. AOP 에 대해 다시 한번 되새김질을 해보자면, AOP 는 공통된 역할을 수행하지만 파편화 되어 있는 코드를 응집화 시킨 Aspect 를 허용하여 해당 코드를 사용하는 모듈에서 참조하는 것이 아닌, Aspect 로 감싸 부가 기능을 수행하는 것이다.

오늘 포스팅 할 내용을 간단히 요약하면, 계속 중복되는 코드들이 있는데 계속 보다보니 이게 어떤 공통된 역할을 수행하더라 → 모듈화 해야겟구만, 그런데 얘는 비즈니스 로직이랑은 관련이 없잖아 ? 비즈니스 로직에서 참조하는게 맞을까? 에 대한 해결 책이다.

Nest.js 의 Interceptor 클래스

인터셉터에는 AOP( Aspect Oriented Programming ) 기술 에서 영감을 받은 일련의 유용한 기능이 있습니다 .

  • 메서드 실행 전/후에 추가 로직 바인딩
  • 함수에서 반환된 결과 변환
  • 함수에서 발생한 예외 변환
  • 기본 기능 동작 확장
  • 특정 조건에 따라 함수를 완전히 재정의합니다(예: 캐싱 목적).

(출처 : https://docs.nestjs.com/interceptors)

공식 문서를 보면 “메서드 실행 전/후에 추가 로직 바인딩”, “특정 조건에 따라 함수를 완전히 재정의합니다(예: 캐싱 목적).” 이라는 말이 있다. 비즈니스 로직과는 별개로, 우리의 관점을 주입 할 수 있는 아주 적절한 클래스가 Interceptor 라는 것을 알 수 있다.

/**
 * 로깅 인터셉터
 * @description 데이터의 변경을 수행하는 함수는 반드시 함수의 인자값과 결과값을 콘솔에 로깅해야한다.
 * - 함수의 파라미터 콘솔 로깅
 * - 함수의 이름, 소속 클래스 로깅
 * - 함수의 리턴값 로깅
 */
export class LogInterceptor implements **NestInterceptor** {
  intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Observable<any> | Promise<Observable<any>> {
    throw new Error('Method not implemented.');
  }
}

주석에 적힌 기능을 AOP 로 구현하기 위해 interceptor 를 사용한다고 생각해보자. 이를 위해서는 @nestjs/common 패키지에서 제공하는 NestInterceptor 인터페이스를 구현해야한다. 해당 인터페이스에는 intercept 메서드를 구현하도록 되어있는데, 이 메서드는 ExecutionContextCallHandler 를 인자로 받아, 비동기 혹은 동기 형태로 Observable 을 반환하는 메서드이다.

export interface NestInterceptor<T = any, R = any> {
    /**
     * Method to implement a custom interceptor.
     *
     * @param context an `ExecutionContext` object providing methods to access the
     * route handler and class about to be invoked.
     * @param next a reference to the `CallHandler`, which provides access to an
     * `Observable` representing the response stream from the route handler.
     */
    intercept(context: ExecutionContext, next: CallHandler<T>): Observable<R> | Promise<Observable<R>>;
}

두 인자에 대해 자세히 알아보자. interceptor 는 Client Request → Pre Interceptor → Controller Handler → Service → .. → Controller Handler → **Post Interceptor** → Client Response 의 단계에서 수행된다.

즉, 어느 단계에서 수행되는지를 보았을때 첫번째 인자인 ExecutionContext 가 Client 부터의 요청에 대한 정보를 담고 있겟구나 하는 것을 유추해 볼 수 있다.

Execution Context, ArgumentsHost 는 어떻게 쓰는걸까?

export interface ExecutionContext extends ArgumentsHost {
    /**
     * Returns the *type* of the controller class which the current handler belongs to.
     */
    getClass<T = any>(): Type<T>;
    /**
     * Returns a reference to the handler (method) that will be invoked next in the
     * request pipeline.
     */
    getHandler(): Function;
}

...
export interface ArgumentsHost {
    /**
     * Returns the array of arguments being passed to the handler.
     */
    getArgs<T extends Array<any> = any[]>(): T;
    /**
     * Returns a particular argument by index.
     * @param index index of argument to retrieve
     */
    getArgByIndex<T = any>(index: number): T;
    /**
     * Switch context to RPC.
     * @returns interface with methods to retrieve RPC arguments
     */
    switchToRpc(): RpcArgumentsHost;
    /**
     * Switch context to HTTP.
     * @returns interface with methods to retrieve HTTP arguments
     */
    switchToHttp(): HttpArgumentsHost;
    /**
     * Switch context to WebSockets.
     * @returns interface with methods to retrieve WebSockets arguments
     */
    switchToWs(): WsArgumentsHost;
    /**
     * Returns the current execution context type (string)
     */
    getType<TContext extends string = ContextType>(): TContext;
}

실행 컨텍스트는 ArgumentsHost 인터페이스를 확장하는데 ArgumnetHost 는 요청의 핸들러 인수를 추상화 하는 역할을 한다. 즉, 핸들러가 처리하는 값들을 추상화해주므로 요청의 종류에 따라 적절한 처리를 할 수 있도록 ArgumentsHost 를 만들어 놓은 것이다.

if (host.getType() === 'http') {
  // do something that is only important in the context of regular HTTP requests (REST)
} else if (host.getType() === 'rpc') {
  // do something that is only important in the context of Microservice requests
} else if (host.getType<GqlContextType>() === 'graphql') {
  // do something that is only important in the context of GraphQL requests
}

(출처 : https://docs.nestjs.com/fundamentals/execution-context#argumentshost-class)

만약 Express 프레임워크의 Http 요청이라면, host.getArgs → [request, response, next] 로 구성되어 있을 것이다.

자, ArgumentsHost 가 핸들러 인수를 추상화 하는 인터페이스 임을 알았다. 여기에서는 요청에 대한 처리 방법을 지정 할 수 있는 다양한 데이터를 제공한다. 그렇다면 Execution ContextArgumentsHost 에 대해 무엇을, 왜 확장한걸까?

getHandler() 메서드는 호출될 핸들러에 대한 참조를 반환합니다.
getClass() 메서드는 이 특정 처리기가 속한 컨트롤러 클래스의 유형을 반환합니다.

각 함수 호출값에 무엇이 들어있는지 직접 확인해보면,

// user.controller.ts
@Post()
@UseInterceptors(LogInterceptor) <-- 유저 생성 요청에 로그 인터셉터를 달았을때
create(@Body() createUserDto: CreateUserDto) {
  return this.userService.create(createUserDto);
}

// log.interceptor.ts
const contextClass = context.getClass();
const contextHandler = context.getHandler();

console.log(contextClass, contextHandler, next);

// console ouptut
[class UserController] [Function: create]

getClass 는 해당 요청을 처리하는 컨트롤러 클래스의 유형 즉 타입을 반환하고, getHandler 는 참조한 함수를 반환하는 것을 알 수 있다. 여기서 getClass 와 getHandler 로 참조를 가져올 수 있게 되면, 해당 대상에 있는 메타 데이터를 가져올 수 있게 된다. 메타데이터에 따라 유연한 처리를 할 수 있도록 해주는 것이다.

intercept(
  context: ExecutionContext,
  next: CallHandler<any>,
): Observable<any> | Promise<Observable<any>> {
  const contextHandler = context.getHandler();

  console.log(Reflect.getMetadata('logType', contextHandler));

  return next.handle().pipe(
    map((data) => data),
    catchError((error) => throwError(() => error)),
  );
}

Call Handler 는 무엇이 ??

두 번째 인수는 콜 핸들러입니다. CallHandler 인터페이스는 handle() 메서드를 구현하며, 이 메서드를 사용하여 인터셉터의 특정 지점에서 루트 핸들러 메서드를 호출할 수 있습니다.
intercept() 메서드의 구현에서 handle() 메서드를 호출하지 않으면 루트 핸들러 메서드가 전혀 실행되지 않습니다.

두 번째로 넘어오는 인수 Call Handler 는 해당 interceptor, 즉 Aspect 가 감싸고 있는 핸들러 메서드이다. 따라서 해당 핸들러 메서드를 적절히 호출하지 않으면 사실상 우리가 중심적으로 실행하고자 하는 비즈니스 로직 메서드는 실행되지 않는다. 해당 내용이 공식 문서의 마지막줄에 표현되어 있다.

export interface CallHandler<T = any> {
    /**
     * Returns an `Observable` representing the response stream from the route
     * handler.
     */
    handle(): Observable<T>;
}

따라서 Call Handler 는 클라이언트로 부터 들어온 요청의 Point Cut 이다.

틈새 AOP 용어 정리

Join PointClient 가 호출하는 모든 비즈니스 로직 (메서드), Point Cut 의 후보
Point Cut특정 조건에 의해 필터링된 Join Point, 수 많은 Join Point 중 우리의 Aspect 를 횡단 적용하기 위한 지점
AdviceAspect 를 구현한 구현체, 횡단 관심이 되는 코드

즉, 우리는 Interceptor 내부에서 Advice 를 동작 시킬 시점을 정하고 해당 동작을 수행시키면 AOP 를 기존 비즈니스 로직에 덮어씌워 적용 할 수 있는 것이다.

동작 시킬 시점은 비즈니스 로직 메서드의 실행 전, 실행 후, 성공 후, 실패 후, 항상 등으로 정의할 수 있으며 이러한 시점은 아주 강력한 Rxjs 의 메서드를 통해 구현 할 수 있다. 예시를 들어 실패시에만 별도의 로깅을 해야한다는 요구사항이 있다면 아래 처럼 스트림에 오류를 감지할 경우 Advice 를 실행하도록 처리 할 수 있다.

intercept(
  context: ExecutionContext,
  next: CallHandler<any>,
): Observable<any> | Promise<Observable<any>> {
  return next.handle().pipe(
    map((data) => data),
    **catchError((error) => {
      console.log('여기는 에러 발생시 ㅎㅎ');
      return throwError(() => error);
    }),
		finalize(() => {
      console.log('여기는 항상 ㅎㅎ');
    }),**
  );
}

Post, Pre interceptor

위의 개념을 이해하고 온다면 사실 Post, Pre Interceptor 는 공통 관심사를 수행할 Point Cut 에 대해 시점을 정하는 것임을 알 수 있다. 코드 상으로는 ExecutionContext 를 사용한다면 Pre Interceptor 가 될 수 있으며 Call Handler 를 사용한다면 Post Interceptor 가 될 것 이다.

Documentation | NestJS - A progressive Node.js framework

여기에서 Nest.js 에서 예시로 알려주는 몇가지 Interceptor 의 활용 방안들이 있다.

Interceptor 로 적용하는 AOP … 더 복잡한 무언가가 필요해요

위에서 Interceptor 가 AOP 를 구현하기 쉽도록 어떠한 함수 인자를 받아오고 해당 인자들을 어떻게 사용해야 하는지 자세하게 알아보았다. 실행될 메서드의 참조, 메타데이터 그리고 요청 자체의 데이터를 받아올 수 있으므로 웬만한 공통 로직의 적용은 무리 없이 할 수 있다.

하지만 개발을 하다보면 조금 더 복잡하게 공통 로직을 구현해야할 필요가 생긴다. 위의 로깅을 예시로 들면, 단순히 콘솔 로깅이 아니라 S3 같은 클라우드 파일 스토리지에 로그 파일을 적재해야한다거나, 서드 파티 로그 분석툴에 정보를 전송해야 할 수도 있다.이럴때는 기존의 객체지향적 개념을 그대로 사용해 최대한 재사용 가능하고 변경에 용이하도록 공통 로직을 구현하면 된다.

// log.module.ts
@Global()
@Module({
  imports: [
    S3FileStorageModule.registAsync({
      useFactory: async (configService: IsAWSConfig) => ({
        ...
      }),
      inject: [AwsConfigService],
    }),
  ],
})
export class LogModule {
  static forRoot(): DynamicModule {
    return {
	     ...
    };
  }
}

// log.service.ts
@Injectable()
export class HttpLogService implements NestLoggerService {
  private readonly logger: Logger;

  constructor(
    @Inject(S3_FILE_STORAGE_SERVICE_TOKEN)
    private readonly s3FileStorage: IsS3FileStorageService,
  ) {
    // logger 생성
    this.logger = createLogger({
      transports: [
				// .log 파일을 일별로 로컬에 저장
        new DailyRotateFile({
          level: 'info',
          datePattern: 'YYYY-MM-DD',
          ...
        }),
      ],
    });
  }

  log(message: string, httpData: HttpRequestLog) {
		...
    this.logger.info(message, httpData);
  }

  ...
}

// log.interceptor.ts
@Injectable()
export class LogInterceptor implements NestInterceptor {
	// log Service 를 주입받아 처리
  constructor(private readonly logService: HttpLogService) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest<Request>();
		...
profile
개발을 사랑하는 개발자. 끝없이 꼬리를 물며 답하고 찾는 과정에서 공부하는 개발자 입니다. 잘못된 내용 혹은 더해주시고 싶은 이야기가 있다면 부디 가르침을 주세요!

0개의 댓글