메인 콘텐츠로 이동하기
  1. Posts/

routing-controllers로 리팩터링하기: 2. Interceptor

·588 자·3 분
목차
이 글의 코드는 express-sequelize-ts 저장소에서 확인할 수 있습니다.

시작한 이유 #

1편에서는 Passport 인증 로직을 routing-controllers의 class middleware로 옮겼습니다. 두 번째로 정리할 대상은 response logging이었습니다.

기존 프로젝트는 morgan middleware로 method, URL, status, response time을 기록했습니다. 이 방식은 HTTP 요청의 흐름을 확인하기에는 충분했지만 controller가 반환한 값을 함께 살펴보려면 다른 접근이 필요했습니다.

Express middleware에서 response body를 기록하려면 res.send()res.json()을 감싸는 방식까지 고려해야 합니다. 반면 routing-controllers의 interceptor는 action이 반환한 content를 직접 받을 수 있습니다. 그래서 요청 메타데이터는 middleware에 남기고, 반환값 logging은 interceptor로 분리했습니다.

middleware와 interceptor의 위치 #

두 기능은 실행되는 위치가 다릅니다.

  • middleware는 Express의 request, response, next를 받아 요청 흐름 전후에 동작합니다.
  • interceptor는 controller action이 실행된 뒤 Action과 반환된 content를 받습니다.

인증처럼 action 실행 전에 확인해야 하는 로직은 middleware가 자연스럽습니다. controller의 결과를 관찰하거나 변환하는 로직은 interceptor가 더 직접적이었습니다.

기존 response logging #

기존 handler.middleware.ts는 status에 따라 morgan handler를 나눴습니다.

1
2
3
4
5
6
7
8
9
const successHandler = morgan(successResponseFormat, {
  skip: (req, res) => res.statusCode >= 400,
  stream: { write: (message) => logger.info(message.trim()) }
});

const errorHandler = morgan(errorResponseFormat, {
  skip: (req, res) => res.statusCode < 400,
  stream: { write: (message) => logger.error(message.trim()) }
});

app.tsinitMiddleware()에서 두 handler를 직접 등록하고 있었습니다.

1
2
3
4
if (this._env !== 'test') {
  this.app.use(loggerHandler.success);
  this.app.use(loggerHandler.error);
}

이후 morgan logging은 LoggingHandler라는 class middleware로 옮겼습니다. 다만 이 middleware의 역할은 method, URL, status, response time 같은 요청 메타데이터를 기록하는 데 두었습니다.

ResponseInterceptor 만들기 #

controller의 반환값은 별도의 ResponseInterceptor에서 기록했습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Interceptor({ priority: 0 })
@Service()
export class ResponseInterceptor implements InterceptorInterface {
  intercept(action: Action, content: any) {
    const { response, request }: { response: Response; request: Request } = action;

    if (content instanceof HttpError) {
      logger.debug(
        `{ api: ${request.url}, status: ${content.httpCode}, data: ${JSON.stringify(content)} }`
      );
      return response.status(content.httpCode).json(content);
    } else {
      logger.debug(
        `{ api: ${request.url}, status: ${response.statusCode}, data: ${JSON.stringify(content)} }`
      );
      return content;
    }
  }
}

action에서는 현재 requestresponse를 가져오고, content에서는 action의 반환값을 받습니다. 정상 response는 현재 status와 반환값을 기록한 뒤 원래 content를 그대로 돌려줍니다.

당시 구현에는 content instanceof HttpError 분기도 넣었습니다. 하지만 controller에서 throw된 모든 오류가 이 분기를 거친다는 뜻은 아닙니다. exception의 최종 response 변환은 별도로 등록한 error middleware가 담당합니다.

전역 interceptor로 등록하기 #

특정 controller마다 decorator를 반복하지 않고, useExpressServer() options에 interceptor 경로를 등록했습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const routingControllerOptions = {
  routePrefix: '/api',
  controllers: [path.join(__dirname, '/*.controller.ts')],
  middlewares: [HttpErrorHandler, LoggingHandler],
  interceptors: [path.join(__dirname, '/interceptors/*.interceptor.ts')],
  defaultErrorHandler: false,
  classTransformer: true,
  authorizationChecker
};

useExpressServer(this.app, routingControllerOptions);

이 설정으로 모든 controller action의 반환값을 같은 위치에서 확인할 수 있게 했습니다. 각 controller는 logging을 위해 별도의 함수를 호출할 필요가 없습니다.

error middleware와 역할 나누기 #

이 변경에서도 HttpErrorHandler는 제거하지 않았습니다. interceptor와 error middleware의 책임이 다르기 때문입니다.

  • LoggingHandler: method, URL, status, response time 기록
  • ResponseInterceptor: controller가 반환한 content 기록
  • HttpErrorHandler: throw되거나 next(error)로 전달된 오류를 HTTP response로 변환

모두 response 주변에서 동작하지만 같은 일을 하지는 않습니다. 실행 시점과 입력값에 맞춰 역할을 나누니 controller 밖의 공통 처리를 찾기가 쉬워졌습니다.

지금 다시 보면 #

당시 코드는 JSON.stringify(content)로 response body 전체를 기록했습니다. 개발 중에는 빠르게 내용을 확인할 수 있지만, 운영 환경에서는 민감한 값이나 큰 payload까지 log에 남을 수 있습니다.

지금 다시 만든다면 method, URL, status, duration, request ID처럼 검색에 필요한 필드를 구조화하고, response body는 기본적으로 제외하겠습니다. 꼭 필요하다면 허용한 필드만 남기거나 민감한 값을 마스킹한 뒤 debug 환경에서 제한적으로 기록하는 편이 안전합니다.

정리 #

middleware와 interceptor 중 하나만 선택할 필요는 없었습니다. 요청 메타데이터와 오류 흐름은 middleware에 두고, controller의 반환값은 interceptor에서 다뤘습니다.

routing-controllers가 action의 전후 지점을 나눠 제공했기 때문에 각 공통 로직을 실행 시점에 맞는 위치로 옮길 수 있었습니다.

참고 #