[회고록] Nest.js custom logger 만들기, npm 배포하기 -(1)

Hoplin·2023년 7월 8일
1
post-thumbnail

Custom logger를 만들게된 계기

Nest.js에는 기본적으로 제공되는 Logger가 존재한다. 하지만 몇가지 불편한 점들이 존재했다.

  1. 로그파일 저장이 안된다.(대부분의 프레임워크 내장 로거도 그렇지만은)
  2. 각각의 요청에 대한 응답이 로깅이 되지 않는다. 이를 위해서는 Interceptor를 따로 정의해야했다.
  3. 스스로 장기적으로 활용할만한 커스텀 프로바이더, 동적모듈을 만들어보고싶었다(?)

내장 로거는 기능이 부족하다는 생각이 들었으며, 추가적인 기능에 대해서는 커스텀을 해야하는 상황이었다.

물론 Express의 Winston로거가 있듯이, Nest에는 nest-winston이라는 로거가 존재한다. 하지만, 막상 사용해 보니 Nest Style Format을 지원하지만, 뭔가 아쉬웠으며, 스스로 기능측면의 확장을 해보고 싶었지만, 그러한 측면에 있어 제한이 있었다. 물론 nest-winston패키지는 transport를 제공하여 로그파일의 확장된 저장 형태, 전송형태도 지원해준다는 장점이있다. 하지만 앞으로 스스로 사용할 로거를 만들어보고, 기능에 대한 확정을 해보기 위해 만들어보고자 하였다.

Github : https://github.com/J-hoplin1/NestJS-Custom-ExtendedLogger
npm : https://www.npmjs.com/package/@hoplin/nestjs-logger?activeTab=readme

Logger 클래스정의

우선 Logger 클래스를 정의하였다. Logger클래스를 정의할때는 두가지 방법이 있다.

Nest.js의 로거는 기본적으로 ConsoleLogger이며, 기본 스타일 그대로 사용하고 싶었기 때문에 2번째 방법을 택하기로 하였다. 그리고 각각의 로그 레벨에 대해 타입을 명확하게 하기 위해서 LogsLogLevels라는 인터페이스와 타입을 정의해 주었다.

export interface Logs {
  log: loggerfn;
  error: loggerfn;
  warn: loggerfn;
  debug: loggerfn;
  verbose: loggerfn;
}
export type LogLevels = keyof Logs;

@Injectable()
export class Logger extends ConsoleLogger implements Logs {
  
  ...

Nest.js는 기본적으로 의존성 주입을 Singleton으로 한다는 특징이있다(하지만 이 싱글톤도 완전한 싱글톤이 아니라는것을 알아둬야한다). 그래도 로깅모듈의 설정을 전역적으로 적용하기 위해 싱글톤으로 로거를 구현하였다. getLogger라는 클래스 메소드르 만들고, 이 메소드 안에서 logger 인스턴스를 반환받도록 하였다.

@Injectable()
export class Logger extends ConsoleLogger implements Logs {
  private static loggerInstance: Logger;
  // private static logLimit: LogLevel[] = DEFAULT_LOG_LEVELS;
  private static saveAsFile: boolean;

  private constructor(
    contextName: string,
    levelNTimestamp?: ConsoleLoggerOptions | undefined,
  ) {
    if (levelNTimestamp) {
      super(contextName, levelNTimestamp);
    } else {
      super(contextName);
    }
  }

  public static getLogger(config?: loggerForRootParam) {
    if (!config) {
      if (!this.loggerInstance) {
        throw new LoggerNotConfigured();
      }
      return Logger.loggerInstance;
    }
    const { applicationName, levelNTimestamp } = config;
    let saveAsFileOption: boolean;
    let logfileDirectory: string;
    if ('saveAsFile' in config) {
      saveAsFileOption = config.saveAsFile;
      logfileDirectory = config.logfileDirectory;

      if (!logfileDirectory) {
        throw new LogfileDirectoryNotGiven();
      }
      Logger.saveAsFile = saveAsFileOption;
      if (Logger.saveAsFile) {
        init(logfileDirectory);
      }
      Logger.loggerInstance = new Logger(applicationName, levelNTimestamp);
    } else {
      Logger.saveAsFile = false;
      Logger.loggerInstance = new Logger(applicationName, levelNTimestamp);
    }
    return Logger.loggerInstance;
  }

logger모듈의 옵션은 아래와 같다.(깃허브 readme와 동일)

  • applicationName - string
    • 애플리케이션 이름
  • saveAsFile (optional) - boolean
    • 로그를 파일로 저장하고 싶은 경우 true. true이면 logfileDirectory옵션은 필수 옵션이 된다.
  • logfileDirectory (optional) - string
    • 로그파일이 저장될 디렉토리
  • levelNTimestamp
    • logLevels(optional) - LogLevel[]
      • 로그레벨을 지정한다. 이는 Nest.js 내장 로거와 동일하게 동작한다.
    • timestamp(optional) - boolean
      • timestamp 출력의 여부이다. timestamp 는 이전 요청과 현재 요청 사이의 시간차를 의미한다.

로그 저장 데코레이터 작성하기

가장 추가하고 싶었던 기능은 로그내용을 텍스트 파일에 저장하고 싶었다. 이 기능이 내장 로거에는 없었기 때문이다. 평소 프레임워크를 사용하다보니 데코레이터를 작성할 일이 생각보다 적었다. 그렇기에 데코레이터 패턴을 활용하여 로그를 저장하는 기능을 구현하기로 하였다. 각각의 로그출력 메소드들은 두가지 값을 반환한다.

콘솔에 출력한것과 동일한 메세지, 그리고 사용자가 지정한 저장여부.



export type LoggerReturn = {
  message: string;
  saveAsFile: boolean;
};

return {
      message: msg,
      saveAsFile: Logger.saveAsFile,
    };

여기서 가장 중요한것은, 콘솔에 출력한것을 그대로 파일에 저장하는것이었다. 일반적으로 사용자가 넘긴 메세지를 그대로 출력하면, 밋밋한 텍스트 그대로 나오게 된다. 이를 찾기 위해 @nestjs/common 패키지 구조를 뜯어보기 시작하였고, printMessage라는 메소드를 발견하였다. 이 메소드는 pid, loglevel, format을 받아 문장을 만든 후 표준 출력에 출력하는듯한 기능을 가지고 있었다.

이를 활용하여 콘솔에 출력하는것과 동일한 로그메세지를 반환하는 메소드를 정의하였다.

 private returnGetConsolePrintString(
    message: unknown,
    context = '',
    logLevel: LogLevels = 'log',
  ): string {
    const pidMessage = this.formatPid(process.pid);
    const contextMessage = this.formatContext(context);
    const timestampDiff = this.updateAndGetTimestampDiff();
    const formattedLogLevel = logLevel.toUpperCase().padStart(7, ' ');
    const formattedMessage = this.formatMessage(
      logLevel,
      message,
      pidMessage,
      formattedLogLevel,
      contextMessage,
      timestampDiff,
    );
    return formattedMessage;
  }

데코레이터 정의는 프로퍼티 디스크립터의 value필드(실제 함수 구현체)를 변형하는 방식으로 구현하였다. 기존의 구현체를 저장한 다음 해당 함수 구현체를 apply를 통해 실행을 한다.(상황에 따라 call로 바꿔줘도 되었지만, 가변인자를 Rest Param으로 받았기때문에) 그러면 위에서 볼 수 있듯이 message, saveAsFile 두개의 필드가 저장된 object를 반환받으면, saveAsFiletrue인경우에만 message를 저장할 수 있도록 구현했다.

export function saveLog2File(
  target: any,
  key: LogLevels,
  desc: PropertyDescriptor,
) {
  // Only available in method type
  if (typeof desc.value === 'function') {
    const originalMethod: loggerfn = desc.value;
    desc.value = async function (...params: (unknown | any)[]) {
      const message: string = params[0];
      // Check message key
      switch (key) {
        case 'debug':
        case 'error':
        case 'log':
        case 'verbose':
        case 'warn':
          const { message, saveAsFile }: LoggerReturn = originalMethod.apply(
            this,
            params,
          );
          if (message && saveAsFile) {
            try {
              await save(key, message);
            } catch (err) {}
          }

          break;
        default:
          return;
      }
    };
  }
}


export async function save(level: LogLevels, message: string) {
  let filename: string;
  switch (level) {
    case 'log':
    case 'verbose':
      filename = `${logfileDirectory}/${logFileName.commonLog}`;
      break;
    case 'debug':
      filename = `${logfileDirectory}/${logFileName.debugLog}`;
      break;
    case 'error':
      filename = `${logfileDirectory}/${logFileName.errorLog}`;
      break;
    case 'warn':
      filename = `${logfileDirectory}/${logFileName.warnLog}`;
      break;
  }
  await fs.appendFile(filename, message);
}

그리고 이 데코레이터는 로그를 출력하는 메소드 위에 정의하여 사용한다.

 @saveLog2File
  log(message: unknown, context?: unknown, ...rest: unknown[]): LoggerReturn {
    super.log.call(this, message);
    const msg = this.returnGetConsolePrintString(
      message,
      
      ....

동적모듈, Custom 프로바이더 정의

여러 프로바이더 형태중 팩토리 프로바이더를 채택하였다. forRoot 클래스 메소드는 logger의 옵션을 받아 인스턴스를 반환받는 역할을 하며, forFeature는 단순히 logger인스턴스를 반환받는 메소드이다. 이를 위해서 Logger의 인스턴스를 반환하는 getLogger의 매개변수를 optional(?)로 정의하였다.

 public static getLogger(config?: loggerForRootParam){
                         
                         ...
@Module({})
export class LoggerModule {
  public static forRoot(loggerOption: loggerForRootParam): DynamicModule {
    return {
      module: LoggerModule,
      providers: [
        {
          useFactory: () => {
            return Logger.getLogger(loggerOption);
          },
          provide: Logger,
        },
      ],
      exports: [Logger],
    };
  }
  public static forFeature(): DynamicModule {
    return {
      module: LoggerModule,
      providers: [
        {
          useFactory: () => {
            return Logger.getLogger();
          },
          provide: Logger,
        },
      ],
      exports: [Logger],
    };
  }
}

Global request logging Interceptor

개인적으로 가장 필요했던것중 하나는, 모든 요청에 대한 간단한 로그메세지를 표기하는것이었다. 이를 위해 인터셉터를 작성해 주었다. 단 주의할 점은 이 인터셉터를 사용하기 위해서는 app.module.ts에서 Logger.forRoot를 통해 logger 설정에 대한 정의를 먼저 해주어야 한다.

@Injectable()
export class FlowInterceptor implements NestInterceptor {
  private readonly logger = Logger.getLogger();
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const ctx = context.switchToHttp();
    const req = ctx.getRequest<Request>();
    const res = ctx.getResponse<Response>();

    return next.handle().pipe(
      tap(() => {
        const statusCode = res.statusCode;
        const reqURL = req.url;
        const reqMethod = req.method;
        const reqresFlowMessage = `${reqMethod} ${reqURL} - ${statusCode}`;
        if (statusCode >= 200 && statusCode < 400) {
          this.logger.log(reqresFlowMessage);
        } else if (statusCode >= 400 && statusCode < 500) {
          this.logger.warn(reqresFlowMessage);
        } else {
          this.logger.error(reqresFlowMessage);
        }
      }),
    );
  }
}

사용 방법은 일반적인 인터셉터와 동일하다. 전역으로 등록해도 되며, 특정 컨트롤러에 대해서만 등록해줘도된다.

import { FlowInterceptor } from '@hoplin/nestjs-logger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new FlowInterceptor());
  ...
profile
더 나은 내일을 위해 오늘의 중괄호를 엽니다

0개의 댓글