Spring에서는 @Transactional을 이용해 트랜잭션 사용이 가능하다.
이는 DB 라이브러리 개발자들이 Spring의 트랜잭션 추상화에 맞게 구현했기 때문이다.
하지만 Nest.js는 트랜잭션 관련한 추상화된 스펙을 제공하지 않는다.
그렇기에 라이브러리에서 제공하는 트랜잭션 명세에 전적으로 의존하게 된다.
예를 들면 Prisma의 트랜잭션 사용 방법은 다음과 같다.
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma.service';
import { User } from '@prisma/client';
@Injectable()
export class UserService {
constructor(private readonly prismaService: PrismaService) {}
async doSomething(user: User) {
await this.prismaService.$transaction(async (tx) => {
await tx.user.create({ data: user });
// ...
})
}
}
비즈니스 로직과 DB 로직을 따로 나누지 않는다면 이렇게 사용하면 된다.
하지만 나는 Repository 패턴을 선호하기에 아래와 같이 하였다.
// user.service.ts
@Injectable()
export class UserService {
constructor(
private readonly prismaService: PrismaService,
private readonly userRepository: UserRepository,
) {}
async doSomething(user: User) {
await this.prismaService.$transaction(async (tx) => {
await this.userRepository.save(user, tx);
})
}
}
// user.repository.ts
@Injectable()
export class UserRepository {
constructor(private readonly prisma: PrismaService) {}
async save(user, tx) {
await (tx ?? this.prisma).user.create({ data: user });
}
}
Repository 계층의 함수들을 하나의 트랜잭션으로 묶기 위해서는 Service 계층에서 트랜잭션이 관리되어야 한다.
하지만 위의 코드처럼 Service에서 트랜잭션을 직접 관리하면 Repository를 분리한 의미가 퇴색된다.
그러면 트랜잭션을 함수의 인자가 아닌 다른 방법으로 전달하는 방법이 있을까?
대안으로 AsyncLocalStorage와 @toss/nestjs-aop를 사용해서 @Transactional 데코레이터를 구현하기로 했다.
AsyncLocalStorage는 함수 인자를 통해 명시적으로 전달하지 않아도 애플리케이션 전체에 상태를 전파하는 방법을 제공한다.
Java의 Local Thread와 유사한 컨셉인데, 비동기 Context마다 각자의 storage를 제공한다.
자세한 설명은 Nestjs 공식문서로 대체하겠다.
@toss/nestjs-aop는 DI를 이용하여 데코레이터를 구현할 수 있게 해준다.
(provider class로 구현한 기능을 decorator를 사용한 함수에 래핑하는 방식인듯 하다)
다음과 같이 구현하였다.
import { Aspect, createDecorator, LazyDecorator, WrapParams } from '@toss/nestjs-aop';
import { AsyncLocalStorage } from 'async_hooks';
import { PrismaService } from '../prisma.service';
export const TRANSACTIONAL_DACORATOR = Symbol('TRANSACTIONAL_DACORATOR');
interface TransactionOptions {}
@Aspect(TRANSACTIONAL_DACORATOR)
export class TransactionalDecorator implements LazyDecorator<any, TransactionOptions> {
constructor(
private readonly als: AsyncLocalStorage<any>,
private readonly prisma: PrismaService,
) {}
wrap({ method, metadata: options }: WrapParams<any, TransactionOptions>) {
return (...args: any) => {
return this.prisma.$transaction(async (tx) => {
return this.als.run({ tx }, () => method(...args));
})
}
}
}
export const Transactional = (options?: TransactionOptions) => createDecorator(
TRANSACTIONAL_DACORATOR,
options
)
이제 Service 계층은 단순해진다.
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository,
) {}
@Transactional()
async doSomething(user: User) {
await this.userRepository.save(user);
}
}
Repository 계층은 조금 지저분해지긴 하지만... 어딘가에서는 이러한 처리를 해주어야 한다.
@Injectable()
export class UserRepository {
constructor(
private readonly prisma: PrismaService,
private readonly als: AsyncLocalStorage<{tx?: any}>,
) {}
async save(user: User) {
const client = this.als.getStore().tx ?? this.prisma;
await client.user.create({ data: user });
}
}
(예제에서는 제네릭과 client 변수의 type을 제대로 명시해놓지 않았다. 실제로 사용할때는 고려해야 할 부분...)
(client 변수 자체도 상속 혹은 합성으로 충분히 분리할 수 있을 것 같다)
테스트를 위해 Service 계층을 수정하였다.
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository,
) {}
@Transactional()
async doSomething() {
await this.userRepository.save({ email: 'test@gmail.com' } as User);
await this.userRepository.save({ email: 'test2@gmail.com' } as User);
throw new BadRequestException() // 롤백 테스트용 예외 추가
}
}
Postman으로 테스트해 본 결과 예외도 잘 반환하고 테이블에도 아무것도 적재되지 않았다.
이번엔 예외를 제거하고 실행해 보았다.
@Transactional()
async doSomething() {
await this.userRepository.save({ email: 'test@gmail.com' } as User);
await this.userRepository.save({ email: 'test2@gmail.com' } as User);
}
데이터가 잘 적재된 것을 확인할 수 있다.
마무리하며
사실 예제 코드에는 부실한 부분이 많습니다.
데코레이터가 Prisma 구현체에 강하게 의존하고 있어서 변경에 취약하다는 점도 한몫하고, Lock 등 관련 설정 세팅은 전부 생략했어요.
이런 방식으로 트랜잭션 추상화를 시도해볼만 하다는것만 참고 부탁드립니다!
'BackEnd > Nest.js' 카테고리의 다른 글
NestJS v10.0.0 redis 에러 이슈 (2) | 2023.12.24 |
---|---|
[TypeORM] join한 릴레이션이 property로 추론되는 이슈 (0) | 2022.10.31 |
[TypeORM] getRawOne, getRawMany 리턴 타입 매핑하기 (0) | 2022.10.31 |