현재 글은
Postgres
+TypeORM
기준으로 작성되었습니다. 하지만 아래 개념을 이용하여 다른 ORM에도 적용이 가능합니다. (MikroORM 등등...)
TypeORM
을 이용하여 Transaction을 이용하는 방법은 이전 글에서도 작성하였듯 대표적으로 2가지가 있다. (3가지 이지만 Nest 공식 Doc에서는 2가지 방법을 추천하고 있음.)
QueryRunner
를 이용하여 Transaction
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 어노테이션처럼 사용하고 싶엇다..)
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
데코레이터를 작성하는 순서는 아래와 같다.
Namespace
를 생성 후 EntityManager
를 심어주는 Middleware
Namespace
에 있는 EntityManager
에 접근할 수 있는 헬퍼
Transaction Decorator
Namespace
를 생성 후 EntityManager
를 심어주는 Middleware
Namespace
를 미들웨어에서 생성하는 이유는 NestJs
요청 라이프사이클을 보면 알 수 있듯이 제일 먼저 요청이 거치게 되는 곳이다.
Middleware
에서 Namespace
가 존재 유무를 판단하여 생성 혹은 가져와 해당 Namespace
에 EntityManager
를 넣어준다. (이 때 EntityManager
를 DI
받게 되는데 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);
}
}
ORM
Module에서 export 하고 있는 EntityManager
를 주입받는다.
주입받은 EntityManager
를 Namespace
에 넣어준다. 이 때 넣어준 EntityManager
는 요청의 context
에 들어가게 된다.
미들웨어 다음에 실행될 함수를 실행시켜준다.
Namespace
에 있는 EntityManager
에 접근할 수 있는 헬퍼헬퍼의 역할은 단순하다. Namespace
가 active
상태인지 확인하고 해당 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;
};
}
인자로 들어오는 PropertyDescriptor
에서 원본 메소드를 꺼내온다.
원본 메소드를 래핑 할 Transaction Method
를 정의 한다.
현재 namespace
가 존재하는지 혹은 namespace
가 활성화 되어있는지 검증한다.
namespace
가 존재한다면 이전 Middleware
서 넣어준 EntityManager
를 꺼낸다.
EntityManager
의 transaction
메소드를 실행시킨 후 callback
에서 Transaction
헬퍼에서 꺼내 쓸 수 있도록 callback
인자로 받은 Transaction
이 시작된 EntityManager
를 넣어준다.
이 후 PropertyDescriptor
의 value 즉 추 후 실행 될 메소드를 Transaction Method
로 변경해준다.
@Transactional
데코레이터를 사용하면 이제 namespace
에 Transaction
이 시작된 Entity Manager
가 들어가는 것을 데코레이터 작성을 하면서 알아봤다.
이제 CustomRepository
에서 해당 Entity Manager
를 꺼내서 사용하기면 하면 된다. 이 때를 위 해 헬퍼를 작성한 것이다. TransactionManager
를 CustomRepository
에 프레임워크의 힘을 빌려 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());
}
}
헬퍼를 생성자 주입받는다.
EntityManager
를 통해 Repository를 가져올 때 필요한 Token
을 얻어오기 위해 해당 class를 상속받는 class
에게 구현을 위임한다.
상속하는 class
에서 구현한 getName()
을 통해 각각의 해당하는 Entity
의 Repository
를 얻어온다.
그럼 구현 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
});
Namespace를 생성 한다.
TypeORM의 DataSource
를 생성 후 init
해준다.
1번에서 생성한 Namespace에 EntityManager를 세팅한다.
greeting
를 호출한다.
호출 한 결과에서는 Error
를 발생시키지 않는다.
TypeORM
의 Logging 옵션을 키고 테스트를 실행시킨 결과이다. greeting
이 정상적으로 호출 된 것을 확인할 수 있으며 BEGIN TRANSACTION
과 COMMIT
이 greeting
의 호출 결과를 감싸고 있는 것을 볼 수 있을 것이다.
안녕하세요 cls hooked이용하여 entity manager관리하는 방법 참고하고 있습니다. 두가지 의문점이 있습니다. Typeorm repository에서 제공하는 기본 기능을 다 cuatomrepository에서 재구현 하실건지 궁금합니다. 두번째로는 datasoutse가 여러개일때 이 방법으로 다른 db까지 트랜잭션 관리가 가능할지 여쭙고 싶습니다. 감사합니다.