새소식

반응형
Back-end/NestJS

[NestJS] NestJS 트랜잭션(Transaction) 관리하기(With TypeORM)

2024.02.17
  • -
반응형

1. 트랜잭션 with NestJS & TypeORM

데이터베이스 트랜잭션(Database Transaction)은 DBMS(Database Management System)내에서 수행되는 작업의 단위를 형상화하고, 다른 트랜잭션들과 독립되어 일관적이고 안전한 방식으로 처리되도록 하는 기술입니다. 

 

TypeORM에는 트랜잭션을 다루는 몇가지 다른 전략들이 존재합니다. 공식문서에서는 QueryRunner 클래스가 트랜잭션에 대한 완전한 제어를 할 수 있기 때문에 이를 사용할 것을 권장하고 있습니다.

 

먼저 우리는 일반적인 방식으로 클래스에 DataSource 객체를 주입해줍니다.

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

 

그런 다음 해당 객체를 통해 트랜잭션을 만들 수 있습니다. 아래 예제는 사용자 1과 사용자 2를 저장하는 간단한 작업들로 이루어진 트랜잭션입니다. 만약 사용자 1은 잘 저장했는데 사용자 2를 저장하던 도중 문제가 생기는 경우를 가정했을 때, 앞서 진행했던 사용자 1을 저장한 과정을 무르는 "rollback"이 이루어진 후에 데이터베이스와의 연결을 release 해줘야 합니다.

async createMany(users: User[]) {
  const queryRunner = this.dataSource.createQueryRunner();

  await queryRunner.connect();
  await queryRunner.startTransaction();
  try {
    await queryRunner.manager.save(users[0]);
    await queryRunner.manager.save(users[1]);

    await queryRunner.commitTransaction();
  } catch (err) {
    // since we have errors lets rollback the changes we made
    await queryRunner.rollbackTransaction();
  } finally {
    // you need to release a queryRunner which was manually instantiated
    await queryRunner.release();
  }
}

dataSource 인스턴스는 오로지 QueryRunner를 생성하는데만 사용됩니다.

 

하지만 이 클래스를 테스트하는 것은 DataSource 객체 전체를 mocking 하도록 요구합니다(그런데 datasource 객체에는 일부 메소드도 포함되어 있습니다).

 

따라서 QueryRunnerFactory와 같은 helper factory class를 사용하고 트랜잭션을 유지하는데 필요한 제한적인 메소드 세트와 함께 인터페이스를 정의합니다.

 

또 다른 방식으로, DataSource 객체의 트랜잭션 메서드로 callback 스타일을 사용할 수 있습니다.

async createMany(users: User[]) {
  await this.dataSource.transaction(async manager => {
    await manager.save(users[0]);
    await manager.save(users[1]);
  });
}

 

 

2. 현실 상황에서 발생할 수 있는 예제를 통해 보는 트랜잭션

그럼 이제 좀 더 현실적인 상황에서 사용될만 한 예시를 통해 트랜잭션을 구현해보도록 하겠습니다.

 

상황

사용자가 주문을 하려고 하는 상황을 가정합시다. 여기에는 세 개의 데이터베이스 테이블이 존재합니다:

  • products, orders, items

 

당연하게도 products 테이블은 우리 앱의 모든 상품을 포함한 테이블입니다. orders 테이블은 주문 번호, 날짜와 같이 주문과 관련된 정보를 저장합니다. 그리고 마지막으로 items 테이블은 어느 items/products가 어떤 order에서 구매되었는지에 대한 정보를 해당 주문의 수량과 함께 저장합니다.

 

이 데이터베이스를 시각화하면 다음과 같습니다.

 

위 사진에서 볼 수 있듯이 Products 테이블에는 2개의 상품이 들어있고, 아직 아무런 주문이 이루어지지 않았다고 가정하여 나머지 두 테이블은 비어있습니다. 만약 우리가 `POST /api/place-order`와 같은 엔드포인트로 요청을 보내서 주문을 한다고 가정하면, 이 엔드포인트에서는 데이터베이스에 2개의 write 쿼리를 보낼 것입니다.

  • 한 쿼리는 하나의 order 객체를 만들기 위해 orders 테이블에 주문 정보를 insert 합니다.
  • 또 다른 한 쿼리는 item/product 정보를 삽입합니다.
    • 이 쿼리는 해당 주문에서 어떤 items를 구매했는지 저장하기 위해서 필수적입니다.

 

이러한 두 쿼리들이 모두 잘 작동한다면 우리의 데이터베이스는 아래 모습과 같이 바뀔 것입니다.

orders 테이블에 id가 100인 order 객체 하나가 있고, items 테이블에 해당 주문과 관련된 2개의 다른 객체가 있습니다. 하나는 컴퓨터이고 그 컴퓨터의 수량도 1개입니다. 두 번째는 마우스이고 수량은 3개입니다. 테이블에 적용된 사항을 보니 모든 것이 잘 작동한 것 같습니다.

 

그런데 만약 첫 번째 날린 쿼리는 성공해서 order 객체를 잘 생성했는데, 두 번째 쿼리가 그 주문에 대한 item들을 삽입하는 과정에서 실패했다면 어떻게 될까요?

 

그러한 경우에 API 호출은 fail을 알릴 것이고 그 에러 응답이 다시 클라이언트에게 보내질 것입니다. 그런데 현재 DB에는 아무런 items도 없이도 order 객체가 orders 테이블에 여전히 남겨져 있을 것이기 때문에 DB에는 일관되지 않은 데이터가 존재하게 될 것입니다.

 

아래 이미지가 위와 같은 경우가 발생했을 때의 상황을 보여줍니다.

 

이러한 경우는 API 호출이 실패한 것이기 때문에 DB에 Order 객체가 없어야 우리가 원하는 모습입니다. 그래서 이 실패한 API 호출이 어떠한 테이블이나 레코드를 뒤집어 놓지 않은 상태여야 합니다. 이것이 바로 트랜잭션이 필요한 이유입니다.

 

트랜잭션을 생성하자마자, 어떠한 테이블도 우리가 COMMIT을 수행하기 전까지는 데이터베이스에 영구적으로 write 작업을 수행하지 않습니다. 트랜잭션이 COMMIT을 하지 않았다면 그 쿼리들은 마치 전혀 실행되지 않은 것처럼 되어있어야 하는 것입니다.

 

실행 중인 모든 write 쿼리가 성공했다고 확신할 때만 COMMIT 해야 합니다. 반대로 만약 무언가 잘못되어 해당 API 호출에서 수행했던 모든 수정사항을 다시 되돌려야 한다면, 트랜잭션의 시작 이후 이루어졌던 모든 수정사항을 되돌리는 ROLLBACK을 수행하면 됩니다.

 

그러면 `POST /api/place-order`엔드포인트에서 트랜잭션을 시작하고, 트랜잭션 내에서 두 개의 write 쿼리를 실행하려고 시도할 것입니다. 만약 어떠한 예외도 발생하지 않았다면, 모든 write 작업을 지속하는 트랜잭션을 DB에게 commit 합니다.

 

그래서 트랜잭션의 규칙이라고 한다면 "단일 라우트에서 여러 개의 다른 write 쿼리를 실행해야 하는 모든 경우에, 트랜잭션 안으로 그것들을 전부 감싸야 하는 것"이라고 할 수 있겠습니다.

 

트랜잭션을 구현하는 다른 방식들

이제 이후에서 트랜잭션을 구현하는 두 가지 다른 방식에 대해서 알아볼 것입니다.

  1. (simple 버전) 트랜잭션을 시작하는 query runner 객체를 사용한 단순한 방식으로, 이 방식에서는 모든 write 쿼리들이 해당 트랜잭션에서 모든 write 작업을 감싸도록 보장하는 query runner 객체를 사용하여 실행됩니다.
  2. 1번보다 더 심화된 방식으로, 한 트랜잭션에서 특정 라우트의 모든 쿼리들을 자동으로 감싸는 request-scopeRepositoryInterceptor를 사용하는데, 이렇게 하면 매번 query runner를 직접 생성하지 않아도 됩니다.

 

simple 버전

simple 버전의 방식을 먼저 알아보겠습니다. 1. query runner를 생성하고, 2. 트랜잭션을 시작하며, 3. query runner에 대해 모든 쿼리들을 실행합니다. 두 가지의 Order와 Item에 해당하는 TypeORM entity가 있다고 생각해 봅시다. 트랜잭션이 없다면 우리는 아래처럼 쿼리를 실행해야 했습니다.

constructor(
  // first inject repositories
  @InjectRepository(Order)
  private orderRepository: Repository<Order>,
  @InjectRepository(Item)
  private itemRepository: Repository<Item>
) {}

@Post()
async routeHandler() {
  // then make queries
  await this.orderRepository.save({
    /* order data */
  });
  await this.itemRepository.save({
    /* Item data */
  });

  // ...
}

 

하지만 앞서 말했든 이는 좋지않은 방식이며, 그렇기 때문에 위 2개의 쿼리를 하나의 트랜잭션 안에 감쌀 필요가 있습니다.

 

아래는 query runner 객체를 사용하여 트랜잭션을 구현한 예시입니다.

constructor(
  // inject data source
  private dataSource: DataSource
) {}

@Post()
async routeHandler() {
  const queryRunner = this.dataSource.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();
  try {
    await queryRunner.manager.save(Order, {
      /* order data */
    });
    await queryRunner.manager.save(Item, {
      /* item data */
    });
    await queryRunner.commitTransaction();
  } catch (e) {
    await queryRunner.rollbackTransaction();
    throw e;
  } finally {
    await queryRunner.release();
  }
  
  // ...
}

여기서 몇가지 중요한 포인트가 있습니다.

  • queryRunner.connect() 메소드는 connection pool로부터 connection을 예약합니다.
  • queryRunner.startTransaction()은 트랜잭션을 생성하는 비동기 메소드입니다.
  • 트랜잭션을 개시하는 connection에 대해 데이터베이스 쿼리들을 수행하도록 하는 ` queryRunner.manager` 객체를  entity manager에 접근하는 용도로 사용합니다. 이는 우리가 entity manager 객체에게 보낸 모든 write 쿼리들이 트랜잭션 내부에 감싸질 것임을 의미합니다. entity manager는 save, find, findOne 등과 같은 메소드를 제공합니다.
    또한 entity manager를 사용할 때, 첫 번째 인자는 entity manager가 어떤 테이블에 삽입할 것인지를 알리기 위한 entity class가 되어야 합니다.
  • 만약 어떠한 에러 없이 모든 쿼리들을 수행했다면, DB로 write 작업 결과를 반영하도록 트랜잭션을 commit 하기 위한  queryRunner.commitTransaction() 메소드를 호출합니다.
  • 만약 에러가 발생했다면, queryRunner.rollbackTransaction()을 호출하여 모든 변화를 되돌리고 NestJS에 의해 처리되는 에러를 던집니다.
  • 마지막으로 finally 블록에서는 queryRunner.release() 메소드를 호출하여 connection pool로 예약된 connection을 반환합니다. 이 release()하는 단계 굉장히 중요합니다. 만약에 예약한 connection을 release 하지 않으면 데이터베이스 연결이 계속 지속되는 것이기 때문에 성능 문제를 야기할 것이고 연결은 되어있는데 어떠한 작업이 이루어 지지 않아 계속해서 연결을 확인하려고 시도할 것입니다.

 

2. 트랜잭션 관리 심화버전

simple 버전을 통해 어떻게 트랜잭션이 이루어지는 구현해보았으니, 이제 우리는 repository 패턴과 request-scoped의 proivder를 사용한 트랜잭션을 구현해보도록 할 것입니다.

 

2-1. 데이터베이스 디자인 다시보기

위 테이블에는 관계가 존재합니다.

  • order와 item 테이블 사이의 one-to-many 관계(하나의 order가 여러 item을 가질 수 있음)
  • item과 product 사이의 one-to-many 관계(하나의 product가 여러 item를 가질 수 있음)

 

2-2. 파일 구조

/src
    /common
        base-repository.ts
        transaction.interceptor.ts
    /modules
        /items
            /dto
                create-item.dto.ts
            items.entity.ts
            items.module.ts
            items.repository.ts
            items.service.ts
        /orders
            orders.controller.ts
            orders.entity.ts
            orders.module.ts
            orders.repository.ts
            orders.service.ts
        /products
            products.controller.ts
            products.entity.ts
            products.module.ts
            products.repository.ts
            products.service.ts
    app.module.ts
    main.ts

위와 같은 파일 구조에는 3개의 다른 모듈이 존재합니다.

  •  Items: 하나의 주문에 대한 items을 추가하는 역할
  • Orders: 주문을 관리하는 역할
  • Products: 데이터베이스에서 상품을 가져오는 역할

 

2-3. Common 폴더

각 모듈에 대한 세부적인 내용을 알아볼 것이지만, 그 전에 common 폴더의 파일이 하는 역할을 알아봅시다.

 

// transaction.interceptor.ts

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Request } from 'express';
import { Observable, catchError, concatMap, finalize } from 'rxjs';
import { DataSource } from 'typeorm';

export const ENTITY_MANAGER_KEY = 'ENTITY_MANAGER';

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

  async intercept(
    context: ExecutionContext,
    next: CallHandler<any>,
  ): Promise<Observable<any>> {
    // request 객체를 가져옵니다.
    const req = context.switchToHttp().getRequest<Request>();
    // transaction 시작
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();
    // attach query manager with transaction to the request
    req[ENTITY_MANAGER_KEY] = queryRunner.manager;

    return next.handle().pipe(
      // 라우트 핸들러가 성공적으로 완료될 때 concatMap이 호출됩니다.
      concatMap(async (data) => {
        await queryRunner.commitTransaction();
        return data;
      }),
      // 라우트 핸들러가 예외를 던질 떄 catchError가 호출됩니다.
      catchError(async (e) => {
        await queryRunner.rollbackTransaction();
        throw e;
      }),
      // 항상 마지막에 실행되는 부분으로 이곳에서 release가 이루어져야 어떠한
      // 상황에서도 release가 보장됩니다.
      finalize(async () => {
        await queryRunner.release();
      }),
    );
  }
}

 

이 Interceptor가 특정 라우트에 적용되는 시점이 언제든 상관없이, 트랜잭션을 시작하고 해당 트랜잭션이 갖는 entity manager를 'ENTITY_MANAGER'라는 key와 함께 req에 첨부합니다.

  • 또한 오타를 방지하기 위해 해당 값을 상수로 보내주도록 합니다.

 

그런 다음, next.handle()로 실제 요청 핸들러 메소드를 호출합니다. pipe() 메소드는 3개의 인자를 가집니다.

  1. 요청의 성공적 처리 시 응답 데이터를 처리할 map() 타입 또는 tap() 함수: 여기서 트랜잭션을 COMMIT하고 데이터를 있는 그대로 반환합니다.
  2. 에러 핸들러: 컨트롤러가 예외를 던질 때 실행합니다. 이 함수에서 API 호출이 실패하고 간단한 에러를 발생하므로 트랜잭션을 ROLLBACK 합니다.
  3. 항상 실행되는 마지막 함수: 보통 connection pool로부터 예약한 connection을 release할 때 사용합니다.

 

다음으로 common 폴더에서 중요한 클래스는 base repository입니다.

// base-repository.ts
import { Request } from 'express';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { ENTITY_MANAGER_KEY } from './transaction.interceptor';

export class BaseRepository {
  constructor(private dataSource: DataSource, private request: Request) {}

  protected getRepository<T>(entityCls: new () => T): Repository<T> {
    const entityManager: EntityManager =
      this.request[ENTITY_MANAGER_KEY] ?? this.dataSource.manager;
    return entityManager.getRepository(entityCls);
  }
}

 

여기서 BaseRepository 클래스는 데이터베이스 작업을 수행하는 데 사용되는 공통 레포지토리 기능을 제공합니다. 이 클래스 내의 getRepository 메서드는 TypeORM의 Repository 인스턴스를 생성하는 기능을 수행 합니다. 이때, 트랜잭션이 적용된 요청과 그렇지 않은 요청을 모두 처리할 수 있도록 설계되어 있습니다.

 

이 클래스는 모든 repository들에 대한 부모 클래스로서 생성자로부터 2개의 인자를 받습니다.

  1. dataSource: 어떠한 트랜잭션도 가지지 않는 connection pool의 simple connection입니다.
  2. request: request 객체입니다. repository 클래스를 request scope에서 정의할 것이기 때문에 request 객체에 대한 직접적인 접근을 가집니다. 이곳에 ENTITY_MANGER의 key로 접근할 수 있는 값이 있는 경우 해당 값은 트랜잭션으로 감싸진 entity manager의 repository입니다.

 

해당 클래스에 보면 protected 메소드(getRepository)가 있는 것을 볼 수 있는데, 이 메소드의 작업은 request 객체가 만약 존재한다면 해당 객체로부터 entity manager를 가져오는 일입니다. 그리고 이 request 객체는 우리는 앞선 interceptor를 사용한 경우에만 request로부터 entity manager를 가져올 수 있다는 것을 알고있습니다.

 

만약 entity manager가 request 객체에 없다면, 이는 해당 작업이 어떤 트랜잭션에서 실행되도록 되어 있지 않다는 의미이기 때문에, dataSource 객체로부터 entity manager를 가져옵니다. 이후 entity manager를 가져온 다음에는, 이 entity manager의 getRepository() 메소드를 호출할 때 적절한 entity class를 전달하여 해당 repository 인스턴스를 가져오게 됩니다.

 

간단히 말해, 이 메소드는 repository 인스턴스를 제공하는데, 트랜잭션 Interceptor가 사용된 경우엔 해당 repository는 트랜잭션 내부에서 쿼리를 실행하게끔 처리되는 것이고 트랜잭션 interceptor가 사용되지 않은 경우에는, 트랜잭션 외부에서 쿼리를 실행하게 되는 것입니다.

 

2-4. Repositories

다음은 repository 클래스입니다. repostory 클래스들의 역할은 일종의 데이터베이스 상호작용을 수행하는 것입니다. 데이터베이스 커뮤니케이션을 또 다른 레이어로부터 추출함으로써 얻는 이점은 더 깔끔한 디자인을 가질 수 있다는 점과, 단위 테스트를 더 쉽게 만든다는 점입니다. 따라서 해당 프로젝트를 더 잘 이해하기 위해서 아래 3가지 repository 클래스를 살펴보아야 합니다.

 

// products.repository.ts

import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { BaseRepository } from 'src/common/base-repository';
import { DataSource } from 'typeorm';
import { Product } from './products.entity';

@Injectable({ scope: Scope.REQUEST })
export class ProductsRepository extends BaseRepository {
  constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) {
    super(dataSource, req);
  }

  async getAllProducts() {
    return await this.getRepository(Product).find();
  }
}

 

// items.repository.ts

import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { BaseRepository } from 'src/common/base-repository';
import { DataSource } from 'typeorm';
import { Item } from './items.entity';
import { CreateItemDto } from './dtos/create-item.dto';

@Injectable({ scope: Scope.REQUEST })
export class ItemsRepository extends BaseRepository {
  constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) {
    super(dataSource, req);
  }

  // Create multiple items
  async createItems(orderId: number, data: CreateItemDto[]) {
    const items = data.map((e) => {
      return {
        order: { id: orderId },
        product: { id: e.productId },
        quantity: e.quantity,
      } as Item;
    });

    await this.getRepository(Item).insert(items);
  }
}

 

// orders.repository.ts

import { Inject, Injectable, Scope } from '@nestjs/common';
import { Request } from 'express';
import { BaseRepository } from 'src/common/base-repository';
import { DataSource } from 'typeorm';
import { Order } from './orders.entity';
import { REQUEST } from '@nestjs/core';

@Injectable({ scope: Scope.REQUEST })
export class OrdersRepository extends BaseRepository {
  constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) {
    super(dataSource, req);
  }

  async getOrders() {
    return await this.getRepository(Order).find({
      relations: {
        items: {
          product: true,
        },
      },
    });
  }

  async createOrder(orderNo: string) {
    const ordersRepository = this.getRepository(Order);

    const order = ordersRepository.create({
      date: new Date(),
      orderNo: orderNo,
    });
    await ordersRepository.insert(order);

    return order;
  }
}

 

코드에서 볼 수 있듯이, 이러한 모든 repository들은 base repository를 상속하고, 모든 생성자에는 dataSourcerequest 객체를 부모 클래스의 생성자에게 전달하는 super() 호출이 있습니다. 또한 이 repository들은 모두 request scope인데, 이는 Nest가 request 객체를 주입할 수 있는 모든 요청마다 초기화된다는 의미이기 때문에, 만약 활성 상태인 트랜잭션이 요청에 존재하는지를 알 수 있어 request scope으로 지정된 것입니다.

 

products repository는 모든 product를 가져오는 하나의 메서드만을 가지고 있습니다. 우리는 테스트 데이터를 일일히 만들어줄 것이기 때문에 모든 CRUD 작업을 가질 필요는 없습니다.

 

items repository는 단일 order에 속한 여러 개의 items을 생성하는 이 역시 하나의 메서드만을 갖습니다. map 함수를 이용하여 dto를 가지고 적절히 포맷팅된 item 객체를 배열로 파싱하고 그렇게 만들어진 Item[]을 데이터베이스 저장합니다.

 

아래는 CreateItemDto의 모습입니다.

import { IsNumber } from 'class-validator';

export class CreateItemDto {
  @IsNumber()
  productId: number;

  @IsNumber()
  quantity: number;
}

 

마지막으로, orders repository는 모든 주문을 가져올 수 있고 orderNo을 가진 주문을 생성할 수도 있습니다.

 

2-.5 Entities

이제 entity에 대해서 이야기 할 차례입니다. 데이터베이스 디자인은 아래와 같았습니다.

 

Entity들은 TypeORM에서 데이터베이스 테이블들의 표현입니다. 그리고 위 데이터베이스 테이블들을 반영하는 방식으로 entity를 정의해야 합니다. 아래는 entity 클래스들입니다.

 

// items.entity.ts

import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { Order } from '../orders/orders.entity';
import { Product } from '../products/products.entity';

@Entity()
export class Item {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  quantity: number;

  @ManyToOne((type) => Order, (order) => order.items)
  order: Order;

  @ManyToOne((type) => Product, (product) => product.items)
  product: Product;
}

 

// orders.entity.ts

import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { Item } from '../items/items.entity';

@Entity()
export class Order {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  orderNo: string;

  @Column({ type: 'datetime' })
  date: Date;

  @OneToMany((type) => Item, (item) => item.order)
  items: Item[];
}

 

// products.entity.ts

import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { Item } from '../items/items.entity';

@Entity()
export class Product {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @Column()
  price: number;

  @OneToMany((type) => Item, (item) => item.product)
  items: Item[];
}

 

위 코드에서 볼 수 있듯이, 각각 하나씩 요구되는 컬럼들과 @ManyToOne, @OneToMany 데코레이터를 사용하여 각 엔티티들을 서로서로 연관짓는 컬럼들을 정의함으로써 모든 엔티티들을 정의하였습니다.

 

 

엔티티까지 정의했으니 이제 다른 요소인 컨트롤러나 서비스를 각 모듈마다 정의해주면 됩니다.

 

2-6. Controllers & Services

Controller는 HTTP 요청과 응답을 처리하는 역할을 하고, service는 실제 비지니스 로직을 포함하도록 되어있습니다.

그래서 비지니스 로직이 컨트롤러로부터 떨어진채로 유지될 수 있습니다.

 

2-7. Product Module의 controller/service

// products.controller.ts

@Controller('products')
export class ProductsController {
  constructor(private productsService: ProductsService) {}

  @Get()
  async getAllProducts() {
    return await this.productsService.getAllProducts();
  }
}

 

// products.service.ts

@Injectable()
export class ProductsService {
  constructor(private productsRepository: ProductsRepository) {}

  async getAllProducts() {
    return await this.productsRepository.getAllProducts();
  }
}

 

product controller는 이 모듈에서 정의된 하나의 엔드포인트만을 갖습니다.

  • GET /products

이 엔드포인트는 우리가 모든 products를 가져올 수 있도록 합니다.(fetch)

 

트랜잭션을 테스트하는 과정에서는 모든 엔드포인트가 필요하지 않기 때문에 따로 구현하지는 않았지만 실제로는 더 많은 엔드포인트들이 필요할 것입니다.

 

해당 컨트롤러는 존재하는 모든 products를 가져오기 위해 product repository를 호출하는 getAllProducts 서비스 메서드를 호출합니다.

 

2-8. Items Module의 controller/service

item 모듈에서는, HTTP 엔드포인트를 통해 item.service.ts 내의 기능을 노출시키고 싶지 않을 것이기 때문에 컨트롤러를따로 갖지 않습니다. Items service가 orders service에서 대신 사용될 것입니다.

 

아래는 items service의 구현입니다.

@Injectable()
export class ItemsService {
  constructor(private itemsRepository: ItemsRepository) {}

  async createItems(orderId: number, items: CreateItemDto[]) {
    await this.itemsRepository.createItems(orderId, items);
  }
}

그저 orderId와 item의 배열을 가지고 앞서 논의한 items repository로부터 createItems 메소드를 호출하여 item 테이블에 그것들을 전부 생성합니다.

 

2-9. Orders Module의 contorller/service

 

// orders.controller.ts

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

  @Get()
  async getOrders() {
    return await this.ordersService.getOrders();
  }

  @Post()
  @UseInterceptors(TransactionInterceptor)
  async createOrder(
    @Body(new ParseArrayPipe({ items: CreateItemDto }))
    data: CreateItemDto[],
  ) {
    return await this.ordersService.createOrder(data);
  }
}

 

orders 컨트롤러는 두 개의 엔드포인트를 갖습니다.

  • GET /orders
  • POST /orders

이러한 엔드포인트들은 새로운 order를 생성할 뿐만 아니라 그 order들을 가져오는 데도 사용될 것입니다. 비지니스 로직을 이해하기 위해 호출되는 service 메소드를 한 번 살펴봅시다.

@Injectable()
export class OrdersService {
  constructor(
    private ordersRepository: OrdersRepository,
    private itemsService: ItemsService,
  ) {}

  async getOrders() {
    return await this.ordersRepository.getOrders();
  }

  async createOrder(items: CreateItemDto[]) {
    const orderNo = `ORD_${randomUUID()}`;
    const order = await this.ordersRepository.createOrder(orderNo);
    await this.itemsService.createItems(order.id, items);
    return order;
  }
}

 

getOrders 메소드는 꽤 단순합니다. 그저 ordersRepository의 getOrders 메소드를 호출함으로써 모든 주문을 데이터베이스로부터 가져옵니다.

 

하지만 재밌는 점은 우리가 OrderService 클래스의 생성자에서 items service 클래스의 인스턴스를 주입하고 있다는 점입니다.

 

createOrder 메소드를 보면 특정 order에 대해 저장할 items의 리스트를 인자로 갖습니다. 먼저 무작위의 orderId를 생성하고, 주문을 만든다음 그 주문에 달린 item들을 생성합니다. 하지만 order와 item 객체를 둘 다 만드는 것은 적어도 2개의 쿼리가 필요하게 되는데 그래서 이것이 바로 이 엔드포인트에는 트랜잭션이 필요한 이유입니다.

 

아까 그냥 지나갔던 OrdesController를 자세히 보면, @UseInterceptor(TransactionInterceptor)로 createOrder 라우트가 데코레이팅 되어 있는 것을 볼 수 있습니다.

  // orders.controller.ts
  ...
  @Post()
  @UseInterceptors(TransactionInterceptor)
  async createOrder(
  ...

 

이는 모든 write 작업이 특정 라우트에서 트랜잭션으로 감싸질 것을 보장합니다.

 

repository가 먼저 request 객체를 살펴본 후에, 활성 트랜잭션이 붙어있는지 확인하고, 트랜잭션이 있다면 해당 트랜잭션의 entity manager를 사용하여 모든 작업을 트랜잭션으로 감싸는 행동을 보장하는 것입니다.

 

이러한 방식으로 인해 createOrder에서 보낸 2개의 쿼리는 한 트랜잭션으로 감싸지게 됩니다.

 


여기서 알아야 할 것이 하나 있습니다. 전체 모듈을 import 하지 않으면 또 다른 모듈로부터 service를 주입할 수 없다는 사실입니다. 이러한 셋업들이 잘 동작하려면 items 모듈은 해당 services를 export 하고 orders 모듈은 해당 service를 사용할 수 있도록 items 모듈을 import 해야 합니다. 

@Module({
  providers: [ItemsService, ItemsRepository],
  exports: [ItemsService], // items module exports the service
})
export class ItemsModule {}
@Module({
  imports: [ItemsModule], // orders module imports items module
  controllers: [OrdersController],
  providers: [OrdersService, OrdersRepository],
})
export class OrdersModule {}

 

 

2-10. App Module

마지막으로 해야할 일은 우리가 앞서 만든 모든 모듈들을 nest 애플리케이션이 갖는 root 모듈인 App 모듈에 등록해주는 일입니다.

 

여기서 애플리케이션 시작 시 실행하는 메서드(onModuleInit())를 호출하여 SQL 스크립트 작성을 통해 임시의 초기 데이터 products를 생성하는 간단한 쿼리문을 작성하여 초기 데이터를 넣어주는 작업을 수동으로 수행하지 않아도 되도록 하는 작업을 추가하였습니다.

@Module({
  imports: [
    ConfigModule.forRoot(),
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: process.env.HOST,
      username: process.env.DB_USER,
      password: process.env.DB_PASS,
      database: process.env.DATABASE,
      entities: [Product, Order, Item],
      synchronize: true,
      logging: true, // log all the queries
      dropSchema: true, // start with a clean db on each run, DO NOT USE FOR PRODUCTION
    }),
    ProductsModule,
    OrdersModule,
    ItemsModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule implements OnModuleInit {
  constructor(private dataSource: DataSource) {}

  async onModuleInit() {
    await this.dataSource.query(`
        insert into product (title, price) values
            ('Computer', 1000), ('Mouse', 19);
    `);
  }
}

 

해당 쿼리문은 애플리케이션이 시작하자마자 computer와 mouse 레코드를 Product 테이블에 삽입합니다.

 

또한 모든 데이터베이스 설정을 TypeOrmModule.forRoot 호출에서 해주었습니다.

 

결과

쿼리가 실제로 트랜잭션 안에서 동작하는지 확인하기 위해 HTTP 요청을 보내봄으로써 엔드포인트에 대한 테스트를 진행해 봅시다.

 

요청 형식은 다음과 같습니다.

POST http://localhost/orders
Content-Type: application/json

[
    {
        "productId": 1,
        "quantity": 3
    },
    {
        "productId": 2,
        "quantity": 5
    }
]

 

다음은 응답 결과입니다.

{
  "orderNo": "ORD_1dd3570d-b82e-4a8d-bb97-8a3b42766302",
  "date": "2023-09-17T21:50:06.434Z",
  "id": 1
}

 

또한 console log 창을 보면, 다음과 같이 실행된 쿼리들을 볼 수 있는데, 이는 우리가 앞서 TypeOrmModule 설정 부분에 logging 속성을 true로 하여 출력된 것입니다.

START TRANSACTION
INSERT INTO `order`(`id`, `orderNo`, `date`) VALUES (DEFAULT, ?, ?) -- PARAMETERS: ["ORD_1dd3570d-b82e-4a8d-bb97-8a3b42766302","2023-09-17T21:50:06.434Z"]
INSERT INTO `item`(`id`, `quantity`, `orderId`, `productId`) VALUES (DEFAULT, ?, ?, ?), (DEFAULT, ?, ?, ?) -- PARAMETERS: [3,1,1,5,1,2]
COMMIT

 

쿼리를 보니 제대로 동작하는 것을 볼 수 있습니다. 완벽합니다!

 

이로써 실제 트랜잭션을 언제 사용하는지와 그러한 상황에서 트랜잭션을 어떻게 사용해야 하는지에 대해서 알아보고 구현까지 해보는 시간을 가져봤습니다.

 

Injection Chain 이해하기

마지막으로 끝내기 전에 알아두면 좋을 것 같은 부분이 있어 이에 대한 언급을 끝으로 포스팅을 마치도록 하겠습니다.

 

앞서 repository클래스들은 request-scope으로 지정해주었었는데, request-scoped인 providers는 Injection Chain에서 전파가 된다는 사실을 우리는 인지하고 있어야 합니다.

 

예를 들어, order 모듈의 injection chain을 살펴봅시다.

  • OrdersController -> OrdersService -> OrdersRepository

OrderController는 OrdersService에 의존하고 OrdersService는 OrdersRepository에 의존하는 식으로 injection chain이 형성되어 있습니다. 하지만 OrdersRepository가 request scope이라는 사실 때문에 그것에 의존하는 다른 모든 provider들까지 request scope으로 만들어버리는 일이 발생합니다.

 

그러한 경우에 OrdersController와 OrdersService 역시 request scoped가 되어버리는 것입니다.

 

이러한 상황은 애플리케이션의 거의 모든 provider를 request scoped로 만들게 될 수도 있습니다. 이는 약간의 성능 오버헤드가 있긴 하겠지만 대부분의 경우에 그것은 무시할만합니다. 

 

하지만 이는 알아둔다면 추후에 성능 개선을 할 때, 가장 먼저 고려해볼 만한 부분이기 때문에, 충분히 언급할만한 가치가 있다고 생각하여 이렇게 마지막 부분에 넣게 되었습니다.

 

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.