TypeORM 쿼리 진입점 로깅하기 #2

이호영·2024년 2월 18일
2
post-thumbnail

📖 예제코드: https://github.com/2hoyeong/nestjs-typeorm-entry-log

이전 글에서 Proxy와 Decorator를 이용해서 서비스의 어떤 함수에서 쿼리가 생성되었는지 주석을 자동으로 추가해 주는 기능을 만들었습니다.

이제 남은 문제들을 정리해 보면

  1. 많은 Repository 계층의 함수에 모두 적용해야 합니다.
  2. config에 정의된 서비스 이름을 가져와야 하기 때문에, ConfigService가 모든 Repository에 주입되어야 합니다.
  3. createQueryBuilder 를 사용하지 않는 함수는 여전히 로깅되지 않습니다.

계속해서 하나씩 해결해 나가보도록 하겠습니다.

이전에 JavaScript 데코레이터로 동작하도록 정의했습니다. 그런데 프로젝트 이름은 env로 관리되고 있고, env는 @nestjs/config 라이브러리를 사용함으로써 NestJS로부터 configService를 주입받아야 합니다.

그래서 NestJS 데코레이터로 변환해 보도록 하겠습니다.

NestJS 데코레이터로 변환

일반적인 데코레이터는 클래스가 선언될 때 동작하기 때문에 클래스가 선언될 때는 NestJS 싱글톤 컨테이너에는 접근할 수 없는 상태입니다.

그래서 NestJS 데코레이터는 클래스가 선언될 당시에 데코레이터가 적용될 대상의 메타데이터를 저장해두고, NestJS가 정상적으로 bootstrap된 뒤에 메타데이터를 이용해 데코레이터 로직을 적용합니다.

import { SetMetadata } from '@nestjs/common';

export const ADD_ENTRY_POINT_COMMENT = 'ADD_ENTRY_POINT_COMMENT';
/**
 * @description QueryBuilder에 EntryPoint를 추가하는 함수 데코레이터
 */
export const AddEntryPointComment = () => SetMetadata(ADD_ENTRY_POINT_COMMENT, true);

그래서 기존 AddEntryPointComment 데코레이터는 메타데이터를 저장하기 위한 로직만 작성합니다.

그다음 NestJS가 정상적으로 동작된 이후 실제 로직(주석에 진입점 추가)이 적용될 로직을 작성합니다.

NestJS 데코레이터를 선언하는 상세한 과정은 줌 기술 블로그에 기술된 내용에 자세히 설명되어 있으니 참고하시길 바랍니다.

import { DiscoveryModule, DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core';
import { DynamicModule, Module, OnModuleInit } from '@nestjs/common';
import { ADD_ENTRY_POINT_COMMENT } from './utils';
import { NestConfigModule } from '../../common/config/config.module';
import { ConfigService } from '@nestjs/config';

@Module({
  imports: [DiscoveryModule, NestConfigModule],
})
export class EntryPointLoggingModule implements OnModuleInit {
  constructor(
    private readonly discovery: DiscoveryService,
    private readonly scanner: MetadataScanner,
    private readonly reflector: Reflector,
    private readonly configService: ConfigService,
  ) {}

  static forRoot(): DynamicModule {
    return {
      module: EntryPointLoggingModule,
      global: true,
    };
  }

	...
}

데코레이터 동작을 정의하기 위한 모듈을 선언합니다. 이제 NestJS 싱글톤 컨테이너에 접근할 수 있으므로, 데코레이터 자체에서 ConfigService를 주입받을 수 있습니다.

...
onModuleInit() {
  this.discovery
    .getProviders()
    .filter((wrapper) => wrapper.isDependencyTreeStatic())
    .filter(({ instance }) => instance && Object.getPrototypeOf(instance))
    .forEach(({ instance }) => {
      this.scanner.getAllMethodNames(Object.getPrototypeOf(instance)).forEach(this.addEntryPointAtComment(instance));
    });
}

addEntryPointAtComment(instance: any) {
  return (methodName: string) => {
    const methodRef = instance[methodName];

    const metadata = this.reflector.get(ADD_ENTRY_POINT_COMMENT, instance[methodName]);
    if (!metadata) {
      return;
    }

    const originalMethod = methodRef;

    if (methodRef.constructor.name === 'AsyncFunction') {
      instance[methodName] = async (...args: unknown[]) => {
        const proxy = this.createProxy(instance, instance.constructor.name, methodName);
        return await originalMethod.call(proxy, ...args);
      };
    } else {
      instance[methodName] = (...args: unknown[]) => {
        const proxy = this.createProxy(instance, instance.constructor.name, methodName);
        return originalMethod.call(proxy, ...args);
      };
    }
  };
}
...

NestJS의 모든 인스턴스를 가져와 addEntryPointAtComment 함수를 실행합니다. 해당 함수는 앞서 선언한 AddEntryPointComment 데코레이터가 적용되었는지 검증 후 기존 데코레이터 처럼 Proxy를 적용합니다.

createProxy(thisArg: any, targetName: string, propertyKey: string) {
  const projectName = this.configService.get('app.projectName');
  return new Proxy(thisArg, {
    get: function (target, propKey, receiver) {
      if (propKey === 'createQueryBuilder') {
        const origin = target[propKey];
        const entrypoint = `${projectName}.${targetName}.${propertyKey}`;
        return (...args: any[]) => origin.call(target, ...args).comment(entrypoint);
      }
      return Reflect.get(target, propKey, receiver);
    },
  });
}

Proxy에서는 주입받은 ConfigService를 사용할 수 있으니, 프로젝트 이름을 config에서 가져와서 적용하도록 변경합니다. 이렇게 JavaScript 데코레이터에서 NestJS 데코레이터로 변경하였습니다.

NestJS 데코레이터 적용

데코레이터를 사용할 때 주의할 점은 단순히 데코레이터만 등록해서는 안 된다는 점입니다. 데코레이터는 메타데이터를 저장만 하고, NestJS의 생명주기 중 onModuleInit 함수가 호출될 때 실제 로직이 적용되기 때문입니다.

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MysqlConfig } from '../../common/config/mysql.config';
import { EntryPointLoggingModule } from './entry-point-logging.module';

@Module({
  imports: [TypeOrmModule.forRootAsync({ useClass: MysqlConfig }), EntryPointLoggingModule.forRoot()],
})
export class OrmModule {}

모듈을 적용합니다.

@Injectable()
export class PlatformRepository {
  constructor(
    @InjectRepository(Platform) private readonly repository: Repository<Platform>,
    // private readonly configService: ConfigService,
  ) {}
	...
}

그리고 이제 Repository에서 주입받던 ConfigService를 제거할 수 있습니다.

그런 다음에 데코레이터가 적용된 함수를 실행시켜보면 쿼리에 진입점 로그가 추가된 것을 확인할 수 있습니다.

앞서 정의한 문제 중 “config에 정의된 서비스 이름을 가져와야 하기 때문에, ConfigService가 모든 Repository에 주입되어야 합니다.” 를 해결했습니다.

Repository 계층에 자동으로 적용시키기

앞서 NestJS 데코레이터로 변환하던 과정을 보면 모든 프로바이더들을 가져와 모든 함수들 중에 데코레이터가 적용된 함수만 로직을 적용하도록 했습니다.

다르게 생각해 보면, 우리의 데코레이터는 많은 함수에 적용되어야 하니 데코레이터가 적용되지 않은 함수도 적용할 수 있겠다는 생각이 듭니다.

isRepository(instance: any) {
  // return instance.constructor.name.endsWith('Repository');
  return Object.getOwnPropertyNames(instance).some((prop) => instance[prop] instanceof Repository);
}

Repository 계층 인지 검증할 수 있는 함수를 정의합니다. 보통 Repository는 생성자 이름이 Repository로 끝나니 이름을 이용해서 검증할 수 있고, instance가 주입받은 속성중에 TypeORM Repository가 있는지 확인하는 방법도 있습니다.

성능면에서 이름을 검증하는 게 빠를 수 있습니다. 둘 중에 맘에 드는 방식으로 진행하면 될 것 같습니다.

onModuleInit() {
  this.discovery
    .getProviders()
    .filter((wrapper) => wrapper.isDependencyTreeStatic())
    .filter(({ instance }) => instance && Object.getPrototypeOf(instance))
    .filter(({ instance }) => this.isRepository(instance)) // Repository 계층 인지 필터링
    .forEach(({ instance }) => {
      this.scanner.getAllMethodNames(Object.getPrototypeOf(instance)).forEach(this.addEntryPointAtComment(instance));
    });
}

그런 다음 Repository가 아닌 instance를 모두 필터링하도록 합니다.

addEntryPointAtComment(instance: any) {
...
	// const metadata = this.reflector.get(ADD_ENTRY_POINT_COMMENT, instance[methodName]);
	// if (!metadata) {
	//   return;
	// }
...
}

그다음 메타데이터를 통해서 데코레이터가 적용된 함수인지 검증하는 부분을 제거합니다. 그런 다음에 Repository에서도 데코레이터를 삭제합니다.

그런 다음에 실행시켜 보면 함수에 데코레이터가 없음에도 불구하고, 진입점이 자동으로 추가되는 것을 확인할 수 있습니다.

💡 자동으로 적용되지 않았으면 하는 함수나 클래스들이 있을 수 있습니다. 그런 경우에는 제외할 수 있는 데코레이터를 선언하여 해당 데코레이터가 적용되어 있으면 건너뛰도록 적용할 수 있습니다.

이제 앞에서 정리한 문제 중 “많은 Repository 계층의 함수에 모두 적용해야 합니다.” 를 해결했습니다.

지금까지 한 내용을 통해 사람의 실수가 발생하지 않도록 하고 큰 노력 없이 대부분의 많은 Repository 계층의 함수에 자동으로 진입점이 로깅되도록 추가했습니다.

다양한 함수에 주석 추가하도록 하기

마지막으로 남은 문제는 “createQueryBuilder 를 사용하지 않는 함수는 여전히 로깅되지 않습니다.”

사실 TypeORM의 find findOne 과 같은 함수들을 잘 사용하지 않습니다. 왜냐하면 사람은 간단하게 생성할 수 있는 쿼리임에도 불구하고 TypeORM이 생성한 함수는 복잡하게 생성되는 문제가 종종 발생하기 때문입니다.

사람이 읽기에도 불편하지만, 복잡한 함수는 MySQL 엔진도 잘 해석하지 못하여 적절한 인덱스를 적용하지 못하는 문제가 있습니다.

그래서 팀 내에서 가능하면 쿼리 빌더를 이용하고 자체 함수는 지양하는 분위기입니다. 하지만 다르게 말하자면 TypeORM이 자체적으로 생성한 쿼리는 느릴 확률이 높기 때문에 오히려 더 추적되어야 한다는 의미로 받아들일 수 있습니다.

TypeORM 함수들 중에서 로깅이 필요한 대상을 정합니다

find(...)
findOne(...)
update(...)
delete(...)
...

꽤나 많은 함수들이 쿼리를 발생시키고 있습니다. 모든 함수를 다 찾아서 적용하기에는 까다로울 수 있습니다.

만약 발견하지 못한 함수가 있다면, 로깅이 안될 수 있습니다. 혹여나 모든 함수에 적용하더라도 TypeORM이 업데이트되어 함수가 사라졌을 때 관리가 안 되거나 새로 생겼을 때 로깅이 되지 않는 문제가 있을 수 있습니다.

그렇다면 이 문제 또한 주석이 추가가 가능한 함수라면 자동으로 추가되도록 하는 게 좋아 보입니다.

createProxy(thisArg: any, targetName: string, propertyKey: string) {
  const projectName = this.configService.get('app.projectName');
  return new Proxy(thisArg, {
    get: function (target, propKey, receiver) {
      const origin = target[propKey];
      if (propKey === 'createQueryBuilder') {
				...
      }

      if (origin instanceof Repository) { // 조회하는 값이 Repository 라면
        const entrypoint = `${projectName}.${targetName}.${propertyKey}`;
        return new Proxy(origin, {
          get: function (target, propKey, receiver) {
            if (target[propKey] instanceof Function) { // Repository 에서 함수를 가져올 때
              return (...args: any[]) => {
                const commentAddedArgs = args.map((arg) => {
                  if (Object.getOwnPropertyDescriptors(arg).hasOwnProperty('where') && !arg.comment) { // 'where'를 가지고 있고, 미리 추가된 주석이 없다면
                    arg.comment = entrypoint; // 진입점 주석 추가
                  }
                  return arg;
                });
                return Reflect.get(target, propKey, receiver).call(target, ...commentAddedArgs);
              };
            }

            return Reflect.get(target, propKey, receiver);
          },
        });
      }

      return Reflect.get(target, propKey, receiver);
    },
  });
}

Proxy 코드를 수정합니다. Repository를 조회할 때 Repository를 감싸는 Proxy를 정의합니다. 만약 함수를 호출하려고 하면, 함수의 모든 파라미터를 순회합니다.

함수의 모든 파라미터를 확인하여 where 속성이 있고, 기존에 comment가 추가된 것이 없다면, comment를 자동으로 entrypoint를 추가하도록 하였습니다.

모든 파라미터를 순회하지 않아도 됩니다. 파라미터의 크기와 길이가 늘어나면 성능에 문제가 발생할 수 있기 때문입니다. 게다가 대부분의 TypeORM 함수들은 첫 번째 인자로 FindOneOptions 를 받기 때문에 첫 번째 인자만 확인하여도 대부분의 함수에 대응할 수 있습니다.

get: function (target, propKey, receiver) {
  if (target[propKey] instanceof Function) {
    return (firstArg: any, ...args: any[]) => {
      if (firstArg && firstArg instanceof Object && firstArg.where && !firstArg.comment) { // 첫번째 인자만 검증
        firstArg.comment = entrypoint;
      }
      return Reflect.get(target, propKey, receiver).call(target, ...[firstArg, ...args]);
    };
  }

이렇게 하고 실행하면, TypeORM 내장 함수를 사용함에도 진입점이 자동으로 추가되는 것을 확인할 수 있습니다.

이렇게 마지막으로 남은 문제인 “createQueryBuilder 를 사용하지 않는 함수는 여전히 로깅되지 않습니다.” 를 해결했습니다.


마무리

조금 긴 과정을 통해서 TypeORM의 DB 진입점을 자동으로 추가하는 모듈을 구현했습니다. 최종적인 결과를 통해 대부분의 쿼리는 진입점이 로깅되어 조금 더 빠르게 슬로우 쿼리가 어디서 발생했는지 찾을 수 있게 되었습니다.

또한 모듈만 등록하면 자동으로 추가되도록 하여 로그를 추가하지 않는 실수를 하지 않을 거라는 보이지 않는 믿음에 기대지 않아도 되도록 하였습니다.

개인적으로 오랜만에 자바스크립트의 실행 Context부터 Proxy, Decorator까지 여러 부분을 다시 공부해 본 좋은 시간이 되었습니다.

profile
안녕하세요!

0개의 댓글

관련 채용 정보