NestJs Transaction Decorator 만들기

이우길·2023년 2월 13일
13

NestJs

목록 보기
19/20
post-thumbnail

Transaction Decorator 만들기

현재 글은 Postgres + TypeORM 기준으로 작성되었습니다. 하지만 아래 개념을 이용하여 다른 ORM에도 적용이 가능합니다. (MikroORM 등등...)

테스트 코드


Goal

  • AOP 개념을 이용하여 Transaction 데코레이터 만들기

개요

TypeORM을 이용하여 Transaction을 이용하는 방법은 이전 글에서도 작성하였듯 대표적으로 2가지가 있다. (3가지 이지만 Nest 공식 Doc에서는 2가지 방법을 추천하고 있음.)

  1. QueryRunner를 이용하여 Transaction

  2. transaction method를 이용

필자는 주로 QueryRunner를 이용하여 Transaction을 사용하는 방법을 이용하고 있었다. 그러다 보니 Transaction을 사용하는 곳에 코드는 거의 필수적으로 아래와 같은 모양을 띄게 되었다.

async saveWithQueryRunner(user: Users){
  const queryRunner = this.connection.createQueryRunner();

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

  try {
    const saved = await queryRunner.manager.save(user);
    //...
    await queryRunner.commitTransaction();
  } catch (e) {
    await queryRunner.rollbackTransaction();
  } finally {
    await queryRunner.release();
  }
}

위와 같은 코드들이 반복되니 method들이 크기가 커지기도 하고 try 절에 들어가는 비지니스 로직을 한눈에 보기 어려운 부분들도 발생하게 되었다.

게다가 Layer별 분리가 되지 않아 Service Layer에서 Query를 작성하는 등등에 문제가 발생하였다... (CustomRepository를 이용하는 방법을 택하지 않는 이상 동일하며 그곳에서도 try~catch는 동일할 것이다.)

그래서 이러한 부분들을 어떻게 처리할 수 있을까 고민하다 이전 NestJS MeetUp에서 발표자료를 참고하여 AOP를 이용한 Transaction처리를 하기로 결정하였다. (spring에서 @Transactional 어노테이션처럼 사용하고 싶엇다..)


cls-hooked

Node진영에서는 single thread이기 때문에 spring boot에서의 thread local같은 개념이 없다.

thread local : 간단히 설명하자면 thread 정보를 key로 하여 값을 저장해두며 같은 thread에서만 접근이 가능한 공간이다.

그래서 Node진영에서는 thread local과 비슷한 성격을 띄는 cls-hooked라는 라이브러리를 이용한다.

cls-hooked는 요청이 들어올 때 마다 Namespace라는 곳에 context를 생성하여 해당 요청만 접근할 수 있는 공간을 만들어준다. 그리고 요청이 끝나게 되면 finally 구문에서 해당 context를 닫아준다. 이를 이용하여 요청이 들어오면 해당 요청에서만 사용할 entityManager를 생성하여 Namespace에 넣어 줄 것이다.

// @ cls-hooked
Namespace.prototype.run = function run(fn) {
  let context = this.createContext();
  this.enter(context);
  try {
    // ...
  } catch (exception) {
    throw exception;
  } finally {
    // ...
    this.exit(context);
  }
};

흐름도

Transaction 개요 : NestJS MeetUp


이론적으로 위와 같이 요청이 들어오면 Namespace를 생성해준 후 Namespace안에서 Transaction을 처리하고 요청이 완료되면 Namespace를 닫아주면 된다.


간단한 사용 후기

직접 사용해보니 요청이 들어오면 Namespace 존재 여부를 확인 후 없으면 생성하고 있으면 존재하는 Namespace를 사용하면 된다.

요청 마다 Namespace를 생성하게 되면 기존 Namespace에 생성된 context가 없어지고 새로운 Namespace로 덮어쓰여지기 때문에 요청을 처리 중인 context가 사라져 자신만의 context를 찾지 못하는 문제가 발생하게 된다.


Transaction 데코레이터 작성

Transaction 데코레이터를 작성하는 순서는 아래와 같다.

  1. Namespace를 생성 후 EntityManager를 심어주는 Middleware

  2. Namespace에 있는 EntityManager에 접근할 수 있는 헬퍼

  3. Transaction Decorator


Namespace를 생성 후 EntityManager를 심어주는 Middleware

Namespace를 미들웨어에서 생성하는 이유는 NestJs 요청 라이프사이클을 보면 알 수 있듯이 제일 먼저 요청이 거치게 되는 곳이다.


Middleware에서 Namespace가 존재 유무를 판단하여 생성 혹은 가져와 해당 NamespaceEntityManager를 넣어준다. (이 때 EntityManagerDI받게 되는데 ORM 모듈을 appModule에 등록해놓았기 때문에 주입이 가능한 것이다.)

@Injectable()
export class TransactionMiddleware implements NestMiddleware {
  constructor(private readonly em: EntityManager) {} // 1

  use(_req: Request, _res: Response, next: NextFunction) {
    const namespace = getNamespace(PYC_NAMESPACE) ?? createNamespace(PYC_NAMESPACE);
    return namespace.runAndReturn(async () => {
      Promise.resolve()
        .then(() => this.setEntityManager()) // 2
        .then(next); // 3
    });
  }

  private setEntityManager() {
    const namespace = getNamespace(PYC_NAMESPACE)!;
    namespace.set<EntityManager>(PYC_ENTITY_MANAGER, this.em);
  }
}

  1. ORM Module에서 export 하고 있는 EntityManager를 주입받는다.

  2. 주입받은 EntityManagerNamespace에 넣어준다. 이 때 넣어준 EntityManager는 요청의 context에 들어가게 된다.

  3. 미들웨어 다음에 실행될 함수를 실행시켜준다.


Namespace에 있는 EntityManager에 접근할 수 있는 헬퍼

헬퍼의 역할은 단순하다. Namespaceactive상태인지 확인하고 해당 Namespace에서 현재 요청이 접근할 수 있는 context에 접근하여 EntityManager를 꺼내오는 역할을 한다.

@Injectable()
export class TransactionManager {
  getEntityManager(): EntityManager {
    const nameSpace = getNamespace(PYC_NAMESPACE);
    if (!nameSpace || !nameSpace.active) throw new InternalServerErrorException(`${PYC_NAMESPACE} is not active`);
    return nameSpace.get(PYC_ENTITY_MANAGER);
  }
}

Transaction Decorator

이제 각 Method 위에 붙일 데코레이터를 작성하면 된다. (데코레이터 작성법에 대한 것은 현재 글에서 서술하지 않겠다. 공식 문서를 참조하여 작성하였음. Typescript - Method Decorator)

export function Transactional() {
  return function (_target: Object, _propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<any>) {
    // 1
    const originMethod = descriptor.value;

    // 2
    async function transactionWrapped(...args: unknown[]) {
      // 3
      const nameSpace = getNamespace(PYC_NAMESPACE);
      if (!nameSpace || !nameSpace.active) throw new InternalServerErrorException(`${PYC_NAMESPACE} is not active`);

      // 4
      const em = nameSpace.get(PYC_ENTITY_MANAGER) as EntityManager;
      if (!em) throw new InternalServerErrorException(`Could not find EntityManager in ${PYC_NAMESPACE} nameSpace`);

      // 5
      return await em.transaction(async (tx: EntityManager) => {
        nameSpace.set<EntityManager>(PYC_ENTITY_MANAGER, tx);
        return await originMethod.apply(this, args);
      });
    }

    // 5
    descriptor.value = transactionWrapped;
  };
}

  1. 인자로 들어오는 PropertyDescriptor에서 원본 메소드를 꺼내온다.

  2. 원본 메소드를 래핑 할 Transaction Method를 정의 한다.

  3. 현재 namespace가 존재하는지 혹은 namespace가 활성화 되어있는지 검증한다.

  4. namespace가 존재한다면 이전 Middleware서 넣어준 EntityManager를 꺼낸다.

  5. EntityManagertransaction메소드를 실행시킨 후 callback에서 Transaction 헬퍼에서 꺼내 쓸 수 있도록 callback인자로 받은 Transaction이 시작된 EntityManager를 넣어준다.

  6. 이 후 PropertyDescriptor의 value 즉 추 후 실행 될 메소드를 Transaction Method로 변경해준다.


Repository에 Transaction EntityManager 사용하기

@Transactional 데코레이터를 사용하면 이제 namespaceTransaction이 시작된 Entity Manager가 들어가는 것을 데코레이터 작성을 하면서 알아봤다.

이제 CustomRepository에서 해당 Entity Manager를 꺼내서 사용하기면 하면 된다. 이 때를 위 해 헬퍼를 작성한 것이다. TransactionManagerCustomRepository에 프레임워크의 힘을 빌려 DI를 해주면 된다.

필자는 abstract class를 이용하여 기초적은 CRUD method는 구현하고 각 CustomRepository에서 필요한 기능만 구현하도록 처리하였으며 헬퍼abstract class에서 DI받았다.

추가적으로 만약 @Transactional 데코레이터를 사용하지 않고 헬퍼를 통해 Repository를 가져오게 되면 Transaction이 열리지 않은 상태의 EntityManger를 사용한다.

export abstract class GenericTypeOrmRepository<T extends RootEntity> {
  // 1
  constructor(@Inject(TransactionManager) private readonly txManger: TransactionManager) {}

  // 2
  abstract getName(): EntityTarget<T>;

  async save(t: T | T[]): Promise<void> {
    await this.getRepository().save(Array.isArray(t) ? t : [t]);
  }

  async findById(id: number): Promise<T | null> {
    const findOption: FindOneOptions = { where: { id } };
    return this.getRepository().findOne(findOption);
  }

  async remove(t: T | T[]): Promise<void> {
    await this.getRepository().remove(Array.isArray(t) ? t : [t]);
  }

  // 3
  protected getRepository(): Repository<T> {
    return this.txManger.getEntityManager().getRepository(this.getName());
  }
}

  1. 헬퍼를 생성자 주입받는다.

  2. EntityManager를 통해 Repository를 가져올 때 필요한 Token을 얻어오기 위해 해당 class를 상속받는 class에게 구현을 위임한다.

  3. 상속하는 class에서 구현한 getName()을 통해 각각의 해당하는 EntityRepository를 얻어온다.


그럼 구현 class에서는 아래와 같이 getName()만 구현하면 된다.

class MockRepository extends GenericTypeOrmRepository<Mock> {
  getName(): EntityTarget<Mock> {
    return Mock.name;
  }
}

테스트 코드

테스트를 위한 Class를 작성하고 그 안에서 @Transactional 데코레이터를 사용하는 간단한 함수를 구현하였다.

class Greeting {
  @Transactional()
  greeting() {
    console.log('Hello Transactional Decorator');
  }
}

greeting method를 호출할 때 정상적으로 트랜젝션 안에서 실행되는지 검증하는 테스트 코드는 아래와 같으며 TypeORM의 DataSource를 생성하기 위해 sqlite3을 사용하였다. (실패 케이스에 대한 테스트 코드는 transactional.decorator.spec.ts 에서 확인할 수 있다.)

it('entityManager가 있는 경우 (정상)', async () => {
  //given
  const mock = new Greeting();
  const namespace = createNamespace(PYC_NAMESPACE); // 1

  // 2
  const dataSource = await new DataSource({
    type: 'sqlite',
    database: ':memory:',
    synchronize: true,
    logging: true,
  }).initialize();
  const em = dataSource.createEntityManager();

  await expect(
    namespace.runPromise(async () => {
      namespace.set<EntityManager>(PYC_ENTITY_MANAGER, em); // 3
      await Promise.resolve().then(mock.greeting); // 4
    }),
  ).resolves.not.toThrowError(); // 5
});
  1. Namespace를 생성 한다.

  2. TypeORM의 DataSource를 생성 후 init해준다.

  3. 1번에서 생성한 Namespace에 EntityManager를 세팅한다.

  4. greeting를 호출한다.

  5. 호출 한 결과에서는 Error를 발생시키지 않는다.


TypeORM의 Logging 옵션을 키고 테스트를 실행시킨 결과이다. greeting이 정상적으로 호출 된 것을 확인할 수 있으며 BEGIN TRANSACTIONCOMMITgreeting의 호출 결과를 감싸고 있는 것을 볼 수 있을 것이다.


REFERENCE

profile
leewoooo

1개의 댓글

comment-user-thumbnail
2023년 3월 30일

안녕하세요 cls hooked이용하여 entity manager관리하는 방법 참고하고 있습니다. 두가지 의문점이 있습니다. Typeorm repository에서 제공하는 기본 기능을 다 cuatomrepository에서 재구현 하실건지 궁금합니다. 두번째로는 datasoutse가 여러개일때 이 방법으로 다른 db까지 트랜잭션 관리가 가능할지 여쭙고 싶습니다. 감사합니다.

답글 달기