Skip to content

typeorm transactional

seoko97 edited this page Mar 10, 2023 · 5 revisions

들어가기 앞서

현재 저희 프로젝트에서는 typeorm-transactional를 통해 Transaction을 관리하고 있습니다.

해당 문서에서는 typeorm-transactional를 사용한 이유와 사용 방법에 대해 정리합니다.

구조 고민

현재 저희 프로젝트에선 Nest.js를 통해 백앤드 개발을 진행중입니다. 이에 MVC 구조를 통해 프로젝트를 관리하고 있습니다.

해당 구조는 다음과 같습니다.

Frame 5

해당 구조를 사용하기 위해 CustomRepository 선언했습니다. 코드는 다음과 같습니다.

import { Inject } from '@nestjs/common';
import { EntityTarget, Repository } from 'typeorm';

import { TransactionManager } from '@/common/transaction.manager';

export abstract class RootRepository<T> extends Repository<T> {
  constructor(
    @Inject(TransactionManager) private readonly txManager: TransactionManager,
  ) {
    super(RootRepository, txManager.getEntityManager());
  }

  abstract getName(): EntityTarget<T>;

  protected getRepo(): Repository<T> {
    return this.txManager.getEntityManager().getRepository(this.getName());
  }
}

TypeORM 0.3.0 부터 EntityRepository가 deprecated되었기 때문에 직접 RootRepositoy란 Customic한 class 개발하여 진행하였습니다.

해당 코드를 개발하며 진행한 예상 플로우는 다음과 같습니다.

  1. AppModule에 등록한 TransactionMiddleware을 통해 Nest.js에서 제공하는 namespace에 EntityManager를 등록
  2. Service에서 Transactional decorator를 통해 namespace에 등록된 EntityManager를 가져와 Repository에 주입
  3. Repository에서 주입된 EntityManager를 통해 transaction 및 original Function 실행

해당 플로우를 예상하며 TransactionMiddlewareTransactional decorator를 생성했습니다.

// transaction.middleware.ts
@Injectable()
export class TransactionMiddleware implements NestMiddleware {
  constructor(private readonly em: EntityManager) {}
  use(_req: Request, _res: Response, next: NextFunction) {
    const namespace =
      getNamespace(PLANDAR_NAMESPACE) ?? createNamespace(PLANDAR_NAMESPACE);
    return namespace.runAndReturn(async () => {
      Promise.resolve()
        .then(() => this.setEntityManager())
        .then(next);
    });
  }

  private setEntityManager() {
    const namespace = getNamespace(PLANDAR_NAMESPACE) as Namespace;
    namespace.set<EntityManager>(PLANDAR_ENTITY_MANAGER, this.em);
  }
}
...

// app.module.ts

...

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(TransactionMiddleware).forRoutes('*');
  }
}

생성한 TransactionMiddleware를 AppModule에 등록하여 route요청이 들어올때마다 실행되도록 작성했습니다.


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

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

      const em = nameSpace.get(PLANDAR_ENTITY_MANAGER) as EntityManager;
      if (!em)
        throw new InternalServerErrorException(
          `Could not find EntityManager in ${PLANDAR_NAMESPACE} nameSpace`,
        );

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

    descriptor.value = transactionWrapped;
  };
}

// user.service.ts

@Injectable()
export class UserService {
  constructor(
    private readonly userRepo: UserRepository,

  ) {}

  ...

  @Transactional()
  async getUsers() {
    return this.userRepo.find();
  }

  ...

}

@Transactional()를 통해 등록된 EntityManager를 가져와 Transaction을 실행하여 하나에 Transaction내부에서 Service 함수들이 실행될 수 있도록 작성했습니다.

에러 발생

해당 코드를 실행하고 요청을 보내니 에러가 발생하였습니다.

image

왜?

RootRepository를 보시겠습니다.

export abstract class RootRepository<T> extends Repository<T> {
  constructor(
    @Inject(TransactionManager) private readonly txManager: TransactionManager,
  ) {
   const _entityManager = txManager.getEntityManager();
    super(RootRepository, _entityManager);
  }

  ...
}

RootRepositoryTransactionManager를 주입받아 EntityManager를 호출하여 저장합니다.

해당 부분을 디버깅해보면

image

다음같이 TransactionManager를 통해 호출한 EntityManagerundefined인 것을 확인할 수 있습니다.

Server flow를 다시 한번 보겠습니다.

1. AppModule에 등록한 `TransactionMiddleware`을 통해 Nest.js에서 제공하는 namespace에 EntityManager를 등록
2. Service에서 `Transactional` decorator를 통해 namespace에 등록된 EntityManager를 가져와 Repository에 주입
3. Repository에서 주입된 EntityManager를 통해 transaction 및 original Function 실행

현재 AppModuleMiddleware단에서 Route 실행시 EntityManager가 등록됩니다.

RootRepository는 서비스가 처음 실행될 때 초기화 되기 때문에 namespace에는 EntityManager가 등록되지않아 undefined가 출력되었습니다.

해결방안?

해당 에러에 대한 해결방안은 다음과 같습니다.

  1. Middleware에서 EntityManager를 등록하지 않고 초기화 진행시 EntityManager를 등록
  2. EntityManager등록시 typeORM의 Datasource를 사용해 Repository에 등록

이에 해당 부분을 만족하며 간편하게 사용할 수 있는 typeorm-transactional 라이브러리를 사용하게 됐습니다.


typeorm-transactional

typeorm-transactional는 크게 3단계로 진행됩니다.

  1. 지정된 namespace에 manager를 등록하는 initializeTransactionalContext 함수
  2. TypeOrmModule 초기화시 dataSource를 등록하는 addTransactionalDataSource 함수
  3. 기존 함수를 Transaction으로 감싸고 호출시 실행하는 Transactional decorator

다음과 같이 3단계를 직접 실행하가며 확인해보겠습니다.

initializeTransactionalContext

initializeTransactionalContext는 처음 서비스가 시작될때 실행되는 main.ts 파일에서 실행됩니다.

// main.ts

async function bootstrap() {
  // 1) initializeTransactionalContext 실행
  initializeTransactionalContext();

  ...
}

bootstrap();

initializeTransactionalContext 내부 코드를 확인해보며 코드 흐름을 이해해봤습니다.

// typeorm-transactional/common/index.ts
export const initializeTransactionalContext = (options?: Partial<TypeormTransactionalOptions>) => {
  // 1.1) initializeTransactionalContext called

  setTransactionalOptions(options);
  ...
};

const setTransactionalOptions = (options?: Partial<TypeormTransactionalOptions>) => {
  data.options = { ...data.options, ...(options || {}) };
};

initializeTransactionalContext 실행시 parameter로 받은 option을 전역변수 data에 저장합니다.

export const initializeTransactionalContext = (options?: Partial<TypeormTransactionalOptions>) => {
  ...
  const patchManager = (repositoryType: unknown) => {
    // 1.4) patchManager: repositoryType에 manager를 정의한다.
    // manager 호출시 getEntityManagerInContext를 호출하여 context에 등록된 manager를 가져온다.
    Object.defineProperty(repositoryType, 'manager', {
      configurable: true,
      get() {
        return (
          getEntityManagerInContext(
            this[TYPEORM_ENTITY_MANAGER_NAME].connection[
              TYPEORM_DATA_SOURCE_NAME
            ] as DataSourceName,
          ) || this[TYPEORM_ENTITY_MANAGER_NAME]
        );
      },
      set(manager: EntityManager | undefined) {
        this[TYPEORM_ENTITY_MANAGER_NAME] = manager;
      },
    });
  };
  ...
};

initializeTransactionalContext내부에 선언된 patchManager는 parameter로 받은 repository.property에 manager를 새롭게 정의합니다.

getEntityManagerInContext 함수는 context를 생성해 등록된 Datasouce를 가져오는 역할을 합니다.

export const initializeTransactionalContext = (options?: Partial<TypeormTransactionalOptions>) => {
  ...
  const getRepository = (originalFn: (args: unknown) => unknown) => {
    // 1.3) 기존 repository를 가져오는 originalFn에 manager 등록 여부를 확인하는 patchRepository를 추가한다.

    return function patchRepository(...args: unknown[]) {
      const repository = originalFn.apply(this, args);

      if (!(TYPEORM_ENTITY_MANAGER_NAME in repository)) {
        repository[TYPEORM_ENTITY_MANAGER_NAME] = repository.manager;

        patchManager(repository);
      }

      return repository;
    };
  };
  ...
};

getRepository 함수는 기존 repository를 가져오는 originalFn에 manager 등록 여부를 확인하는 patchRepository를 추가합니다.

이때 manager가 존재하지 않으면 앞서 설명한 patchManager을 실행하여 manager를 등록합니다.

전체 코드는 다음과 같습니다.

export const initializeTransactionalContext = (options?: Partial<TypeormTransactionalOptions>) => {
  // initializeTransactionalContext called: TYPEORM_ENTITY_MANAGER_NAME에 manager를 추가하고 관련 기능을 정의

  setTransactionalOptions(options);

  const patchManager = (repositoryType: unknown) => {
    // patchManager: repositoryType에 manager를 정의한다.
    // manager 호출시 getEntityManagerInContext를 호출하여 context에 등록된 manager를 가져온다.
    Object.defineProperty(repositoryType, 'manager', {
      configurable: true,
      get() {
        return (
          getEntityManagerInContext(
            this[TYPEORM_ENTITY_MANAGER_NAME].connection[
              TYPEORM_DATA_SOURCE_NAME
            ] as DataSourceName,
          ) || this[TYPEORM_ENTITY_MANAGER_NAME]
        );
      },
      set(manager: EntityManager | undefined) {
        this[TYPEORM_ENTITY_MANAGER_NAME] = manager;
      },
    });
  };

  const getRepository = (originalFn: (args: unknown) => unknown) => {
    // 기존 repository를 가져오는 originalFn에 manager 등록 여부를 확인하는 patchRepository를 추가한다.

    return function patchRepository(...args: unknown[]) {
      const repository = originalFn.apply(this, args);

      if (!(TYPEORM_ENTITY_MANAGER_NAME in repository)) {
        /**
         * Store current manager
         */
        repository[TYPEORM_ENTITY_MANAGER_NAME] = repository.manager;

        /**
         * Patch repository object
         */
        patchManager(repository);
      }

      return repository;
    };
  };

  const originalGetRepository = EntityManager.prototype.getRepository;
  const originalExtend = Repository.prototype.extend;

  // EntityManager.prototype.getRepository에 getRepository저장하여 해당 메서드 호출시 manager 생성/저장
  EntityManager.prototype.getRepository = getRepository(originalGetRepository);
  Repository.prototype.extend = getRepository(originalExtend);

  // Repository.prototype에 manager 등록
  patchManager(Repository.prototype);

  // namespace 생성
  return createNamespace(NAMESPACE_NAME) || getNamespace(NAMESPACE_NAME);
};

addTransactionalDataSource

addTransactionalDataSource는 AppModule에서 실행되어 Datasource를 등록하는 함수입니다.

// app.module.ts
@Module({
  imports: [
    ...
    TypeOrmModule.forRootAsync({
      ...
      dataSourceFactory: async (options) => {
        if (!options) {
          throw new Error('Invalid options passed');
        }

        // addTransactionalDataSource 실행하여 datasource 생성 및 전역 dataSources에 등록'
        return addTransactionalDataSource(new DataSource(options));
      },
    }),
    ...
  ],
})
export class AppModule {}

TypeOrmModule.forRootAsync 내부의 addDatasourceFactory 에서 실행됩니다.

export const addTransactionalDataSource = (input: DataSource | AddTransactionalDataSourceInput) => {
  if (isDataSource(input)) {
    input = { name: 'default', dataSource: input, patch: true };
  }

  const { name = 'default', dataSource, patch = true } = input;
  if (dataSources.has(name)) {
    throw new Error(`DataSource with name "${name}" has already added.`);
  }

  if (patch) {
    // dataSource.menager에 EntityManager 등록
    patchDataSource(dataSource);
  }

  dataSources.set(name, dataSource);

  dataSource[TYPEORM_DATA_SOURCE_NAME] = name;

  return input.dataSource;
};

코드는 간단합니다. parameter로 넘어온 인자가 Datasource인지 확인하고 EntityManager를 등록하고 반환합니다.

또한, 해당 Datasource를 전역변수 datasources에 저장합니다.

key 기본값은 default이며 여러개의 Datasouce를 저장할 수도 있습니다.

만약 key값이 testDS라면 저장된 Datasource는 다음과 같습니다.

image

Transactional

실제 실행 함수가 등록되고 호출시 Transaction이 실행되는 부분입니다.

export const Transactional = (options?: WrapInTransactionOptions): MethodDecorator => {
  return (
    _: unknown,
    methodName: string | symbol,
    descriptor: TypedPropertyDescriptor<unknown>,
  ) => {
    const originalMethod = descriptor.value as () => unknown;

    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,
    });
  };
};

등록된 originalMethod를 transaction으로 감싸 저장하고 해당 함수 실행시 transaction을 실행합니다.

@Injectable()
export class UserService {
  constructor(
    private readonly userRepo: UserRepository,
  ) {}

  ...
  @Transactional({ connectionName: 'testDS' })
  async getUsers() {
    return this.userRepo.find();
  }
  ...
}

addTransactionalDataSource를 실행할때 등록했던 name을 connectionName과 동일하게 작성해야 합니다.

실행

image

Nest.js start시 정상적으로 실행되는 모습을 확인할 수 있습니다.

API 요청

API 요청을 통해 Transaction이 정상적으로 동작하는지 확인해 보겠습니다.

성공

@Controller('user')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get()
  async getUsers() {
    return this.userService.getUsers();
  }
}

@Injectable()
export class UserService {
  constructor(
    private readonly userRepo: UserRepository,
  ) {}

  @Transactional({ connectionName: 'testDS' })
  async getUsers() {
    return this.userRepo.find();
  }
}
image image

API 성공시 데이터와 Transaction Commit 또한 정상적으로 확인할 수 있습니다.

실패

@Injectable()
export class UserService {
  ...
  @Transactional({ connectionName: 'testDS' })
  async createUser(success: boolean) {
    const ret = [];
    for (let i = 0; i < 3; i++) {
      const num = Math.random() * 10000;
      const data = { user: undefined, plan: undefined };
      if (i === 2 && !success) {
        throw new InternalServerErrorException('bomb! Internal Server');
      }
      data.user = await this.userRepo.createUser(`user${Math.floor(num)}`);
      data.plan = await this.planRepo.createPlan(`plan${Math.floor(num)}`);
      ret.push(data);
    }
    return ret;
  }
}
image image

API 요청중 에러가 발생하면 Rollback 되는 것을 확인할 수 있습니다.