기존에 TypeORM 라이브러리에서 트랜잭션을 적용하던 방식은 아래와 같다
async createUser(createUserDto: CreateUserDto) {
const { email, password, nickname } = createUserDto;
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 비즈니스 로직
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
위 코드처럼 기존의 트랜잭션 사용 방식은 해당 메서드의 크기가 커질 수 있었고, try-catch 절의 비즈니스 로직을 한 눈에 파악하기 어려웠다.
그래서 AOP를 이용하여 트랜잭션과 관련된 로직을 구현하기로 결정하였다.
Aspect Oriented Programming(관점 지향 프로그래밍)이란?
AOP는 애플리케이션의 여러 부분에서 반복되는 횡단 관심사(cross-cutting concerns)를 분리하여 모듈화하는 프로그래밍 패러다임이다.
NestJS에서는 이를 통해 코드의 재사용성과 관리성을 높일 수 있다.
NestJS의 인터셉터를 이용하면, 트랜잭션이라는 공통 로직을 중앙에서 관리할 수 있게 된다.
@Injectable()
export class TransactionInterceptor implements NestInterceptor {
constructor(private readonly dataSource: DataSource) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const req = context.switchToHttp().getRequest();
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
req.queryRunnerManager = queryRunner.manager;
return next.handle().pipe(
catchError(async (err) => {
await queryRunner.rollbackTransaction();
await queryRunner.release();
if (err instanceof HttpException) {
throw new HttpException(err.message, err.getStatus());
} else {
throw new InternalServerErrorException(err.message);
}
}),
tap(async () => {
await queryRunner.commitTransaction();
await queryRunner.release();
})
);
}
}
// transaction-manager.decorator.ts
export const TransactionManager = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return req.queryRunnerManager;
});
// users.controller.ts
@Post('signup')
@UsePipes(ValidationPipe)
@HttpCode(HttpStatus.CREATED)
async signUp(
@Body() createUserDto: CreateUserDto,
@TransactionManager() queryRunnerManager: EntityManager,
) {
await this.usersService.signUp(createUserDto, queryRunnerManager);
return { message: 'success' };
}
OAuth를 구현하기 위해 Passport 라이브러리를 이용했는데, 그러다보니 가드에서 해당 OAuth 전략에 맞는 유저를 생성하는 작업을 담당하였다.
그러나, NestJS의 실행 순서를 확인해보면, 인터셉터보다 가드가 먼저 실행되기 때문에, 트랜잭션이 적용되지 않는 문제가 발생했다.
인터셉터에서의 문제를 해결하기 위해 접근 방식을 변경하였다. 인터셉터 대신 미들웨어를 사용하고, cls-hooked 라이브러리를 사용하였다.
cls-hooked 라이브러리는 비동기 작업 간에 트랜잭션 컨텍스트를 유지할 수 있게 도와주는 라이브러리다.
클라이언트의 요청은 NestJS의 미들웨어를 가장 먼저 거친다. 따라서 namespace를 미들웨어에서 생성한 후, EntityManager를 등록한다.
@Injectable()
export class TransactionMiddleware implements NestMiddleware {
constructor(private readonly entityManager: EntityManager) {}
use(_req: Request, _res: Response, next: NextFunction) {
const namespace = getNamespace(TRANSACTION) ?? createNamespace(TRANSACTION);
return namespace.runAndReturn(async () => {
Promise.resolve()
.then(() => this.setEntityManager())
.then(next);
});
}
private setEntityManager() {
const namespace = getNamespace(TRANSACTION) as Namespace;
namespace.set(ENTITY_MANAGER, this.entityManager);
}
}
미들웨어에서 등록한 EntityManager을 가져오고, 트랜잭션을 실행한다. 그 후, 데코레이터를 적용한 서비스 레이어의 메서드를 감싸도록하여 트랜잭션이 적용되도록 한다.
export function Transactional() {
return function (_target: any, _propertyKey: string | symbol, descriptor: PropertyDescriptor) {
const originMethod = descriptor.value;
async function transactionWrapped(...args: unknown[]) {
const namespace = getNamespace(TRANSACTION);
if (!namespace || !namespace.active) {
throw new InternalServerErrorException(`${TRANSACTION} is not active`);
}
const entityManager = namespace.get(ENTITY_MANAGER) as EntityManager;
if (!entityManager) {
throw new InternalServerErrorException(
`Could not find EntityManager in ${TRANSACTION} namespace`
);
}
return await entityManager.transaction(async (transactionalEntityManager: EntityManager) => {
namespace.set(ENTITY_MANAGER, transactionalEntityManager);
return await originMethod.apply(this, args);
});
}
descriptor.value = transactionWrapped;
};
}
namespace가 존재하고 active 상태라면, EntityManager를 반환하는 헬퍼다. 이 헬퍼를 이용해서 레포지토리는 트랜잭션 컨텍스트에 접근하여 EntityManager를 가져온다.
@Injectable()
export class TransactionManager {
public getEntityManager(): EntityManager {
const namespace = getNamespace(TRANSACTION);
if (!namespace || !namespace.active)
throw new InternalServerErrorException(`${TRANSACTION} is not active`);
return namespace.get(ENTITY_MANAGER);
}
}
// generic-transaction.repository.ts
export abstract class GenericTypeOrmRepository<T extends RootEntity>
implements IGenericRepository<T>
{
constructor(
@Inject(TransactionManager) private readonly transactionManager: TransactionManager
) {}
...
}
NestJS - Transaction Interceptor 적용하기
NestJS Meet up
NestJs Transaction Decorator 만들기
Handling Transactions (feat. NestJS, TypeORM)