NestJS에서 AOP를 위해 예외 처리와 응답 변환을 분리해보자!

윤학·2023년 4월 17일
0

Nestjs

목록 보기
4/13
post-thumbnail

하나의 컨트롤러에 라우터를 추가 할 때마다 예외 처리 로직이 들어가고 Client에게 일관된 응답 형식을 위해 변환하는 로직이 들어갔었다.

기존 코드의 이러한 부분들을 별도의 레이어로 빼서 처리해보는 과정을 살펴보자.

그 전에..기존 작성한 프로젝트 코드가 감히 MSA를 공부해 보겠다고 했다가 결국 알맞은 서비스한테 라우팅만 해주는 ApiGateway와 서비스들로만 이루어져 있었다.

Nginx나 AWS 서비스를 이용한게 아니기 때문에 ApiGateway는 단순히 경로 처리만 해준다.

이런 미흡한 부분들은 배제하고 봐주시면 감사할 것 같다!

1. AOP?

그럼 먼저 AOP란 뭘까?

현재 컨트롤러가 있고 로깅을 남기는 작업과 응답 데이터를 변환하는 작업이 각 경로 핸들러마다 들어가 있다고 가정해보자.

흔하게 있는 기능이지만 경로를 하나 더 추가한다고 하면 추가 할 때마다 똑같은 작업이 들어갈텐데 과연 이 기능들이 해당 경로 핸들러가 주로 처리해야 할 기능일까?

이렇게 횡단으로 공통적으로 나타나면서 부가적인 기능들을 수행하고 있는 부분을 횡단 관심사라고 한다.

AOP(관점지향프로그래밍)는 이런 횡단 관심들을 분리하여 모듈성을 높이는 것에 집중하여 프로그래밍 하는 것이라고 이해하면 될 것 같다.

코드로 예시를 들어보자.

@Get('video/detail/:videoId')
  async videoDetailInfoRequest(@Param('videoId') videoId: string): Promise<any> {
    try {
      const [videoInfoData, videoCommentData] = await Promise.all([
        	this.httpService.axiosRef.get(`${this.VIDEO_SERVICE}/video/detail/${videoId}`),
       		this.httpService.axiosRef.get(`${this.COMMENT_SERVICE}/video/comment/${videoId}`)
      ])
      const { data: videoInfoResponseData } = videoInfoData;
      const { data: videoCommentsResponseData } = videoCommentData;
      return {
        data: {
          detail: {
            ...videoInfoResponseData,
            comments: [
              ...videoCommentsResponseData
            ]
          }
        }
      }
    }
    catch (err) {
      throw new HttpException(
        '죄송해요 비디오 서버에 문제가 생겨 복구중이에요... router -> video/detail',
        HttpStatus.INTERNAL_SERVER_ERROR
      )
    }
  }

만약 경로를 하나 추가 한다고 하면 위의 코드에서 Http통신 오류 처리를 위한 try-catch문과 응답 데이터를 변환하는 로직이 추가될 것이다.

예외를 처리하는 부분은 예외 처리에만 관심가지게 분리해주고, 데이터 변환 부분은 데이터 변환에만 관심가지게 분리하여 원래 주 관심사인 동영상 상세 정보 데이터를 받아오는 로직에만 집중하도록 해보자.

이렇게 변경될 것이다!

@Get('video/detail/:videoId')
  videoDetailInfoRequest(@Param('videoId') videoId: string): Observable<AxiosResponse> {
    const [detail$, comments$] = [
      this.httpService
        .get(`${this.VIDEO_SERVICE}/video/detail/${videoId}`)
        .pipe(map(response => response.data)),
      this.httpService
        .get(`${this.COMMENT_SERVICE}/video/comment/${videoId}`)
        .pipe(map(response => response.data))
    ];
    return detail$.pipe(mergeWith(comments$));
  }

2. Interceptor를 이용한 응답 데이터 변환

Nest에서는 AOP에서 영향을 받은 Interceptor가 존재한다.

Interceptor는 요청에 대한 라우트 핸들러의 처리 전/후로 호출되기 때문에 처리 이후에 클라이언트에게 도착하기 전 원하는 형식으로 응답 데이터를 변환하는 것이 가능하다.

1) Interface

먼저 필자의 응답 데이터 형식은 data 필드안에 실제 처리 결과 데이터들이 들어있다.

그러므로 응답에 대한 Interface를 만들고 결과 data는 제네릭 타입으로 선언해주자.

common-response.interface.ts

export interface ResponseWithData<T> {
  data: T
}

2) NestInterceptor

다음으로 Nest에서 Interceptor를 구현하려면 NestInterceptor 인터페이스를 구현해야 한다.

근데 해당 인터페이스를 잠시 살펴보면

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

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>>;
}

요청 결과의 응답 스트림을 rxjs의 Observable로 구현하여 전달하고 있다.

위의 내용을 정리하면 각각의 요청 응답 스트림은 각 Observable이 가지고 있으며 Observable을 수신하여 데이터를 조작하고 싶다면 CallHandler인터페이스의 handle()메소드를 통해 가능하다는 것이다.

근데 NestInterceptor의 next가 CallHandler를 참조하고 있으므로 next를 통해 handle() 메소드를 호출하고 Observable을 수신하여 해당 응답 스트림을 조작할 수 있는것이다.

3) Interceptor 구현

그럼 이제 응답 데이터를 변환하는 Interceptor를 구현해보자.

하나의 요청에서 반환되는 응답 데이터와 두개의 요청 결과를 하나의 데이터로 합쳐 응답하는 과정으로 나눠보자.

(1) 하나의 응답 처리

@Injectable()
export class ResponseTransformInterceptor<T> implements NestInterceptor<ResponseWithData<T>> {
    intercept(context: ExecutionContext, next: CallHandler<any>): Observable<ResponseWithData<T>> {
        
        const router = context.switchToHttp().getRequest().route.path; // request path
        const nextHandler = next.handle();

        return nextHandler
                .pipe( 
                    map( responseData => ({ 
                        data: (!responseData || !responseData.length) ? null : responseData }) 
                    )
                )
}

nextHandler에 응답 스트림을 담고 있는 Observable객체를 수신한 다음 약속된 데이터 타입으로 변경하고 데이터가 없거나 빈 배열일 경우 null값으로 보내준다.

그럼 컨트롤러는 어떻게 변해야 할까

@UseInterceptors(ResponseTransformInterceptor)
export class ApiGateway {
  constructor(
    private readonly httpService: HttpService,
  ) { }

  /*
   * Service: User/Auth 
   * Router: User: user/ , Auth: auth/
   * Port: 8080
  */
  @Get('user/feed/:id')
  getMyFeedRequest(@Param('id') id: string): Observable<AxiosResponse> {
    try {
    return this.httpService
      .get(`http://127.0.0.1:8080/user/feed/${id}`)
      .pipe(map(response => response.data))
    }
    catch(err) {
      //에러처리...
    }
  }

먼저 적용하고 싶은 메소드나 컨트롤러에 @UseInterceptor() 데코레이터를 적용하고 구현한 Interceptor클래스를 전달해주자.

Nest에서 제공하는 Http모듈이 Observable< AxiosResponse > 타입을 반환하기 때문에 map operator를 통해 AxiosResponse에서 data만 꺼내주어야 한다.

그럼 Observable객체에는 실제 요청 결과의 데이터만 남게 될테고 원래는 클라이언트와 약속한 데이터 타입으로 변경하는 로직을 작성해야 했지만 그 부분을 횡단 관심으로 분리하여 처리한 것이다.

//값이 있을 경우
{
    "data": [
        {
            "id": "123",
            "thumbNail": "www.aws.com"
        }
    ]
}
  
//빈 배열일 경우
  {
    "data": null
}

(2) 여러 응답을 하나의 데이터로

그럼 만약 하나의 동영상을 상세조회 했을 때 동영상 정보를 반환해주는 서비스와 해당 동영상의 댓글들을 반환해주는 서비스의 응답들은 어떻게 하나의 응답으로 데이터를 합쳐 보낼까

이번엔 Controller부터 변경해보자.

@Get('video/detail/:videoId')
  videoDetailInfoRequest(@Param('videoId') videoId: string): Observable<AxiosResponse> {
  try{
    const [detail$, comments$] = [
      this.httpService
        .get(`http://127.0.0.1:8081/video/detail/${videoId}`)
        .pipe(map(response => response.data)),
      this.httpService
        .get(`http://127.0.0.1:8084/video/comment/${videoId}`)
        .pipe(map(response => response.data)),
    ];
    return detail$.pipe(mergeWith(comments$));
  }
  catch(err) {
    //에러 처리...
  }
 }

먼저 각 서비스에 요청한 동영상에 해당하는 정보들을 조회한다.

그럼 총 2개의 Observable객체가 생성될 것이고 그대로 응답으로 넘긴다면 Interceptor에서 하나의 Observable만 연결될 것이다.

해결하기 위해선 먼저 하나의 Observable객체로 합쳐줘야 하는데 각각의 요청을 순차적으로 처리하는 것이 아닌 동시에 처리를 해주고 합쳐주는 operator인 mergeWith로 가능하다.

그리고 다시 아까 작성했던 Interceptor에 코드를 추가해주자.

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { map, Observable, reduce } from "rxjs";
import { ResponseWithData } from "src/common/interface/common-response.interface";

@Injectable()
export class ResponseTransformInterceptor<T> implements NestInterceptor<ResponseWithData<T>> {
    intercept(context: ExecutionContext, next: CallHandler<any>): Observable<ResponseWithData<T>> {
        
        const exceptRouterList = ['/api/v1/video/detail/:videoId']; // except router
        const router = context.switchToHttp().getRequest().route.path; // request path
        const nextHandler = next.handle();

        if( exceptRouterList.indexOf(router) == -1 ) {
            return nextHandler
                    .pipe( 
                        map( responseData => ({ 
                            data: (!responseData || !responseData.length) ? null : responseData }) 
                        )
                    )
        }
        return nextHandler
                    .pipe(
                        reduce((totalData, responseData) => ({ ...totalData, ...responseData }), {}),
                        map(responseData => ({ data: responseData }))
                    )
        }
}

exceptRouterList에 여러 서비스의 응답을 처리하는 경로들을 관리해주고 들어온 경로가 일치하는지 체크한다.

그리고 reduce operator를 통해 각 서비스의 응답 결과를 하나의 객체로 합쳐준 뒤 data필드에 담아서 보내주었다.

detail과 comment역시 각 서비스가 응답할 때 Interceptor를 통해 아래와 같은 타입으로 변환될테지만 이 글에서는 해당 부분은 다루지 않았다.

{
    "data": {
        "detail": {
            "id": "1234",
            "title": "제목내용",
            "aws": "www.aws.com"
        },
        "comment": [
            {
                "id": "123",
                "content": "댓글1"
            },
            {
                "id": "234",
                "content": "댓글2"
            }
        ]
    }
}

3. Filter를 이용한 예외 처리

응답 데이터의 변환 처리는 분리를 해줬으니 이번에는 try-catch문으로 처리하고 있었던 예외처리를 횡단 관심으로 분리해보자.

여기선 HTTPException을 제외한 Exception도 500번 에러로 발생시켜 보았다.

1) @Catch()

먼저 @Catch()데코레이터를 통해 모든 에러를 잡고 처리를 위해 ExceptionFilter 인터페이스의 catch()메소드를 구현해보자.

@Catch()
export class AllHttpExceptionFilter implements ExceptionFilter {
    constructor( private readonly httpAdapterHost: HttpAdapterHost ) {}
    catch(exception: any, host: ArgumentsHost) {

        const { httpAdapter } = this.httpAdapterHost;
        const ctx = host.switchToHttp();
        let errorResponseBody = createErrorResponseBody(exception, ctx);

        httpAdapter.reply(ctx.getResponse(), errorResponseBody, errorResponseBody.statusCode);
    }
}

catch()메소드의 exception은 발생한 예외 객체를 나타내며 모든 에러를 잡을 것이기 때문에 any타입으로 변경하였다.

에러가 발생하면 createErrorResponseBody()함수를 통해 ResponseBody를 만들어 httpAdapter에 적용하여 에러 응답을 전달하는데 그러기 위해선 구현한 Filter 클래스를 Nest에서 제공하는 @UseFilters() 데코레이터에 등록해줘야 한다.

필자는 예외 처리 역시 컨트롤러에 적용하였다.

@Controller('api/v1')
@UseFilters(AllHttpExceptionFilter)
export class ApiGateway { }

그럼 본격적으로 에러 처리를 해보자.

2) ErrorResponseBody

위에서 지나쳤던 createErrorResponseBody()함수를 봐보자.

function createErrorResponseBody(exception: any, ctx: HttpArgumentsHost) {
    const request = ctx.getRequest();
  	const method = request.method;
    let errorResponseBody = {
        value: method === 'GET' ?
            ( Object.keys(request.params).length ? 
                request.params : request.query ) : request.body,
        }
    
    if( exception instanceof HttpException ) {
        return {
            statusCode: exception.getStatus(),
            message: exception.message,
            ...errorResponseBody,
            path: request.url
        }
    }
}

2개의 파라미터를 받고 있는데 첫번째는 발생한 에러 객체이며, 두번째는 원래의 요청을 받은 경로 핸들러로부터 전달되어지는 객체이다.

필자는 에러 응답에 보낼 body에 method, 상태코드, 매세지, 전달했던 값, 요청 경로를 만들어 반환했기 때문에 요청 메소드에 따라 나누고 GET메소드일 경우에는 qeury파라미터인지 path파라미터를 구분하였다.

만약 발생된 에러가 Http에러의 인스턴스면 발생한 에러 그대로를 전달해주고, 아니라면 500에러로 응답을 해줬다.

500에러 부분은 잠시 뒤에 알아보고 먼저 클라이언트 요청을 받은 경로에서 404에러를 강제로 일으켜 보자.

{
    "statusCode": 404,
    "message": "오류 발생",
    "method": "GET",
    "value": {
        "id": "123"
    },
    "path": "/api/v1/user/feed/123"
}

그럼 해당 경로의 핸들러는 어떻게 변해 있을까

@Controller('api/v1')
@UseInterceptors(ResponseTransformInterceptor)
@UseFilters(AllHttpExceptionFilter)
export class ApiGateway {
  constructor(
    private readonly httpService: HttpService,
  ) { }
  
  /*
   * Service: User/Auth 
   * Router: User: user/ , Auth: auth/
   * Port: 8080
  */
  @Get('user/feed/:id')
  getMyFeedRequest(@Param('id') id: string): Observable<AxiosResponse> {
    return this.httpService
      .get(`http://127.0.0.1:8080/user/feed/${id}`)
      .pipe(map(response => response.data))
  }
}

try-catch문이 없어지고 요청을 처리하는데에만 집중할 수 있게 되었다.

근데 만약 요청을 받은 라우트 핸들러가 필요한 데이터를 받아오기 위해 외부 Api와 Http 통신을 하는 상황 중 데이터를 반환해주는 서비스에서 에러가 나면 어떻게 처리해야 할까?

이런 상황에서 오류가 난다면 해당 exception 객체가 HttpException 인스턴스(400, 401등)라고 하여도 결국 최종적으로 클라이언트와 통신하는 컨트롤러의 Exception Filter에서는 인스턴스라고 인지하지 못한다.

throw new HttpException("오류 매세지", HttpStatus.NOT_FOUND)

이유를 확인해보기 위해 외부 서비스에서 위와 같은 오류를 날려보면 클라이언트가 요청한 라우트 Exception Filter에서 잡히는 exception 객체는 아래와 같은 형태로 넘어온다.

...
  response: {
    status: 404,
    statusText: 'Not Found',
	.....
    data: { statusCode: 404, message: '오류 매세지' }
  }

외부 서비스에서 발생한 오류를 클라이언트에게도 그대로 전달하기 위해

HttpException인스턴스가 아니라면 다른 외부 api에서 오류가 난거일 수도 있으니 확인해보고 맞다면 해당 오류를 클라이언트에게까지 그대로 반환을 하도록 하고 아니면 500에러를 반환하도록 함수를 변경했다.

function createErrorResponseBody(exception: any, ctx: HttpArgumentsHost) {
    const request = ctx.getRequest();
  	const method = request.method;
    let errorResponseBody = {
        value: method === 'GET' ?
            ( Object.keys(request.params).length ? 
                request.params : request.query ) : request.body,
        }
    
    // 직접 오는 에러
    if( exception instanceof HttpException ) {
        return {
            statusCode: exception.getStatus(),
            message: exception.message,
            ...errorResponseBody,
            path: request.url
        }
    }

    // 다른 서비스와의 관계에서 발생한 에러
    const httpStatus = exception?.response ?
                        exception.response.data.statusCode :
                            HttpStatus.INTERNAL_SERVER_ERROR
    return {
        statusCode: httpStatus,
        message: httpStatus === 500 ? 
                    '현재 서버와의 통신이 불안정해요' :
                        exception.response.data.message,
        ...errorResponseBody,
        path: httpStatus === 500 ? 
                exception.request._options.pathname : 
                        exception.request.path
    }
}  

ApiGateway에서 사용자 서비스에 Http요청을 하고 사용자 서비스에서는 에러를 날려보면

{
    "statusCode": 401,
    "message": "인증 오류",
    "method": "GET",
    "value": {
        "id": "123"
    },
    "path": "/user/feed/123"
}

위와 같이 클라이언트에게도 오류가 발생한 외부 서비스 경로와 오류 내용이 정확히 전달됐다.

이렇게 횡단 관심사들을 분리해 보면서 코드도 깔끔해지고 처리도 한 곳에서 할 수 있어 장점이라고 생각한다.

하지만 외부 Api와의 오류 처리에 더 좋은 방법은 있을 것 같다.

방법을 찾게 된다면 그 때 다시 시도해 보는 것으로 하자...

번외

필자는 동영상 상세 조회 화면을 S3에 저장되어 있는 동영상 경로, 제목과 같은 동영상에 대한 정보 조회 Api와 해당 동영상에 대한 댓글 조회 Api에 각각 요청해 데이터를 합쳐서 화면에 구성했다.

이렇게 따로 처리한 이유는 사용자들이 댓글은 오류가 나서 못불러와도 동영상은 볼 수 있도록 하기 위함인데..

const [detail$, comments$] = [
      this.httpService
        .get(`http://127.0.0.1:8081/video/detail/${videoId}`)
        .pipe(map(response => response.data)),
      this.httpService
        .get(`http://127.0.0.1:8084/video/comment/${videoId}`)
        .pipe(map(response => response.data)),
    ];
    return detail$.pipe(mergeWith(comments$));

현재 이 방식은 mergeWith operator가 둘 중 하나의 Observable에서 오류가 나도 error로 반환하기 때문에 굳이 분리 한 이유가 없어졌다고 생각했다.

const [detail$, comments$] = [
      this.httpService
        .get(`${this.VIDEO_SERVICE}/video/detail/${videoId}`)
        .pipe(
          map(response => response.data),
          catchError(_ => of({ detail: 'error' }))
          ),
      this.httpService
        .get(`${this.COMMENT_SERVICE}/video/comment/${videoId}`)
        .pipe(
          map(response => response.data),
          catchError(_ => of({ comments: 'error' }))
        )
    ];
    return detail$.pipe(mergeWith(comments$));

그래서 catchError operator를 추가했다.

catchError는 각 Observable에서 오류가 나도 다른 Observable에서 진행할 수 있게 해주는데 of function을 통해 새로운 Observable을 생성하고 해당 서비스의 에러로 데이터를 채웠다.

catchError를 추가하지 않으면 서비스에서 난 오류가 그대로 클라이언트에게 전달 되었다.

하지만 변경하고 나면

{
    "data": {
        "detail": {
            "id": "1234",
            "title": "제목내용",
            "aws": "www.aws.com"
        },
        "comments": "error"
    }
}

이와 같이 클라이언트에게 오류가 난 서비스는 error를 넘겨주고 정상 응답 처리를 하고 오류의 내용은 해당 서비스의 Exception filter를 통해 로그로 남길 수 있을 것이다.

참고

RxJS API List
NestJS Interceptors
NestJS Exception Filters

0개의 댓글