본문 바로가기
Backend/NestJS

Transaction, 한 가지 이상의 CRUD 를 실행할 때 roll back

by 찬찬2 2024. 6. 27.

실무에서 접했던 상황을 바탕으로 설명해보자면,

 

데이터베이스 저장소(repository)

- user_farm: 사용자의 농장정보를 담고 있다.

- farm: 사용자들의 농장정보를 담고 있다.

- farm_dong: 농장의 동(A농장-1동, A농장- 2동, A농장- 3동...) 정보들을 담고 있다.

 

사용자가 농장을 추가하면 아래와 같은 순서로 저장되는 흐름이다.

1. this.userFarmRepo.save()

2. this.farmRepo.save()

3. this.farmDongRepo.save()

 

// service.ts
async saveUserFarm(dto: UserFarmDto){
   const model = this.userFarmRepo.create(dto);
   
   const userFarm = await this.userFarmRepo.save(model); // step 1
   
   const farm = await this.createFarm(model.idx); // step 2
   
   const dong = await this.createDong(farm.idx); // step 3
    
   return await this.userFarmRepo.findOne({ where: { idx: userFarm.idx }});
}

async createFarm(userIdx: number){
   return await this.farmRepo.save({ userIdx });
}

async createDong(farmIdx: number){
   return await this.dongRepo.save({ farmIdx });
}

 

step1: save 메서드로 user_farm 에 저장된 후 저장된 객체가 userFarm 변수에 할당된다.

step 2: 저장된 객체의 idx 값을 farm 테이블에 컬럼 USER_IDX 에 넣고 새로운 row 를 추가하고 추가된 객체를 farm 변수에 할당한다.

step 3: 저장된 farm 객체의 idx 값으로 farm_dong 테이블에 컬럼 FARM_IDX 에 넣고 새로운 row 를 추가한다.

 

saveUserFarm 함수에서 세 번의 save 가 발생하는데, 각각 save 할때마다 자신 앞에서 save 된 값을 받아 사용하는, 고리형태를 보인다.

 

여기서 문제는 이 고리형태에서 누구 하나라도 에러가 발생하면이다. 기획적으로 다른 이견이 없다면 보통 모두 저장이 되지 않아야 한다. 그렇기 때문에 step 1 에서 저장되었더라도 step 2 에서 에러가 발생하면 step 1 을 원복해야 하는 roll-back 이 필요하다.

 

자세히는 모르지만 대부분의 데이터베이스에서는 Transaction 으로 이를 가능하게 한다고 한다. typeORM 역시 Transaction 을 구현할 수 있고 그 방법은 총 두 가지이다.

 

1. DataSource 객체에 있는 createQueryRunner 메서드

2. DataSource 객체에 있는 transaction 메서드

 

두 개의 차이점은 범용성인데, 

 

우선 createQueryRunner 를 사용해서 위 코드를 수정해보자.

 

// service.ts
constructor(
   private readonly dataSource: DataSource
){}

async saveUserFarm(dto: UserFarmDto){
   // 쿼리 러너 생성
   const qr = this.dataSource.createQueryRunner();
   // 쿼리 러너 연결
   await qr.connect();
   // 트랜잭션 시작
   await qr.startTransaction();
   
   try{
      const userFarmRepo = qr.manager.getRepository(UserFarmRepo);   
      const userFarm = await userFarmRepo.save(dto); // step 1
   
      const farmRepo = qr.manager.getRepository(FarmRepo);
      const farm = await farmRepo.save({ userIdx: model.idx }); // step 2
   
      const dongRepo = qr.manager.getRepository(DongRepo);
      const dong = await dongRepo.save({ farmIdx: farm.idx }); // step 3
      
      // 모든 save 커밋 저장
      await qr.commitTransaction();
      // 쿼리 러너 종료
      await qr.release();
      
      return await userFarmRepo.findOne({ where: { idx: userFarm.idx }});
   }catch(e){
      await qr.rollbackTransaction();
      await qr.release();
      
      throw new InternalServerErrorException('에러 발생: ', e.message);
   };
   

}

 

constructor 에서 typeorm 에서 제공하는 DataSource 객체를 DI 받은 뒤 위와 같이,

 

1. createQueryRunner 로 QueryRunner 객체를 만들어 qr 변수에 담고,

 

2. connect 매서드로 transaction 을 연결,

 

3. startTransaction 메서드로 transaction 시작

 

4. QueryRunner 객체는 EntityManager 객체를 manager 프로퍼티에 담고 있다. 이 manager 의 getRepository 메서드로 필요한 리파지토리를 불러와 QueryRunner 와 연결시켜줘야 한다.

 

5. try, catch 문으로 실행시킬 코드를 넣어준다. 마지막으로 모두 정상적으로 저장되었다면 commitTranstion. 실패했다면 rollbackTransaction 메서드를 호출한다.

 

6. 성공 또는 실패 후 transaction 을 종료 시킨다.

 

이번에는 transaction 메서드를 사용해보자.

 

// service.ts
async saveUserFarm(dto: UserFarmDto){
   try{
      await this.dataSource.transaction(async manager => {
         const userFarmRepo = manager.getRepository(UserFarmRepo);   
         const userFarm = await userFarmRepo.save(dto); // step 1
   
         const farmRepo = manager.getRepository(FarmRepo);
         const farm = await farmRepo.save({ userIdx: userFarm.idx }); // step 2
   
         const dongRepo = manager.getRepository(DongRepo);
         const dong = await dongRepo.save({ farmIdx: farm.idx }); // step 3
      });
      return await userFarmRepo.findOne({ where: { idx: userFarm.idx }});
   }catch(e){
      throw new InternalServerErrorException('에러 발생: ', e.message);
   };
}

 

첫 번째 방법과 다르게 transaction 메서드의 인자로 콜백함수를 넣어주면 된다. 콜백함수의 파라미터는 EntityManager 객체를 담고 있고 해당 객체에서 getRepository 를 호출해 save 할 리파지토리와 transaction 을 연결한다.

 

한 눈에 보더라도 두 번째 방법이 코드량도 적고 가독성이 좋다.

 

QueryRunner 의 범용성

QueryRunner 로 transaction 을 실행하기 위해서는 여러개의 메서드를 호출해야 한다. 레고처럼 메서드들이 블록이라고 생각하면, 이 블록들로 나는 자동차를 만들 수 있고, 비행기를 만들 수도 있다. 즉, 부품을 내가 원하는 맛에 따라 조합할 수 있다는 것이다.

 

인강에서 알려준  QueryRunner 와 Interceptor 을 조합해서 사용하는 코드를 보자.

 

// interceptor.ts
import { Injectable, CallHandler, ExecutionContext, InternalServerErrorException, NestInterceptor } from "@nestjs/common";
import { Observable, catchError, tap } from "rxjs";
import { DataSource } from "typeorm";

@Injectable()
export class TransationInterceptor implements NestInterceptor {
    constructor(
        private readonly dataSource: DataSource
    ) { }

    async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
        const req = context.switchToHttp().getRequest();

        const qr = this.dataSource.createQueryRunner();
        await qr.connect();
        await qr.startTransaction();

        req.queryRunner = qr;

        return next.handle().pipe(
            catchError(async (e) => {
                await qr.rollbackTransaction();

                throw new InternalServerErrorException(e.message);
            }),
            tap(async () => {
                await qr.commitTransaction();
                await qr.release();
            })
        );
    }
}

// controller.ts
@Post()
@UseInterceptor(TransationInterceptor)
postUserFarm(
   @Body() dto: UserFarmDto,
   @Request() req: Request extends { queryRunner: QueryRunner } ? Request : { queryRunner: QueryRunner } 
){
   this.farmService.saveUserFarm(dto, req.qr);
}

// service.ts
async saveUserFarm(dto: UserFarmDto, qr: QueryRunner){   
   try{
      const userFarmRepo = qr.manager.getRepository(UserFarmRepo);   
      const userFarm = await userFarmRepo.save(dto); // step 1
   
      const farmRepo = qr.manager.getRepository(FarmRepo);
      const farm = await farmRepo.save({ userIdx: model.idx }); // step 2
   
      const dongRepo = qr.manager.getRepository(DongRepo);
      const dong = await dongRepo.save({ farmIdx: farm.idx }); // step 3
      
      return userFarmRepo.findOne({ where: { idx: userFarm.idx }});
   }catch(e){      
      throw new InternalServerErrorException('에러 발생: ', e.message);
   };
   

}

 

NestJS 에서 interceptor 는 Request 와 Response 를 하나의 객체에서 다룰 수 있다.

 

사용자가 API 를 호출할때 interceptor 에서 사용자의 request 를 받고 거기에 QueryRunner 객체를 얹어 controller.ts 에 전달한다. 그리고 transaction 이 필요한 route 에 @UseInterceptor 데코레이터를 넣고 실행할 함수의 인자로 @Request 데코레이터를 넣음으로써 해당 함수는 QueryRunner 객체에 접근할 수 있게된다.

함수 안에 있을 service 는 가져온 QueryRunner 객체에 EntityManager 로 원하는 리파지토리에 복수의 CRUD 하면서 roll-back 기능이 생기게되었다.

 

굳굳~!

댓글