diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..49ef2b2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // IntelliSense를 사용하여 가능한 특성에 대해 알아보세요. + // 기존 특성에 대한 설명을 보려면 가리킵니다. + // 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요. + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "개발 디버그", + "skipFiles": ["/**"], + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "start:dev"], + "autoAttachChildProcesses": true, + "restart": true, + "sourceMaps": true, + "cwd": "${workspaceRoot}", + "console": "integratedTerminal", + "protocol": "inspector" + } + ] +} diff --git a/src/app.module.ts b/src/app.module.ts index 06d8f9c..f5855b7 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,6 +14,7 @@ import { SummaryModule } from './summary/summary.module'; import { TypeOrmConfigService } from './database/typerom-config.service'; import { OpenaiModule } from './openai/openai.module'; import { AppController } from './app.controller'; +import { AopModule } from './common/aop/aop.module'; @Module({ imports: [ @@ -101,6 +102,7 @@ import { AppController } from './app.controller'; : '', }), OpenaiModule, + AopModule, ], controllers: [AppController], providers: [], diff --git a/src/common/aop/aop.module.ts b/src/common/aop/aop.module.ts new file mode 100644 index 0000000..0a9e271 --- /dev/null +++ b/src/common/aop/aop.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { DiscoveryModule } from '@nestjs/core'; +import { AutoAspectExecutor } from './auto-aspect.executor'; +import { TransactionDecorator } from './transactional.decorator'; + +@Module({ + imports: [DiscoveryModule], + providers: [AutoAspectExecutor, TransactionDecorator], +}) +export class AopModule {} diff --git a/src/common/aop/aspect.ts b/src/common/aop/aspect.ts new file mode 100644 index 0000000..b6ce864 --- /dev/null +++ b/src/common/aop/aspect.ts @@ -0,0 +1,6 @@ +import { Injectable, SetMetadata, applyDecorators } from '@nestjs/common'; + +export const ASPECT = Symbol('ASPECT'); + +export const Aspect = (metadataKey: string | symbol) => + applyDecorators(SetMetadata(ASPECT, metadataKey), Injectable); diff --git a/src/common/aop/auto-aspect.executor.ts b/src/common/aop/auto-aspect.executor.ts new file mode 100644 index 0000000..f3b52fb --- /dev/null +++ b/src/common/aop/auto-aspect.executor.ts @@ -0,0 +1,75 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core'; +import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; +import { ASPECT } from './aspect'; + +/** + * 모듈 초기화 시 전체 프로바이더를 탐색하여 AOP를 활용하는 메서드를 찾습니다. + */ +@Injectable() +export class AutoAspectExecutor implements OnModuleInit { + constructor( + private readonly discoveryService: DiscoveryService, + private readonly metadataScanner: MetadataScanner, + private readonly reflector: Reflector, + ) {} + + onModuleInit() { + const providers = this.discoveryService.getProviders(); + + const lazyDecorators = this.lookupLazyDecorator(providers); + if (lazyDecorators.length === 0) { + return; + } + + providers + .filter((wrapper) => wrapper.isDependencyTreeStatic()) + .filter(({ instance }) => instance && Object.getPrototypeOf(instance)) + .forEach(({ instance }) => { + this.metadataScanner.scanFromPrototype( + instance, + Object.getPrototypeOf(instance), + (methodName) => { + lazyDecorators.forEach((lazyDecorator) => { + const metadataKey = this.reflector.get( + ASPECT, + lazyDecorator.constructor, + ); + + const metadata = this.reflector.get( + metadataKey, + instance[methodName], + ); + if (!metadata) { + return; + } + const wrappedMethod = lazyDecorator.wrap( + instance, + instance[methodName], + metadata, + ); + instance[methodName] = wrappedMethod; + }); + }, + ); + }); + } + + private lookupLazyDecorator(providers: InstanceWrapper[]) { + return providers + .filter((wrapper) => wrapper.isDependencyTreeStatic()) + .filter(({ instance, metatype }) => { + if (!instance || !metatype) { + return false; + } + + const aspect = this.reflector.get(ASPECT, metatype); + if (!aspect) { + return false; + } + + return instance.wrap; + }) + .map(({ instance }) => instance); + } +} diff --git a/src/common/aop/decorator.interface.ts b/src/common/aop/decorator.interface.ts new file mode 100644 index 0000000..e548cb9 --- /dev/null +++ b/src/common/aop/decorator.interface.ts @@ -0,0 +1 @@ +export type Decorator = (...args: any) => void | Promise; diff --git a/src/common/aop/lazy-decorator.ts b/src/common/aop/lazy-decorator.ts new file mode 100644 index 0000000..ac7112a --- /dev/null +++ b/src/common/aop/lazy-decorator.ts @@ -0,0 +1,8 @@ +import { Decorator } from './decorator.interface'; + +/** + * 데코레이터의 초기화를 모듈이 생성되는 시점까지 늦춥니다. + */ +export interface LazyDecorator { + wrap(target: unknown, originalFn: any, options: void): Decorator | undefined; // TODO 반환 형식 +} diff --git a/src/common/aop/transactional.decorator.ts b/src/common/aop/transactional.decorator.ts new file mode 100644 index 0000000..55ab096 --- /dev/null +++ b/src/common/aop/transactional.decorator.ts @@ -0,0 +1,31 @@ +import { LazyDecorator } from './lazy-decorator'; +import { DataSource } from 'typeorm'; +import { Aspect } from './aspect'; +import { TRANSACTIONAL } from './transactional'; + +/** + * TODO Transaction 전파 등 다양한 케이스를 고려해야 함. + * 1. 해당 Decorator는 단순 명령을 처리하는 객체이므로, 내부 구현만 달라지면 OK + * 2. Repository에서 트랜잭션을 매니징할 객체를 시그니처에 명시한다. -> 매니징 객체의 타입이 상당히 애매하다. 어댑터로 추상화를 도울 수 있도록 개선. + */ +@Aspect(TRANSACTIONAL) +export class TransactionDecorator implements LazyDecorator { + constructor(private readonly dataSource: DataSource) {} // TypeORM에 의존하고 있으므로 주의 + + wrap(target: unknown, originalFn: any, _: void) { + return async (...args: any[]) => { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + await originalFn.call(target, ...args, queryRunner.manager); + await queryRunner.commitTransaction(); + } catch (e) { + await queryRunner.rollbackTransaction(); + throw e; + } finally { + await queryRunner.release(); + } + }; + } +} diff --git a/src/common/aop/transactional.ts b/src/common/aop/transactional.ts new file mode 100644 index 0000000..dbaf607 --- /dev/null +++ b/src/common/aop/transactional.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const TRANSACTIONAL = Symbol('TRANSACTIONAL'); + +export const Transactional = () => SetMetadata(TRANSACTIONAL, true);