-
Notifications
You must be signed in to change notification settings - Fork 1
typeorm transactional
현재 저희 프로젝트에서는 typeorm-transactional
를 통해 Transaction을 관리하고 있습니다.
해당 문서에서는 typeorm-transactional
를 사용한 이유와 사용 방법에 대해 정리합니다.
현재 저희 프로젝트에선 Nest.js를 통해 백앤드 개발을 진행중입니다. 이에 MVC 구조를 통해 프로젝트를 관리하고 있습니다.
해당 구조는 다음과 같습니다.
해당 구조를 사용하기 위해 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 개발하여 진행하였습니다.
해당 코드를 개발하며 진행한 예상 플로우는 다음과 같습니다.
- AppModule에 등록한
TransactionMiddleware
을 통해 Nest.js에서 제공하는 namespace에 EntityManager를 등록 - Service에서
Transactional
decorator를 통해 namespace에 등록된 EntityManager를 가져와 Repository에 주입 - Repository에서 주입된 EntityManager를 통해 transaction 및 original Function 실행
해당 플로우를 예상하며 TransactionMiddleware
와 Transactional
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 함수들이 실행될 수 있도록 작성했습니다.
해당 코드를 실행하고 요청을 보내니 에러가 발생하였습니다.
RootRepository
를 보시겠습니다.
export abstract class RootRepository<T> extends Repository<T> {
constructor(
@Inject(TransactionManager) private readonly txManager: TransactionManager,
) {
const _entityManager = txManager.getEntityManager();
super(RootRepository, _entityManager);
}
...
}
RootRepository
는 TransactionManager
를 주입받아 EntityManager
를 호출하여 저장합니다.
해당 부분을 디버깅해보면
다음같이 TransactionManager
를 통해 호출한 EntityManager
가 undefined
인 것을 확인할 수 있습니다.
Server flow를 다시 한번 보겠습니다.
1. AppModule에 등록한 `TransactionMiddleware`을 통해 Nest.js에서 제공하는 namespace에 EntityManager를 등록
2. Service에서 `Transactional` decorator를 통해 namespace에 등록된 EntityManager를 가져와 Repository에 주입
3. Repository에서 주입된 EntityManager를 통해 transaction 및 original Function 실행
현재 AppModule
의 Middleware
단에서 Route 실행시 EntityManager
가 등록됩니다.
RootRepository
는 서비스가 처음 실행될 때 초기화 되기 때문에 namespace에는 EntityManager
가 등록되지않아 undefined
가 출력되었습니다.
해당 에러에 대한 해결방안은 다음과 같습니다.
Middleware
에서EntityManager
를 등록하지 않고 초기화 진행시EntityManager
를 등록EntityManager
등록시 typeORM의 Datasource를 사용해 Repository에 등록
이에 해당 부분을 만족하며 간편하게 사용할 수 있는 typeorm-transactional
라이브러리를 사용하게 됐습니다.
typeorm-transactional
는 크게 3단계로 진행됩니다.
- 지정된 namespace에 manager를 등록하는
initializeTransactionalContext
함수 TypeOrmModule
초기화시 dataSource를 등록하는addTransactionalDataSource
함수- 기존 함수를 Transaction으로 감싸고 호출시 실행하는
Transactional
decorator
다음과 같이 3단계를 직접 실행하가며 확인해보겠습니다.
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
는 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는 다음과 같습니다.
실제 실행 함수가 등록되고 호출시 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과 동일하게 작성해야 합니다.
Nest.js start시 정상적으로 실행되는 모습을 확인할 수 있습니다.
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();
}
}
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;
}
}
API 요청중 에러가 발생하면 Rollback 되는 것을 확인할 수 있습니다.