개요
운영중인 서비스에서 로깅을 남기는 건 필수적입니다.
Nest.js에서는 사용자 요청/응답 로그를 Middleware에서 남길 수도 있고, Interceptor에서 남길 수도 있습니다.
실제로 공식 문서에서도 두 가지 예시를 모두 다루고 있습니다.
그런데, 둘 중 어떤 방식으로 구현하는 게 더 좋은 선택일까요?
이 글은 이런 분들께 도움이 될 거예요.
- 위 물음에 물음표가 생기신 분
- Nest.js에서 Enhancer라는 용어를 처음 들어보신 분
전통적인 Middleware
Middleware는 개발 분야에서 널리 쓰이는 용어입니다.
어디서 쓰이든 이름처럼 '중간자' 역할을 수행하는데, 우리에겐 express나 socket.io 덕분에 친숙한 개념이기도 합니다.
Node.js 애플리케이션에서 Middleware는 인증, 로깅, 데이터 검증 및 변환 등 여러 목적으로 쓰입니다.
저마다 목적은 다르지만, 전체 흐름의 중간에서 각 단계의 전/후 처리를 맡는다는 점은 동일합니다.
- 사용자의 요청을 수행하기 전 인증 여부 확인
- 작업 수행 전 데이터 검증하기
- 작업 수행 후 결과를 로그로 남기기
- 사용자에게 응답을 보내기 전 데이터 변환하기
- 등등
주로 함수 형태로 구현되며 인자 끝에 오는 next
함수 호출을 통해 다음 단계로 흐름을 넘길지, 아니면 멈추고 오류를 발생시킬지를 결정합니다.
Nest.js에서는 NestMiddleware
라는 인터페이스를 구현하여 만들 수 있습니다.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class CustomMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log('Request...');
next();
}
}
Nest.js의 Enhancer
Nest.js의 Enhancer는 Middleware를 특성별로 세분화한 것입니다.
따라서 Enhancer 역시 Middleware로 볼 수 있지만, Nest.js에서는 Middleware를 Enhancer와 구분짓고 있습니다.
Enhancer로 분류되는 요소는 Pipe, Guard, Interceptor, Exception Filter입니다.
익숙하시죠? Enhancer라는 용어가 낯설 뿐이지, 이미 우리가 잘 쓰고 있는 개념입니다.
전통적인 Middleware의 인터페이스를 살펴봤듯이, Enhancer들의 인터페이스도 살펴보겠습니다.
Middleware에서 next
라는 함수가 중요했듯이 이번에도 각 Enhancer 인터페이스의 인자에 집중해주세요!
Pipe
첫 번째로 Pipe입니다. Pipe는 PipeTransform
이라는 인터페이스를 통해 구현할 수 있습니다.
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
@Injectable()
export class ValidationPipe implements PipeTransform {
transform(value: unknown, metadata: ArgumentMetadata) {
return value;
}
}
PipeTransform
인터페이스는 transform
이라는 메서드를 갖는데, 이 메서드 내의 인자인 ArgumentMetadata
타입을 살펴보겠습니다.
interface ArgumentMetadata {
type: 'body' | 'query' | 'param' | 'custom';
metatype?: Type<unknown>;
data?: string;
}
보시다시피 type
이라는 속성을 갖습니다. Pipe가 어디에 쓰였는지에 따라 type
은 그때그때 다른 값이 부여됩니다. 만약 @Body()
데코레이터에 쓰였다면 'body', @Query()
데코레이터에 쓰였다면 'query', 웹소켓의 이벤트 리스너에 쓰였다면 'custom'입니다.
여기서 중요한 것은 내부 속성인 type값만 달라질 뿐이지, 동일한 Pipe 구현체를 어디서든 쓸 수 있다는 점입니다. 이 점을 기억해두고 다음 Enhancer로 넘어가보겠습니다!
Guard
Guard는 CanActivate
인터페이스로 구현할 수 있습니다.
아시다시피 반환되는 boolean 값에 따라 인증 여부를 결정할 수 있습니다.
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class RolesGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
return true;
}
}
여기서는 canActivate
라는 메서드를 구현해야 하는데, 이 메서드는 ExecutionContext
타입의 인자를 갖습니다. Interceptor에서도 동일한 인자가 있으므로 얼른 넘어가보겠습니다.
Interceptor
Interceptor는 NestInterceptor
인터페이스로 구현할 수 있습니다.
아까 Guard에서 살펴본 ExecutionContext
타입의 인자와 next라는 CallHandler
타입의 인자를 갖는 걸 확인할 수 있습니다.
(주의: Interceptor의 next는 rxjs에서 온 것이므로 Middleware의 next와는 다른 개념입니다!)
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): Observable<any> {
console.log('Before...');
const now = Date.now();
return next
.handle()
.pipe(
tap(() => console.log(`After... ${Date.now() - now}ms`)),
);
}
}
마지막으로 살펴볼 것은 Exception Filter인데, 마찬가지로 ExecutionContext
가 Exception Filter에서도 나오니 바로 넘어가겠습니다.
Exception Filter
Exception Filter는 ExceptionFilter
인터페이스로 구현할 수 있습니다.
이 인터페이스는 catch
라는 메서드를 갖습니다. catch
메서드에는 exception과 host라는 인자가 있습니다.
이때 host는 ArgumentsHost
라는 타입을 갖습니다.
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
response
.status(status)
.json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
어? 그런데 아까 ExecutionContext
가 Exception Filter에도 나온다고 하지 않았던가요?
왜 Exception Filter에선 ExecutionContext
가 보이지 않는 걸까요?
ExecutionContext
는 ArgumentsHost
를 상속받는 인터페이스입니다.
interface ExecutionContext extends ArgumentsHost {
getClass<T = any>(): Type<T>;
getHandler(): Function;
}
type ContextType = 'http' | 'ws' | 'rpc';
interface ArgumentsHost {
getType<TContext extends string = ContextType>(): TContext;
getArgs<T extends Array<any> = any[]>(): T;
getArgByIndex<T = any>(index: number): T;
switchToRpc(): RpcArgumentsHost;
switchToHttp(): HttpArgumentsHost;
switchToWs(): WsArgumentsHost;
}
요컨대 Guard, Interceptor, Exception Filter는 모두 ArgumentsHost
타입의 인자를 갖는다는 거죠.
ArgumentsHost
는 getType
이라는 메서드를 갖습니다. getType
메서드는 Enhancer가 어디에 쓰였는지에 따라 다른 값을 반환합니다.
그런데... 뭔가 익숙하지 않나요? 앞서 살펴본 Pipe의 ArgumentMetadata
와 유사해보입니다.
여기서 잠깐 공식 문서를 통해 Nest.js의 설계를 간단히 확인해보겠습니다.
다양한 Context를 갖는 Nest.js
Nest.js 기본기(fundamentals) 문서에는 Platform agnosticism라는 페이지가 있습니다.
이 페이지에서는 Build once, use everywhere라는 표어를 내세우며 Nest.js가 여러 플랫폼에 대응 가능한(platform-agnostic) 프레임워크임을 설명합니다.
다른 유형의 애플리케이션에서도 로직들을 재사용할 수 있게 설계되었다는 뜻인데요, 예를 들면 HTTP 웹서버를 구현하는 데 사용했던 기능들을 웹소켓 서버나 마이크로서비스에도 그대로 재사용할 수 있다는 의미입니다.
구현해둔 Enhancer를 어떤 맥락(Context)에서도 사용할 수 있는 설계를 ArgumentMetadata
와 ArgumentsHost
에서 느낄 수 있습니다.
Interceptor로 예를 들어보겠습니다.
다음은 사용자의 HTTP 요청 전/후를 로그로 남기는 LoggingInterceptor입니다.
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor<T> implements NestInterceptor<T, T> {
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<T> | Promise<Observable<T>> {
console.log("before");
return next.handle().pipe(
tap(() => {
console.log('after');
})
);
}
}
이 구현체는 다음과 같이 웹소켓 Gateway에도 그대로 재사용할 수 있습니다.
@UseInterceptors(LoggingInterceptor)
@WebSocketGateway()
export class SampleGateway { ... }
클라이언트 소켓이 이벤트를 발생시키면 동일하게 해당 이벤트 리스너의 처리 전/후 로그가 남게 됩니다.
어떠신가요? Build once, use everywhere가 잘 느껴지지 않나요?
만약 Middleware로 구현했다면 웹소켓용 구현체를 새로 만들어야 했을 겁니다.
이같은 Context의 더 다양한 활용은 공식 문서의 Execution Context 페이지에서 찾아보실 수 있습니다.
정리
사실 Nest.js에서 Middleware를 사용하는 게 잘못된 것은 아닙니다.
특히 대부분의 경우 express를 거쳐 Nest.js로 넘어오기 때문에 아무래도 익숙한 개념을 쓰기 마련이니까요.
(저도 처음엔 그랬구요!)
게다가 더 깊게 다루려면 Interceptor에서는 rxjs까지 활용하게 되기 때문에 입문서나 강의에서 다루기엔 부담스러우니 일부 기능은 이해하기 쉽게 Middleware로 대체하는 경우도 많은 것으로 보입니다.
Nest.js가 많이 익숙해지셨다면 프레임워크의 의도에 맞게 기존에 작성된 Middleware를 모두 Enhancer로 바꿔보는 건 어떨까요? 사실 Nest.js의 공식 Discord에서도 이를 권장하고 있답니다 ㅎㅎ.