개인정보로부터 안전한 API 로깅 시스템 만들기
NestJS Controller 데코레이터 정보를 활용하여 로깅 시스템을 개선하고, DTO 정보를 파싱하는 과정을 상세히 설명합니다.
Contents
Nestjs 를 사용하려면, 필수적으로 데코레이터의 기능을 이용해야 한다. Nestjs 의 많은 기능들에서 Decorator 는 클래스에 메타데이터를 입력하고, 런타임에서 해당 메타데이터를 읽어들여서 원하는 기능을 추가해주는 방식으로 기능을 구현한다. NestJS 환경에 맞는 Custom Decorator 만들기 글을 참고해 보면, 어떠한 방식으로 작동하는지 짐작해볼 수 있다. 회사에서 Controller 의 Param Decorator 정보를 파싱하여, 추가적인 기능을 구현해주어야 하는 상황이 생겼다.
문제상황
Nestjs 서버를 운영하는 과정에서, 로깅 과정을 개선해보고 싶다는 생각이 들었다. 회사에서 운영하고 있는 로깅 시스템은 다음의 두 가지 요구사항을 만족해야 한다고 생각하였다.
- 민감한 유저의 개인정보는 절대 평문으로 로깅되면 안되며, 마스킹되어 로깅되어야 한다.
 - 로깅은 많은 정보를 표현해 주어 예상치 못한 문제상황이 생겼을 때, 편하게 디버깅할 수 있어야 한다.
 
위의 두 요구사항은 불행하게도, 서로 trade-off 관계로 충돌하는 상황이 종종 발생한다. 개인정보로 의심되는 데이터들을 마스킹하는 rule 을 보수적으로 설정할수록, 로그의 많은 정보들을 잃게 되기 때문이다. 그와 반대로, 마스킹 rule 을 느슨하게 잡게 되면, 개인정보가 로깅 시스템에 남게 될 확률이 상대적으로 높아지게 된다. 후자는 절대 발생하면 안되는 상황이기 때문에, 전자의 상황(마스킹 rule 을 보수적으로 설정)으로 시스템을 운영하고 있었다. 해당 시스템은 써드파티와 많이 연동되어 있는 상황이어서, 외부 시스템의 변동상황에 따라 장애가 자주 발생할 수밖에 없는 상황이 많이 있었다. 또한 다른 시스템에서 잘못된 payload 로 API 를 호출하여 에러가 발생하는 경우가 많았다. 이에 따라 HTTP 통신 내역을 로그로 남기는 경우가 많았는데, 혹여나 개인정보가 로깅 시스템에 남을까봐 차라리 로그를 남기지 않는 경우도 종종 있었다. 시스템 운영을 하며 에러 디버깅할 상황이 많아지자, 로깅 시스템을 개선해야겠다고 생각했다.
설계
API 서버의 로깅시스템에서 가장 로깅에 남기고 싶었던 데이터는 다름아닌 API 의 Request Payload 와 Response Body 였다. 디버깅할 때 가장 많은 도움이 되는 것이 Input 과 Output 이기 때문이다.

위의 그림은 많이 보아 왔던 함수에 대한 도식이다. 함수의 Input 과 Output 만 정확히 파악한다 해도, 이 함수가 어떠한 함수인지 많은 부분을 파악할 수 있다. 이것은 단순 함수에만 적용되는 것이 아니라, API 들에도 적용될 수 있기 때문이다. 하지만 Request Body 와 Response Body 를 모두 로깅하자 하니, 개인정보가 로그에 남을 수 있다는 위험이 있었다.
가장 간단히 문제를 해결할 수 있는 방안으로는, Request 와 Response 의 특정 키를 블랙리스트로 등록하고, 해당 키는 마스킹하여 로그에 남기는 것이다.
class PrivacyRequestDto {
  /** 주민등록번호 */
  @IsString()
  @ApiProperty()
  ssn: string;
}
class Logger {
  private readonly blackLists = ['ssn'];
  async log(message: string | Record<string, any>) {
    /*
     * message 에서 blackList 에 포함하는 정보는 마스킹하고,
     * 그렇지 않은 정보는 그대로 로깅한다.
     */
  }
}
위와 같은 방법으로, 손쉽지 않게 구현할 수 있다. 하지만 누군가 다음과 같은 코드를 짠다면 어떻게 될까 ?
class 개인정보RequestDto {
  /** 주민등록번호 */
  @IsString()
  @ApiProperty()
  주민등록번호: string;
}
개인정보RequestDto 는 Logger 의 blackLists 에 등록되지 않았다면, 로그에 유저의 주민등록번호가 그대로 평문으로 남게될 것이다. 무엇보다, DTO 레이어가 로깅 시스템에 의존성이 생기게 되는 것 같아, 이러한 설계는 좋지 않다는 생각이 들었다. 
다른 방법으로, 다음과 같이 로깅 설정을 할 수 있으면 좋겠다고 생각했다.
class 개인정보RequestDto {
  /** 마스킹되어야 하는 민감 데이터 */
  @LogFormat(LogFormatType.MASK)
  @IsString()
  @ApiProperty()
  주민등록번호: string;
  /** 평문으로 남아도 되는 데이터 */
  @LogFormat(LogFormatType.PLAIN)
  @IsString()
  @ApiProperty()
  닉네임: string;
  /** 마스킹할 필요는 없지만, 평문으로는 저장하기 쉬운 데이터 */
  @LogFormat(LogFormatType.SHA256)
  @IsString()
  @ApiProperty()
  매우_중요하진_않은_데이터: string;
  /** 마스킹할 필요는 없지만, 평문으로는 저장하기 쉬운 데이터 */
  @LogFormat(LogFormatType.ENCRYPT)
  @IsString()
  @ApiProperty()
  암호화하고_싶은_데이터: string;
}
위와 같은 형태라면, 로깅 레이어와 DTO 레이어가 분리되면서도, 로깅 설정을 다양하게 설정할 수 있다고 생각했다.
LogFormatter
위의 설계 대로 구현을 하기 위해서, 먼저 로그를 남기기 전에 log message 를 전처리하는 LogFormatter 를 구현해 보았다. 먼저 다양한 Log Format Type (Plain, Mask, Encrypt) 을 추상화할 인터페이스가 필요했다.
/**
  * PlainLogFormatHandler, EncryptLogFormatHandler, MaskLogFormatHandler 
  * 등을 아래와 같은 형태로 추상화하여 의존성을 역전시킬 수 있다.
  */
export interface LogFormatHandler {
  /** 어떠한 LogFormat 으로 포매팅할지 결정할 때 사용한다 */
  isSupport(logFormatType: string): boolean;
  /** Log Format 작업을 수행한다 */
  handle(target: Record<string, any>, key: string): string;
}
/*
 * 예시로 구현해본 MaskLogFormatHandler
 */
export class MaskLogFormatHandler implements LogFormatHandler {
  isSupport(logFormatType: string): boolean {
    return logFormatType === LogFormatType.MASK;
  }
  handle(target: Record<string, any>, key: string): string {
    const rawValue = target[key];
    if (typeof rawValue === 'object') {
      return maskObject(rawValue);
    }
    return mask(rawValue);
  }
}
각각의 LogFormat 을 위와 같은 코드로 구현할 수 있다면, LogFormatter 는 다음과 같이 구현해볼 수 있다.
export class LogFormatter {
  private readonly defaultHandler: LogFormatHandler;
  private readonly handlers: LogFormatHandler[];
  constructor(params: LogFormatterParam) {
    this.defaultHandler = params.defaultHandler;
    this.handlers = params.handlers;
  }
  format(
    target: Record<string, any>,
    key: string
  ): string | Record<string, any> {
    const metadata: string = Reflect.getMetadata(LOG_FORMAT, target, key);
    const handler =
      this.handlers.find((handler) => handler.isSupport(metadata)) ??
      this.defaultHandler;
    /** Nested 된 구조이면, 재귀적으로 LogFormatting 을 시작한다 */
    if (typeof target[key] === 'object') {
      return this.formatObject(target[key]);
    }
    return handler.handle(target, key);
  }
  formatObject(target: Record<string, any>): Record<string, any> {
    const formatObject = (obj: Record<string, any>) =>
      Object.keys(obj).reduce((ac, key) => {
        return {
          ...ac,
          [key]: this.format(target, key),
        };
      }, {});
    /** 배열이면 반복을 돌며 LogFormat 처리 */
    return Array.isArray(target)
      ? target.map((it) => this.formatObject(it))
      : formatObject(target);
  }
}
위와 같은 코드를 통해, DTO 들의 각 필드들을 원하는 형태로 변환하여 로깅으로 남길 수 있다.
끝나지 않은 문제
위와 같은 형태로 많은 문제들을 해결해 놓았지만, 하지만 Nestjs 에서 DTO 가 처리되는 시점으로 인해 추가적인 문제를 해결해야 했다. 해당 시스템의 로깅 기능은 AOP 기능을 활용하여, Controller 의 비즈니스 로직과 분리하여, middleware 계층에서 로그 기능을 구현하였다. 
@Injectable()
export class StatusLogMiddleware implements NestMiddleware {
  constructor(
    @Inject(STATUS_LOGGER)
    private readonly logger: StatusLogger,
    @Inject(STATUS_LOG_IGNORE_PATH)
    private readonly ignorePaths: IgnorePathTypes[],
  ) {}
  use(request: Request, response: Response, next: () => void) {
    if (this.shouldIgnore(request)) {
      next();
    }
    const start = Date.now();
    // 기존의 send 함수를 decorate 한다
    const originalSend = response.send;
    response.send = (...data: any[]) => {
      const result = originalSend.apply(response, data);
      const end = Date.now();
      this.logger.log(request, response, end - start);
      return result;
    };
    next();
  }
  private shouldIgnore(req: CARequest) {
    return this.ignorePaths.some((path) => {
      if (is.string(path)) {
        return req.url.includes(path);
      }
      return path.test(req.url);
    });
  }
}
Nestjs Request Lifecycle 을 살펴보면, Controller 내부의 DTO 변환 기능은 가장 마지막 단계이기 때문에, 로깅을 남길 때 까다로운 부분이 있다.
- Incoming request
 - Globally bound middleware
 - Module bound middleware
 - Global guards
 - Controller guards
 - Route guards
 - Global interceptors (pre-controller)
 - Controller interceptors (pre-controller)
 - Route interceptors (pre-controller)
 - Global pipes
 - Controller pipes
 - Route pipes
 - Route parameter pipes
 - Controller (method handler)
 - Service (if exists)
 - Route interceptor (post-request)
 - Controller interceptor (post-request)
 - Global interceptor (post-request)
 - Exception filters (route, then controller, then global)
 - Server response
 
따라서, 문제를 해결하기 위해서는 Middlware 나 Interceptor 단에서 Request DTO 정보를 파싱하여, 기록해 놓아야 한다. 이러한 문제점을 알고 난 후, Nestjs Controller 의 Decorator 에 대해 파보기 시작했다.
Nestjs Controller 의 Decorator
다음과 같은 간단한 Nestjs Controller 를 컴파일해보면, 다음과 같은 결과를 얻을 수 있다.
@ApiTags('hello')
@Controller('hello')
export class HelloWorldController {
  @Get()
  helloWorld(@Body() dto: HelloDto) {
    return `hello ${dto.name}!`;
  }
}
let HelloWorldController = class HelloWorldController {
    helloWorld(dto) {
        return `hello ${dto.name}!`;
    }
};
__decorate([
    (0, common_1.Get)(),
    __param(0, (0, common_1.Body)()),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [hello_dto_1.HelloDto]),
    __metadata("design:returntype", void 0)
], HelloWorldController.prototype, "helloWorld", null);
눈여겨 볼 점은, __decorate 부분이다. tsconfig 의 emitDecoratorMetadata 설정이 활성화 되어있으면, 클래스 메서드의 메타데이터 정보를 남길 수 있다. reflect-metadata 가 남긴 정보 중 활용할 수 있는 메카데이터는 design:paramtypes 이다. Request 가 어떤 DTO 와 매핑되는 지 알 수 있다면, 로깅 시점에 해당 DTO 의 로깅 정책도 파악하여 적용할 수 있다.
그렇다면 Nestjs 에서는 어떻게 Route 에 대한 메타데이터를 저장해 놓을까 ?
Nestjs 소스코드를 살펴보면, Controller 의 API Endpoint 메서드들은 ROUTE_ARGS_METADATA("__routeArguments__") 메타데이터 키를 통해, Controller instance 에 메타데이터를 저장한다.
이것도 읽어보세요