NestJs + Typeorm 을 사용하면서 기존과 다르게 Transaction 적용하게 되면서 배운 점들을 공유드리려 합니다!
우선 저희 회사는 기본적으로 자신의 아이템을 소유하고 잃는 게임에 기반한 서비스 입니다.
그러다보니 특정 아이템을 잃는 상황과 지급해줘야하는 상황에서 반드시 확실히 보장된 서버데이터를 바탕으로 쿼리를 진행시켜야 했습니다.
그래서 아이템이나 유저의 소유물에 관련된 특수한 상황들에만 SERIALIZABLE 레벨의 트렌젝션을 최소한(반드시 필요한 조건만 확인하고 로직을 진행)으로 걸어주며 서비스를 만들어 나가고 있습니다.
제가 알고있는 방법으로는 총 3가지 방법이 있습니다!
@Transaction({ isolation: 'SERIALIZABLE' })
async transactionSomething(
id: number,
@TransactionManager() transactionManager: EntityManager,
) {
// 트렌젝션 로직
}
위의 방식처럼 @Transaction 트렌젝션을 주입해준 다음 트렌젝션에 사용하는 방식입니다!
데코레이터를 사용할 때는 typeorm 데코레이터를 사용하기도 하고
https://github.com/odavid/typeorm-transactional-cls-hooked 이 라이브러리를 사용하는 방식으로도 많이 사용하는 듯 합니다!
그런데 일단 굳이 추가적인 라이브러리를 사용하긴 싫었고(충분히 좋은 대안이 있는데 굳이 외부 라이브러리를 사용하기 싫었고) 데코레이션이 제대로 동작하지 않는 경우도 있다고 하여 해당 방법은 사용하지 않기로 했습니다.
async transactionExample() {
return await getManager().transaction(
'SERIALIZABLE',
async (txManager) => {
const transaction =
txManager.getCustomRepository(ExampleRepository);
// 트렌젝션 로직
},
);
}
위 처럼 getManager를 기반으로 transaction을 만들고 내부에서 커스텀 레포지토리를 가져와서 사용하는 방식도 있습니다!
저희 회사는 이 방식을 사용했었는데 커스텀 레포지토리를 만들어주고 커스텀 레포지토리 내부에서 비즈니스로직을 작성해서 트렌젝션을 진행시켰습니다!
async postAttendance(uid: number) {
const queryRunner = this.connection.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction('SERIALIZABLE');
try {
const data = await this.transactionRepository.getSomething(uid, queryRunner.manager);
if(!data) {
throw new Exeception();
}
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw new Exeception(error);
} finally {
await queryRunner.release();
}
}
마지막으로 저희 회사에서 앞으로 적용시키려고하는 queryRunner입니다!
위의 1,2 두 방식은 트렌젝션을 자동으로 관리해주기 때문에 명확하지 않고 의도대로 작동시키지 못하는 지점이 생기지만, queryRunner는
연결 시작 (connect),
트랜젝션 시작 (startTransaction),
트랜젝션 완료 (commitTransaction),
트랜젝션 실패 후 복구 (rollbackTransaction)
연결 종료 (release)
해당 과정을 수동으로 맞춰줄 수 있다는 것에서 강점을 느꼈습니다!
그리고 유저의 소유물을 잃고 새로 만들어주는 서비스 특성상 rollback과정에 대한 수동적인 처리가 중요했고 그래서 해당 방식을 사용하기로 했습니다.
아직 저희 회사는 테스트 코드를 작성하지 않지만 위의 queryRunner방식이 두 방식보다 테스트 코드를 작성하기도 더 쉽다고 합니다!
제 다른글인 새로운 구조 모험기에서도 나오겠지만 Service에서 repository를 분리하는 것, 즉 레이어드 패턴을 명확히 하기 위해 노력하고 있습니다.
nest를 사용하면 Service에 Repostitory(entity | schema)를 직접 주입 받아서 DB로직을 작성하는 경우가 있습니다.
이 경우 각 다양한 서비스에서 Service Repository 주입 받는 것이 너무 쉬워져서 아래와 같은 문제들이 생깁니다.
이 두가지 문제가 크다고 생각하여 Service에서 Repository를 분리했습니다.
또한 Transaction을 사용할 경우 transaction.repository.ts를 새롭게 만들어주고 아래와 같이 구조를 만들었습니다.
[routerName]
-- routerName.controller.ts
-- routerName.service.ts
-- routerName.transaction.repository.ts
-- routerName.repository.ts
-- routerName.module.ts
Service에서 queryRunner를 불러주고 transaction.repository에서 쿼리를 진행해줬습니다.
그리고 deadlock에 빠지지 않게 하기 위해 최소한의 필요한 로직만 transaction을 걸어줬습니다.
코드는 다음과 같습니다.
//example.service.ts
@Injectable()
export class ExampleService {
constructor(
private readonly ExampleRepository: ExampleRepository,
private readonly exampleTransactionRepository: ExampleTransactionRepository,
private readonly connection: Connection,
) {}
//
async transactionExample() {
const queryRunner = this.connection.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction('SERIALIZABLE');
try {
const exampleReturn = await this.exampleTransactionRepository.transactionExample1(
queryRunner.manager,
);
if (!exampleReturn) {
throw new Exception();
}
await this.exampleTransactionRepository.transactionExample2(
queryRunner.manager,
);
await Promise.all([
this.exampleRepository.noNeedTransaction1(),
this.exampleRepository.noNeedTransaction2(exampleReturn),
]);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw new Error(error);
} finally {
await queryRunner.release();
}
}
위와같이 트랜젝션이 필요한 Service가 동작합니다.
그럼 두 transactionRepository에서는 트렌젝션을 진행하고 해당 기능 이후에는 일반 Repository에서 쿼리를 진행해줍니다.
//example.transaction.service.ts
@Injectable()
export class ExampleTransactionRepository {
// 의존성 주입으로 repository를 받지 않는다.
constrouctor() {}
// 전달받은 queryRunner.manager는 접근자 데코레이터 @TransactionManager()로 받아줌
async transactionExample1(@TransactionManager() transactionManager: EntityManager): Promise<any> {
return await transactionManager.getRepository(Example).someQuery()
}
async transactionExample2(@TransactionManager() transactionManager: EntityManager): Promise<any> {
return await transactionManager.getRepository(Example).someQuery()
}
}
//example.repository.ts
@Injectable()
export class ExampleRepository {
// 의존성 주입으로 repository를 받는다.
constrouctor() {
@InjectRepository(Example)
private readonly example: Repository<Example>,
}
async noNeedTransaction1(): Promise<any> {
return await example.somethimeQuery1()
}
async noNeedTransaction2(): Promise<any> {
return await example.somethimeQuery2()
}
}
이런식으로 진행할 수 있습니다!
그리고 기존에 customRepository를 만들고 비즈니스 로직 즉 Service의 역할을 하는 것들도 분리할 수 있었습니다.