본문 바로가기

Backend

[NestJS] Interceptor를 활용한 TypeORM Transaction 코드 개선하기

TypeORM에서 제공해 주고 있는 QueryRunner 트랜잭션을 사용하려면 commit, rollback, release 처리를 수동으로 해주어야 합니다.

그래서 코드가 길어짐과 동시에 가독성이 떨어지게 되고, 트랜잭션을 수행하는 함수마다 동일한 코드가 반복되어 생길 것입니다.

이를 개선하고자 트랜잭션 인터셉터를 생성하여 적용했던 방법을 기록하고자 합니다.

 

인터셉터의 동작 과정

NestJS Lifecycle

우선 인터셉터가 동작하는 순서를 그림으로 살펴보면, 컨트롤러를 거쳐 서비스 로직이 실행되기 전/후에 실행된다는 것을 알 수 있습니다.

이 점을 활용해 우리는 아래의 로직을 인터셉터로 구현할 것입니다.

1. 컨트롤러/서비스 로직이 실행되기 전
  a. 새로운 QueryRunner 인스턴스를 생성하고 트랜잭션 start
  b. Request 객체에 생성된 QueryRunner 인스턴스를 세팅

2. 서비스 로직이 실행된 후
  a. 정상적으로 수행되었다면 QueryRunner 인스턴스를 사용해 변경한 모든 내용을 commit
  b. 에러가 발생했다면 QueryRunner 인스턴스를 사용해 변경한 모든 내용을 rollback 하고 에러 처리

3. QueryRunner 인스턴스 relase

 

기존 트랜잭션을 수행하는 서비스 로직

간단한 주문 내역을 생성한다고 가정하겠습니다.

하나의 order는 여러 개의 orderItem을 가지는 1:N 구조입니다.

orders ERD

 

@Controller('orders')
export class OrdersController {
  constructor(private readonly ordersService: OrdersService) {}

  @Post()
  async createOrder(@Body() body: CreateOrderRequestDto): Promise<Order> {
    return await this.ordersService.createOrder(body);
  }
}

 

@Injectable()
export class OrdersService {
  constructor(private readonly dataSource: DataSource) {}

  async createOrder({ itemsToOrder }: CreateOrderRequestDto): Promise<Order> {
    const queryRunner = this.dataSource.createQueryRunner();

    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      const order = await queryRunner.manager.save(
        Order.create({
          name:
            itemsToOrder.length > 1
              ? `${itemsToOrder[0].name} 외 ${itemsToOrder.length - 1}건`
              : itemsToOrder[0].name,
          totalPrice: itemsToOrder.reduce(
            (result, { price }) => result + price,
            0,
          ),
        }),
      );

      await queryRunner.manager.save(
        itemsToOrder.map(({ id, name, price }) =>
          OrderItem.create({
            orderId: order.id,
            productId: id,
            name,
            price,
          }),
        ),
      );
      
      await queryRunner.commitTransaction();

      return order;
    } catch (e) {
      await queryRunner.rollbackTransaction();
      throw new InternalServerErrorException();
    } finally {
      await queryRunner.release();
    }
  }
}

 

우리는 하나의 주문을 생성할 때, 주문할 상품 리스트를 받아 order, orderItems를 저장만 해주면 됩니다.

그런데 위의 코드를 보다시피, 서비스 로직에서 트랜잭션 처리를 모두 해주다 보니 가독성이 떨어지게 됩니다.

또한 트랜잭션 처리가 필요한 서비스 로직마다 동일하게 중복 코드가 발생할 것입니다.

 

트랜잭션을 수행하는 서비스 로직 개선하기

1. 트랜잭션 인터셉터 생성하기

인터셉터는 위에서 본 것처럼 컨트롤러 실행 전, 서비스 로직 수행 후 수행된 결과가 반환된 후 실행됩니다.

그래서 트랜잭션 처리 코드들을 인터셉터로 모아주겠습니다.

 

@Injectable()
export class TransactionInterceptor implements NestInterceptor {
  constructor(private readonly dataSource: DataSource) {}

  async intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Promise<Observable<any>> {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    const request = context.switchToHttp().getRequest();
    request.queryRunnerManager = queryRunner.manager;

    return next.handle().pipe(
      catchError(async (error) => {
        await queryRunner.rollbackTransaction();
        await queryRunner.release();

        if (e instanceof HttpException) {
          throw new HttpException(e.getResponse(), e.getStatus());
        }
        throw new InternalServerErrorException();
      }),
      tap(async () => {
        await queryRunner.commitTransaction();
        await queryRunner.release();
      }),
    );
  }
}

 

2. 커스텀 데코레이터 생성하기

커스텀 데코레이터도 만들어 줍니다. 요청 발생 시, 요청 객체에 세팅해 두었던 queryRunnerManager를 가져오는 역할을 해줍니다.

직접 Request 객체에서 뽑아와도 무관하지만, 커스텀 데코레이터가 보다 깔끔하고 TransactionManager라는 이름을 붙여 트랜잭션 처리를 위한 데코레이터라고 명확하게 표현해 주면 좋을 것 같습니다.

 

export const TransactionManager = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const req = ctx.switchToHttp().getRequest();
    return req.queryRunnerManager;
  },
);

 

3. 컨트롤러에서 적용하기

위에서 생성한 인터셉터와 데코레이터를 컨트롤러에 적용시킵니다.

 

@Controller('orders')
export class OrdersController {
  constructor(private readonly ordersService: OrdersService) {}

  @Post()
  @UseInterceptors(TransactionInterceptor)
  async createOrder(
    @TransactionManager() transactionManager: EntityManager,
    @Body() body: CreateOrderRequestDto,
  ): Promise<Order> {
    return await this.ordersService.createOrder(transactionManager, body);
  }
}

 

@Injectable()
export class OrdersService {
  async createOrder(
    transactionManager: EntityManager,
    { itemsToOrder }: CreateOrderRequestDto,
  ): Promise<Order> {
    const order = await transactionManager.save(
      Order.create({
        name:
          itemsToOrder.length > 1
            ? `${itemsToOrder[0].name} 외 ${itemsToOrder.length - 1}건`
            : itemsToOrder[0].name,
        totalPrice: itemsToOrder.reduce(
          (result, { price }) => result + price,
          0,
        ),
      }),
    );

    await transactionManager.save(
      itemsToOrder.map(({ id, name, price }) =>
        OrderItem.create({
          orderId: order.id,
          productId: id,
          name,
          price,
        }),
      ),
    );

    return order;
  }
}

 

이로써 유스 케이스에 대한 서비스 로직에만 집중하여 작성할 수 있고, 중복 코드를 방지할 수 있습니다.

 

참고

NestJS - Interceptor

NestJS - Custum Decorator

Interceptor 사용하여 TypeORM Transaction 적용하기