NestJS에서 AOP를 활용하여 트랜잭션 관리 개선하기

Oneik·2024년 7월 7일
0

문제: 너무 복잡한 코드

기존에 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에서는 이를 통해 코드의 재사용성과 관리성을 높일 수 있다.

TranscationInterceptor 사용하기

NestJS의 인터셉터를 이용하면, 트랜잭션이라는 공통 로직을 중앙에서 관리할 수 있게 된다.

동작 과정

  1. 인터셉터를 통해 요청을 가로챈 후, QueryRunner를 생성하여 새로운 트랜잭션을 시작한다.
  2. QueryRunnerManager를 요청 객체에 추가한다.
  3. TransactionManager 데코레이터를 통해 요청 객체에 존재하는 QueryRunnerManager를 추출하여, 서비스 메서드에 전달한다.
  4. 모든 작업이 완료되면, 인터셉터에서 응답을 가로채서 트랜잭션을 커밋하고, QueryRunner를 해제한다.
  5. 에러가 발생하면, 트랜잭션을 롤백하고 QueryRunner를 해제한다.

TransactionInterceptor 구현

@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();
      })
    );
  }
}

TransactionManager 데코레이터

// 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 구현 시 문제 발생

OAuth를 구현하기 위해 Passport 라이브러리를 이용했는데, 그러다보니 가드에서 해당 OAuth 전략에 맞는 유저를 생성하는 작업을 담당하였다.

그러나, NestJS의 실행 순서를 확인해보면, 인터셉터보다 가드가 먼저 실행되기 때문에, 트랜잭션이 적용되지 않는 문제가 발생했다.

Transactional Decorator 사용하기

인터셉터에서의 문제를 해결하기 위해 접근 방식을 변경하였다. 인터셉터 대신 미들웨어를 사용하고, cls-hooked 라이브러리를 사용하였다.

cls-hooked 라이브러리는 비동기 작업 간에 트랜잭션 컨텍스트를 유지할 수 있게 도와주는 라이브러리다.

동작 방식

  1. 요청이 들어오면, TransactionMiddleware에서 namespace를 만든 후, EntityManager를 등록한다.
  2. 서비스 레이어에서 해당 메서드에 Transactional 데코레이터를 사용하여 트랜잭션을 적용한다.
  3. Custom Repository에서 namespace에서 EntityManager를 꺼내 사용할 수 있도록 TransactionManager를 적용한다.

TransactionMiddleware에서 namespace에 EntityManager 등록

클라이언트의 요청은 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);
  }
}

Transactional 데코레이터

미들웨어에서 등록한 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;
  };
}

TransactionManger

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
  ) {}
  
  ...
  
}

Reference

NestJS - Transaction Interceptor 적용하기
NestJS Meet up
NestJs Transaction Decorator 만들기
Handling Transactions (feat. NestJS, TypeORM)

profile
초보 개발자의 블로그입니다

0개의 댓글

관련 채용 정보