들어가기에 앞서
이번 포스팅은 아래 자료를 참고해, 직접 적용해보며 재구성한 글임을 밝힙니다. 좋은 자료 감사합니다!
- NestJS 환경에 맞는 Custom Decorator 만들기 (toss.tech)
- Handling Transactions (feat. NestJS, TypeORM) (velog.io)
- [NestJS 밋업] Shall We NestJS? (youtube.com)
현재의 문제 상황
async writePost(postDto: WritePostDto, userUuid: string) {
... DTO 검증 및 변환 작업들
return this.prisma.beginTransaction<PostDto>(async () => {
const summary = await this.summaryService.summarizePost(post.title, blocks);
const createdPost = await this.postService.createPost(userUuid, { ...post, summary });
const user = await this.userService.findUserByUniqueInput({ uuid: userUuid });
const { uuid: postUuid } = createdPost;
const [createdBlocks, createdFiles] = await Promise.all([
this.blockService.createBlocks(postUuid, blocks),
this.fileService.attachFiles(files),
]);
...
});
}
저번 글에서는 AsyncLocalStorage를 활용하는 PrismaProvider를 만들어, 트랜잭션을 애플리케이션 계층에서 레파지토리까지 전파해 주었습니다. 적용된 코드는 위와 같을 것 입니다.
- PrismaProvider를 사용해 코드 레벨에서 트랜잭션을 처리해 주고 있습니다.
- 핵심 비즈니스 로직과 트랜잭션 관리 로직이 뒤엉켜 있습니다.
- 애플리케이션 계층이 인프라 (PrismaORM) 종속성과 너무 강하게 결합되어 있습니다.
제가 만든 PrismaProvider를 모르는 사람은 이 코드를 보고 이해할 수 있을까요? 또, 이를 사용해 새로운 기능을 개발할 수 있을까요? 십중팔구 이해하기 위해 수많은 시간을 소요할 것입니다.
트랜잭션 처리를 데코레이터로 분리해보자
이 문제를 해결하기 위해서는 트랜잭션 처리를 보다 추상화해야 합니다. 그렇다면 이전에 Layered Architecture로 핵심 관심사(비즈니스 로직)을 분리했듯이, AOP(Aspect-Oriented Promgramming) 관점에서 트랜잭션 처리를 모듈로 분리할 것입니다.
사실 트랜잭션 처리는 핵심 비즈니스 로직에 관련이 없습니다. 그저 동작이 실패했을 때, 데이터를 원복하는 부가적인 기능을 수행할 뿐입니다. 그래서 이런 작업들은 핵심 관심사에서 벗어나게 되는 횡단 관심사(Cross Cutting Concerns)라고 합니다.
그래서 오늘은 @Transactional() 데코레이터를 구현해, 트랜잭션 처리를 AOP 관점에서 위임할 것입니다. 오늘 해볼 작업은 크게 3가지가 되겠습니다.
- 트랜잭션 데코레이터를 만들고, 프로젝트에서 데코레이터가 달린 메소드를 찾아야 합니다.
- 데코레이터가 달린 메소드에 트랜잭션을 처리해야 합니다.
- 레파지토리의 PrismaClient에게 상황에 따라 트랜잭션 상황을 전파해야 합니다.
트랜잭션 데코레이터를 만들고, 프로젝트에서 트랜잭션 메소드를 찾아보자
@Transactional 데코레이터 만들기
@Post()
@SetMetadata('roles', ['admin'])
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
nest.js에서는 이미 @SetMetadata() 라는 데코레이터가 존재합니다.
- 이 데코레이터는 위와 같이 key, value 형태로 메소드에 메타 데이터를 부여할 수 있습니다.
import { SetMetadata } from '@nestjs/common';
const TRANSACTIONAL = Symbol('TRANSACTIONAL');
export const Transactional = (): MethodDecorator => SetMetadata(TRANSACTIONAL, true);
이 데코레이터를 확장해, 트랜잭션 메타 데이터를 부여하는 Transactional 데코레이터를 만들겠습니다.
- TRANSACTIONAL Key를 Symbol 객체로 사용해, Key 값이 중복되는 사태를 방지합니다.
@Transactional 가 달린 메소드 찾기
/**
* Retrieves static instances from the discovery service.
* @returns Array of static instances.
*/
private getStaticInstances(): any[] {
return this.discoverService
.getProviders()
.filter((wrapper) => wrapper.isDependencyTreeStatic()) [1]
.filter(({ instance }) => instance && Object.getPrototypeOf(instance)) [2]
.map(({ instance }) => instance); [3]
}
nest.js에서 메소드를 찾는 DiscoveryModule을 활용해, 프로젝트의 모든 메소드들을 불러옵니다.
- Wrapper 객체 중, 싱글톤인 객체만 불러올 수 있도록 필터링합니다.
- 실제 객체를 참조하는 Instance가 있는지 확인하고, 있으면 Wrapper 객체 밖으로 꺼냅니다.
/**
* Wraps methods annotated with @Transactional.
*/
wrapDecorators() {
const instances = this.getStaticInstances();
instances.forEach((instance) => {
const prototype = Object.getPrototypeOf(instance);
const methodNames = this.metadataScanner.getAllMethodNames(prototype); [1]
methodNames.forEach((name) => {
const method = instance[name]; [2]
// Wrap only if the method is annotated with @Transactional.
if (this.reflector.get(TRANSACTIONAL, method)) { [3]
instance[name] = this.wrapMethods(method, instance);
}
});
});
}
이제 찾은 Instance들에게 접근해, @Transactional()이 달린 메소드를 찾아봅시다.
- Instance(서비스, 컨트롤러 등)에 등록된 메소드 이름을 모두 찾습니다.
- 찾은 이름을 Key에 대입해, Instance 안의 모든 메소드를 접근할 수 있습니다.
- 메소드에 메타 데이터가 TRANSACTIONAL Symbol을 가졌는지 검사합니다.
이제 @Transactional()이 달린 메소드를 모두 찾을 수 있습니다. 이제 이 메소드에 트랜잭션 관련 로직을 씌어 주겠습니다.
메소드에 트랜잭션을 적용해보자
메소드에 트랜잭션 처리하기
/**
* Wraps a method with transactional logic.
* @param method The original method to wrap.
* @param instance The instance of the class containing the method.
* @returns The wrapped method.
*/
private wrapTransaction(method: any, instance: any) {
const { prisma, asyncLocalStorage } = this;
// Wrapping the original method with transaction logic.
return async function (...args: any[]) {
const store = asyncLocalStorage.getStore();
if (store) { [1]
return method.apply(instance, args);
}
return prisma.$transaction(async (txClient) => [3]
asyncLocalStorage.run({ txClient }, () => method.apply(instance, args)),
);
};
}
메소드를 Wrapping하는 비동기 함수를 구현해 보겠습니다. AsyncLocalStorage를 활용해 @Transactional 데코레이터가 달린 비동기 함수 안에 txClient(트랜잭션 클라이언트)을 보관할 것입니다.
- 이미 트랜잭션 상태라면 그냥 메소드를 실행합니다. This Binding에 주의합시다.
(이 경우 상위, 하위 함수끼리 @Transactional() 데코레이터가 겹칠 경우일 것입니다) - 트랜잭션 상태가 아니라면, 대화형 트랜잭션을 실행하고 AsyncLocalStorage에 txClient를 보관합니다.
상위, 하위 함수끼리 @Transactional() 데코레이터가 겹칠 경우가 어떤 뜻일까요? 예시를 들어 설명해 보겠습니다.
writePost와 createPost에 @Transactional() 데코레이터가 달린 상태를 예시로 들어 보겠습니다. 먼저 wrtiePost가 실행된 이후, 하위 함수로 createPost가 실행 된 상태입니다.
- 이 때는 상위 writePost가 트랜잭션을 실행했기 때문에 , 이미 txClient가 존재합니다.
- 다시 트랜잭션을 실행하게 된다면 createPost를 기준으로 새로운 트랜잭션 범위가 지정되게 됩니다.
- 이 때, writePost에서 예외가 발생해도, createPost만 성공했으면 DB에 내용이 Commit 됩니다.
의존성 주입이 완료된 이후에 작업 수행하기
데코레이터를 찾고, 트랜잭션을 적용하는 작업은 모든 모듈의 "의존성 주입"이 모두 완료된 뒤에 수행해야 합니다. 이를 위해 NestJS 모듈의 생명 주기를 알아봐야 합니다.
onModuleInit() | Called once the host module's dependencies have been resolved. |
onApplicationBootstrap() | Called once all modules have been initialized, but before listening for connections. |
onModuleDestroy()* | Called after a termination signal (e.g., SIGTERM) has been received. |
beforeApplicationShutdown()* | Called after all onModuleDestroy() handlers have completed (Promises resolved or rejected); once complete (Promises resolved or rejected), all existing connections will be closed (app.close() called). |
onApplicationShutdown()* | Called after connections close (app.close() resolves). |
@Injectable()
export class TransactionManager implements OnModuleInit {
private readonly prisma = new PrismaClient();
constructor(
@Inject('TRANSACTION_STORAGE') private readonly asyncLocalStorage: TransactionStorage,
private readonly discoverService: DiscoveryService,
private readonly metadataScanner: MetadataScanner,
private readonly reflector: Reflector,
) {}
/**
* Initializes the transaction manager, wrapping methods and repositories with transactional logic.
*/
onModuleInit() {
this.wrapDecorators();
}
... 후략
onModuleInit()은 모든 모듈의 의존성 주입이 모두 완료될 때 수행되는 생명 주기입니다. NestJS Provider에 이를 implements하고, 메소드를 사용하면 이 생명 주기를 추적해 메소드를 실행할 수 있습니다.
레파지토리의 PrismaClient에 트랜잭션 상황을 전파하자
PrismaClient 확장하기
/**
* Extends prisma to use the transactional client from AsyncLocalStorage.
*/
wrapPrisma() {
const { asyncLocalStorage } = this;
return this.$extends({
query: {
$allOperations: async ({ args, model, operation, query }) => { [1]
const store = asyncLocalStorage.getStore();
if (!store) { [2]
return query(args);
}
if (!model) { [3]
return store.txClient[operation](args);
}
return store.txClient[model][operation](args);
},
},
});
}
PrismaORM은 Client를 확장할 수 있는 기능을 제공합니다.
- $allOperations은 Prisma의 모든 연산을 수행할 때 발생하는 이벤트입니다.
- AsyncLocalStorage에 접근해 txClient를 찾고, 없으면 query를 바로 실행합니다.
- txClient가 있을 경우(트랜잭션 상태일 경우), model와 operation을 이용해 연산을 대신 수행합니다.
@Injectable()
export class TxPrismaService extends PrismaClient implements OnModuleInit {
constructor(@Inject('TRANSACTION_STORAGE') private readonly asyncLocalStorage: TransactionStorage) {
super();
}
async onModuleInit() {
await this.$connect();
// overwrite prisma
Object.assign(this, this.wrapPrisma());
}
우리가 만든 확장을 적용하기 위해, Object.assign을 사용해 객체를 덮어씌웁니다.
@Transactional()
async writePost(postDto: WritePostDto, userUuid: string) {
... DTO 검증 및 변환 작업들
const summary = await this.summaryService.summarizePost(post.title, blocks);
const createdPost = await this.postService.createPost(userUuid, { ...post, summary });
const user = await this.userService.findUserByUniqueInput({ uuid: userUuid });
const { uuid: postUuid } = createdPost;
const [createdBlocks, createdFiles] = await Promise.all([
this.blockService.createBlocks(postUuid, blocks),
this.fileService.attachFiles(files),
]);
...
}
이제 @Transactonal() 데코레이터로 트랜잭션을 손쉽게 처리해 줄 수 있습니다.
- 해당 비동기 함수 아래에 있는 모든 Prisma 작업은 하나의 트랜잭션으로 묶이게 됩니다.
- 하위 함수에 @Transactional() 데코레이터가 있어 겹치더라도, 최상위 함수 기준으로 하나의 트랜잭션으로 묶입니다.
'IT > Node.js' 카테고리의 다른 글
nest.js와 PrismaORM 트랜잭션 여행기 #1 AsyncLocalStorage로 트랜잭션을 전파해보자 (1) | 2023.12.20 |
---|