본문 바로가기

nestjs

[nestjs] transaction 라이브러리 분석하기 - 3

이번 글에서는 아래의 목적을 달성하기 위해 typeorm-transacional이 해둔 작업을 보겠습니다.

트랜잭션을 위한 코드를 wrapper에게 완전히 위임하고, 서비스 계층에선 비지니스 로직에만 집중하고 싶다.

아래 3개의 화두가 중요합니다.

  • typeorm-transactional은 트랜잭션을 위한 코드를 넣어둔 wrapper를 어떻게 구현했는지?
  • myEntityManager은 어디서 어떻게 얻어왔는지?
  • typeorm-transactional에서 트랜잭션 안에서 또 다른 트랜잭션(=nested transaction)을 어떻게 생성하는지?

아래 예시코드를 보면 메서드에 @Transacional 데코레이터가 붙어 있습니다.

export class UserService {
  constructor(
    @InjectDataSource()
    private readonly dataSource: DataSource,
    @InjectRepository(User)
    private readonly userRepository: Repository<User>
  ) {}

  @Transactional()
  async countAndClear() {
    const count = dataSource.query("select count(*) from user");
    userRepository.clear(user);
    const countByClear = dataSource.query("select count(*) from user");
    console.log("user count is changed ${count} to ${countByClear}");
  }
}

 

@Transacional 데코레이터의 정의는 아래와 같습니다. 

export const Transactional = (options?: WrapInTransactionOptions): MethodDecorator => {
  return (
    _: unknown,
    methodName: string | symbol,
    descriptor: TypedPropertyDescriptor<unknown>,
  ) => {
    const originalMethod = descriptor.value as () => unknown;
    // 기존 메서드 대신에 wrapper를 할당
    descriptor.value = wrapInTransaction(originalMethod, { ...options, name: methodName });

    Reflect.getMetadataKeys(originalMethod).forEach((previousMetadataKey) => {
      const previousMetadata = Reflect.getMetadata(previousMetadataKey, originalMethod);
      Reflect.defineMetadata(previousMetadataKey, previousMetadata, descriptor.value as object);
    });

    Object.defineProperty(descriptor.value, 'name', {
      value: originalMethod.name,
      writable: false,
    });
  };
};

기존 메서드를 감싸는 wrapper를 만들어서 descriptor.value = wrapper 대입연산을 하여 wrapper가 호출되도록 바꿔줍니다.

여기서 wrapper의 정의가 어떤지가 중요합니다.

 

그 전에 전파속성에 대해 간단히 알아보겠습니다. 여러 종류가 있지만 중요한 3가지 전파수준을 보겠습니다.

 

REQUIRED (= default 전파 속성)

: 기존에 트랜잭션이 있으면 해당 트랜잭션에서 코드를 실행, 기존 트랜잭션이 없으면 새 트랜잭션을 생성하여 코드를 실행

 

MANDATORY

: 기존 트랜잭션에서 코드를 실행, 만약 기존 트랜잭션이 없으면 예외 발생

 

NESTED

: (보통 기존 트랜잭션이 있을 때 사용) 기존 트랜잭션이 있는지 없는지 따지지 않고 무조건 새 트랜잭션를 생성하여 코드를 실행

 

 

다시 코드로 돌아와서, 핵심인 wrapInTransaction 함수의 정의를 보겠습니다. ↓

(코드가 길어서 예외 처리 부분은 생략 하고 플로우 확인을 위해 필요한 부분만 보겠습니다.)

export const wrapInTransaction = <Fn extends (this: any, ...args: any[]) => ReturnType<Fn>>(
  fn: Fn,
  options?: WrapInTransactionOptions,
) => {
  function wrapper(this: unknown, ...args: unknown[]) {
    const context = getTransactionalContext();

    const connectionName = options?.connectionName ?? 'default';

    const dataSource = getDataSourceByName(connectionName);

    const propagation = options?.propagation ?? Propagation.REQUIRED;
    const isolationLevel = options?.isolationLevel;

    // 아래 3가지 함수 정의 (runOriginal, runWithNewHook, runWithNewTransacionCallback)
    const runOriginal = () => fn.apply(this, args);
    const runWithNewHook = () => runInNewHookContext(context, runOriginal);
    const runWithNewTransaction = () => {
      // dataSource.transaction(isolationLevel, callback) 메서드를 호출할 때 
      // callback 자리 넣어줄 콜백함수인 transactionCallback을 정의
      const transactionCallback = async (entityManager: EntityManager) => {
        // 아래 entityManager를 cls에 set하는 부분이 중요!
        setEntityManagerByDataSourceName(context, connectionName, entityManager);
        try {
          const result = await runOriginal();
          return result;
        } finally {
          setEntityManagerByDataSourceName(context, connectionName, null);
        }
      };
      // 콜백함수 정의는 끝났고 이제 본격적인 실행 코드
      // dataSource.transacion 메서드를 호출
      if (isolationLevel) {
        return runInNewHookContext(context, () => {
          return dataSource.transaction(isolationLevel, transactionCallback);
        });
      } else {
        return runInNewHookContext(context, () => {
          return dataSource.transaction(transactionCallback);
        });
      }
    };
    
    // 위에서 정의한 3가지 함수를 (propagation에 맞게) 호출
    return context.runAndReturn(async () => {
      const currentTransaction = getEntityManagerByDataSourceName(context, connectionName);
      switch (propagation) {
        case Propagation.MANDATORY:
          if (!currentTransaction) {
            throw new TransactionalError(
              "No existing transaction found for transaction marked with propagation 'MANDATORY'",
            );
          }
          return runOriginal();

        case Propagation.NESTED:
          return runWithNewTransaction();

        case Propagation.REQUIRED:
          if (currentTransaction) {
            return runOriginal();
          }
          return runWithNewTransaction();
      }
    });
  }

  return wrapper as Fn;
};

코드기 꽤 길지만, 기능과 플로우만 보면 그리 복잡하지 않습니다.

REQUIRED 전파 속성일 때,

기존 트랜잭션이 없는 상태에서 서비스 메서드가 실행되었다고 합시다. 여기서 기존 트랜잭션이 없다는 것은const currentTransaction = getEntityManagerByDataSourceName(context, connectionName);

에서 currnetTransaction이 undefined라는 뜻입니다. 다른 말로 하면 cls에 myEntityManager가 없다는 뜻입니다.

 

myEntityManager를 생성하고 cls에 set 하는 작업은 runWithNewTransacion()가 담당합니다.

이 함수에서 딱 한 부분만 보겠습니다. ↓

dataSource.transaction(isolationLevel, transactionCallback);
// 혹은 
dataSource.transaction(transactionCallback);

dataSource.transacion 메서드 정의를 보면 this.manager.transacion 메서드를 호출하는 코드'만' 있습니다.

 

우리는 이전에 patchDataSource 함수를 통해 dataSource가 내부적으로 this.manager를 하면
cls에서 myEntityManager를 가져오도록 작업을 해두었습니다.

 

그럼 dataSource.transaction 메서드가 내부에서 this.manager.transaction을 할 때,

cls를 참조하여 myEntityManager를 불러오려는 시도를 하겠네요.

그런데, 우리는 현재 cls에 myEntityManager가 없습니다.

 

[현재 cls에 myEntityManager가 없음 -> runWithNewTransacion() 실행 -> ... -> cls에서 myEntityManger 불러오려 시도]

이런 문제 상황인 것입니다.

 

따라서 typeorm-transacional은 pathDataSource 함수에서 한 가지 작업을 더 합니다.

const patchDataSource = (dataSource: DataSource) => {
    // defineProperty 하기 전에 
    // dataSource의 멤버변수를 originalManager 변수에 저장해둠
    let originalManager = dataSource.manager;
    // 중간 생략
    Object.defineProperty(dataSource, 'manager', {~~~});
    // 중간 생략
    dataSource.transaction = function (...args: unknown[]) {
      return originalManager.transaction(...args);
  };
};

(defineProperty 하기 전에) dataSource 멤버변수인 dataSource.manager를 originalManager 변수에 저장하였습니다.

그리고 dataSource.transacion이 호출되면 originalManager.transacion 메서드가 호출되도록 작업을 하였습니다.

 

이는 dataSource의 멤버변수 manager(= originalManager)에 대해,

originalManager.queryRunner는 디폴트로 undefined라는 사실을 이용한 것입니다.

 

manager 객체의 멤버변수 queryRunner가 undefined인 상태에서 manager.transaction 메서드를 호출하면
내부적으로 새로운 entityManager객체와 queryRunner객체를 만듭니다. 그리고 두 객체는 양방향 연관관계를 맺습니다.

그리고 콜백함수에게 새로운 entityManager객체를 인자로 제공해줍니다.

 

정리하면 아래와 같습니다.

originalManager.transacion(callback) 메서드는
매번 새 entityManager와 queryRunner를 생성후 쌍을 맺어서 callback 함수에게 새 entityManager를 전달합니다.
그리고 dataSource.transaction = originalManager.transaction 작업을 해주었습니다.
따라서 dataSource.transacion(callback) 메서드 역시
매번 새 entityManager와 queryRunner를 생성후 쌍을 맺어서 callback 함수에게 새 entityManager를 전달합니다.

 

다시 wrapInTransaction 함수로 돌아갑시다. REQUIRED일 때, cls에 myEntityManager가 없으면 아래의 작업을 수행합니다.

 

runWithNewTransacion()

dataSource.transaction(callback)

callback이 cls에 (인자로 받은) 새 entityManager를 set 

(새 entityManager = myEntityManager)

@Transactional로 감싸진 기존 서비스 코드를 실행

 

REQUIRED일 때, 기존 트랜잭션이 없으면 (=cls에 myEntityManager 없음) 새 myEntityManager를 제공받아 cls에 세팅합니다.

이 작업과 이전에 했던 작업들이 맞물려서 트랜잭션 관리를 위한 원칙이 잘 지켜지게 되는 것입니다.

 

REQUIRED일 때, 기존 트랜잭션이 있으면 (=cls에 myEntityManager 있음) 별 다른 작업을 하지 않고 기존 서비스 코드를 실행합니다.

이 작업?과 이전에 했던 작업들이 맞물려서 트랜잭션 관리를 위한 원칙이 잘 지켜지게 되는 것입니다.

 

NESTED 전파 속성일 때,

switch (propagation) {
    case Propagation.NESTED:
      return runWithNewTransaction();
}

nested는 간단합니다. (기존 트랜잭션이 있는지 없는지 확인하지 않고) runWithNewTransaction 함수를 실행합니다.

runWithNewTransaction 함수는 위에서 한 번 설명했기 때문에 생략하겠습니다.

 

추가로, 만약 patchDataSource에서

dataSource.transaction=originalManager.transaction 작업을 하지 않았다면 nested에서도 문제가 생기게 됩니다.

 

왜냐하면 현재 cls에 myEntityManager가 있는 상태에서 runWithNewTransaction을 실행하였을때,
결국 dataSouce는 cls의 myEntityManager에 대해 myEntityManager.transaction 메서드를 호출할 것입니다.

그런데 myEntityManager는 멤버변수로 (undefined가 아닌) queryRunner를 갖는다고 했습니다.

그럼 새 트랜잭션에서 서비스 코드를 실행하는게 아니라 기존 트랜잭션에서 서비스 코드를 실행하게 될 것입니다.

 

MANDATORY 전파 속성일 때,

switch (propagation) {
    case Propagation.MANDATORY:
      if (!currentTransaction) {
        throw new TransactionalError(
          "No existing transaction found for transaction marked with propagation 'MANDATORY'",
        );
      }
      return runOriginal();
}

우리가 지금까지 얻은 지식을 바탕으로 충분히 이해할 수 있는 코드라 생각합니다.


이제 typeorm-transacional 라이브러리의 핵심적인 부분은 모두 보았습니다.