항해 플러스 - 2주차

CH_Hwang·2023년 12월 14일
0

항해플러스

목록 보기
3/8

TDD start

프레임워크는 nest.js, orm은 typeORM으로 골랐다.

또다시 transaction이 문제다.

1차시도

  • repository마다 엔티티 매니저가 다르기때문에 service단에서 transaction을 해주려면 repository의 entityManager를 하나의 entityManager로 변경해준 후 transaction을 걸어준다. -> method 실행 후 다시 원복시켜준다.
import { DataSource, EntityManager } from 'typeorm';

export function Transactional() {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = async function (this: any, ...args: any[]) {
      let originalEntityManager;
      let result: any;

      await (this.dataSource as DataSource).transaction(async (entityManager) => {
        for (const key of Object.keys(this)) {
          const repository = this[key];
          if (repository.entityManager) {
            originalEntityManager = repository.entityManager;
            repository.entityManager = entityManager;
          }
        }
        result = await originalMethod.apply(this, args);
      });
      for (const key of Object.keys(this)) {
        const repository = this[key];
        if (repository.entityManager) {
          repository.entityManager = originalEntityManager;
        }
      }
      return result;
    };
    return descriptor;
  };
}

왜인지 잘 모르겠으나 transaction 사용하는 method 사용 후 transaction을 사용하지 않는 method 사용 시 에러가 뜬다.. 제대로 되지 않는 것 같다...

2차시도

원상복구가 제대로 되지 않는다면 무조건 되도록 하자!

import type { ApplicationService } from '../ddd/service';

export function Transactional() {
  return function (target: ApplicationService, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = async function (this: ApplicationService, ...args: any[]) {
      let originalEntityManager;
      let result: any;

      try {
        await this.dataSource.createEntityManager().transaction(async (entityManager) => {
          for (const key of Object.keys(this)) {
            // @ts-expect-error
            const repository = this[key];
            if (repository.manager) {
              originalEntityManager = repository.manager;
              repository.manager = entityManager;
            }
          }
          result = await originalMethod.apply(this, args);
        });
      } finally {
        for (const key of Object.keys(this)) {
          // @ts-expect-error
          const repository = this[key];
          if (repository.manager) {
            repository.manager = originalEntityManager;
          }
        }
      }

      return result;
    };
    return descriptor;
  };
}

조금더 가다듬었다.
하지만 개념은 같다. repository의 매니저를 새로운 entityManager로 만들어서 transaction을 한 후에 결과가 성공하든 실패하든 무조건 원상복구를 시켜주도록 하였다.

이 경우 하나의 트랜잭션에 잘 걸리게 된다.

다만 lock을 걸었을때 이상하게 동작되는 문제가 있다.


  @Transactional()
  async deposit(args: { userId: string; amount: number }) {
    const account = await this.accountRepository.findOneOrFail(
      { userId: args.userId },
      { lock: { mode: 'pessimistic_write' } },
    );
    account.deposit(args.amount);
    await this.accountRepository.save([account]);
    await new Promise((resolve) => {
      setTimeout(() => {
        resolve(0);
      }, 3000);
    });
    return account;
  }

이런 메서드가 있을때 요청을 2번 보내면 transaction이 2번 걸려야하는데 3번걸리는 요상한 일이 있다..
1번째 요청은 제대로 된다.

START TRANSACTION
SELECT * FROM account WHERE userId=~~ LIMIT 1 FOR UPDATE
# 요 아래 select문 2개는 typeORM의 save method를 쓰면 이렇게 되는 것 같다..?
SELECT * FROM account
UPDATE account SET ~~
SELECT * FROM account

START TRANSACTION # 두번째 요청의 trasaction
SELECT * FROM account WHERE userId=~~ LIMIT 1 FOR UPDATE
COMMIT # 첫번째 transaction commit
SELECT * FROM account
START TRANSACTION # 두번째 요청의 두번째trasaction???
UPDATE account SET ~~

하지만 저 사이에 있는 2번째 요청이 문제다.. 왜 update문 실행 전에 한번 더걸리는거지....

3차 시도

항해플러스에서 코치님과의 멘토링시간이 있었다.
일단 첫번째로 Transactional() 데코레이터를 사용하지 말고 repository에 인자로 넘기라고 하셨다.
그게 더 명시적이라고 하셨는데 함수 밖에서 데코레이터로 사용할 경우 service method안에서 어디까지가 트랜젝션이고 어디까지가 아닌지를 구별하기가 쉽지 않다는 것이었다.

그래서 함수로 만들었다.
일단 레포지토리에서 이런식으로 구현했다. 트랜젝션 매니저가 있다면 트랜젝션 매니저로, 아니라면 레포지토리에 주입되어있는 엔티티매니저를 통해서 하도록 되어있다.

async save(args: { target: T[]; transactionalEntityManager?: EntityManager }) {
  return (args.transactionalEntityManager ?? this.entityManager).save(args.target);
}

모든 메서드를 이런식으로 변형해주었다.

그 후 service단에서 메서드를 쓸때마다 매니저를 넘겨주기 싫어서 이런 함수를 하나 만들었다.

import type { EntityManager } from 'typeorm';

export function injectTransactionalEntityManager(transactionalEntityManager: EntityManager) {
  return <T extends (...args: any[]) => any>(f: T) =>
    async <U extends Parameters<T>>(...args: U): Promise<ReturnType<T>> =>
      f({ ...args[0], transactionalEntityManager });
}

아래 함수를 통해서 리턴받는 injector는 레포지토리 메서드를 인자로 받는다. 그리고 그 메서드에 인자를 넘겨줄때 transaction manager도 같이 넘겨주도록 설계했다.

그래서 이런식으로 사용된다.

 async deposit(args: { userId: string; amount: number }) {
    return this.dataSource.transaction(async (transactionalEntityManager) => {
      const injector = injectTransactionalEntityManager(transactionalEntityManager);
      const account = await injector(this.accountRepository.findOneOrFail)({
        conditions: { userId: args.userId },
        options: { lock: { mode: 'pessimistic_write' } },
      });
      account.deposit(args.amount);
      await injector(this.accountRepository.save)({ target: [account] });
      return account;
    });
  }

이런식으로 사용하자 문제없이 잘 돌아간다. 다만 안좋은 점은 서비스 내부에 있는 로직이 단위 테스트를 하기 어려워 진다는 것이었는데 애초에 서비스단에서는 비즈니스 로직이 없고 조회, 저장 등의 호출등만 있기 때문에 비즈니스 로직의 단위테스트는 전부 가능하도록 되어있다. 그렇다면 서비스단은 어떤 테스트를 해야하나? -> 통합테스트, e2e테스트를 통해 트랜젝션이 잘 동작하는 지에대한 동시성 테스트를 하면 될 것 같다.

0개의 댓글