본문 바로가기

[Project] 프로젝트 삽질기25 (feat Transaction)

어가며

사이드 프로젝트에서 푸시 알림을 활용한 서비스를 개발하고 있습니다. 개발한 API를 살펴보면서, 비효율적인 부분들이 눈에 보였습니다. 같은 조회 쿼리를 하나의 API에 두 번씩 도는 로직이 있었고, 처리 순서가 중요하지 않은 조회 쿼리를 동기적으로 처리하고 있었습니다. 마지막으로 데이터를 저장할 때 하나의 트랜잭션으로 처리하는 것이 아니라, 별도의 트랜잭션으로 처리하고 있었고, 만약 에러가 발생할 경우에도 별도의 트랜잭션으로 데이터를 저장하기에, 롤백되는 것이 아닌 커밋될 위험이 다분했습니다. 이 문제를 해결하기 위해 트랜잭션과 Promise.all을 활용했습니다. 이 글은 트랜잭션과 Promise.all을 어떻게 활용했는지에 관한 글입니다. 

 

 

 

 

 


 

 

 

트랜잭션?

API를 개발하면서, 에러가 났음에도 데이터가 저장된 것을 보면서, 트랜잭션을 제대로 적용해봐야겠다고 생각했습니다. 트랜잭션을 외우기만 했지, 적용해본 적이 없기에 이번 기회에 트랜잭션이란 무엇이며, 트랜잭션에 고민해야 할 것은 무엇인지, lock은 무엇이며, 트랜잭션 고립 레벨 별로 lock은 어떻게 동작하는지 알아야겠다고 생각했습니다.

 

 

 

 

[데이터베이스] 트랜잭션

들어가며 SOPT에서 프로젝트를 진행하면서, Sequelize ORM을 활용해서 프로젝트를 관리했습니다. 하지만 Sequelize를 활용하다 보니, SQL의 원리에 대해서 제대로 알 수 없었습니다. Sequelize에 문제가 생

overcome-the-limits.tistory.com

 

[데이터베이스] 트랜잭션의 격리수준과 Lock

들어가며 Lock 쿼리 때문에 부하가 걸려서, 장애가 났다는 개발자들의 이야기를 들으면서, Lock 쿼리란 무엇이고, Lock이 무엇인지 알고 싶다고 생각했습니다. Lock을 알아야 훗날 Lock 쿼리를 만나도

overcome-the-limits.tistory.com

 

 

위와 같이 공부하고서, 프로젝트에 어떻게 트랜잭션을 적용했는지 살펴보겠습니다.

 

 

 

 

 


 

 

 

 

 

 

 

API 리팩터링

기존 작업한 API는 다음과 같았습니다.

 

 

async createFavoriteSummoner (
    userDto: UserReq,
    favoriteSummonerDto: FavoriteSummonerReq,
  ): Promise<void> {
      await this.checkLimitFavoriteSummoner(userDto.userId);
      await this.redisClient.saveRedisSummonerRecord(favoriteSummonerDto);
      await this.saveSummonerRecord(favoriteSummonerDto);
      await this.saveFavoriteSummoner(favoriteSummonerDto, userDto);
      await this.restoreFavoriteSummoner(favoriteSummonerDto, userDto);
  }
  
  
  private async saveSummonerRecord(
    favoriteSummonerDto: FavoriteSummonerReq,
  ): Promise<void> {
    const isFindSummonerRecordId = await this.isSummonerRecordBySummonerId(
      favoriteSummonerDto.summonerId,
    );

    if (!isFindSummonerRecordId) {
      await this.summonerRecordRepository.save(
        await favoriteSummonerDto.toSummonerRecordEntity(),
      );
    }
  }
  
  private async saveFavoriteSummoner(
    favoriteSummonerDto: FavoriteSummonerReq,
    userDto: UserReq,
  ): Promise<void> {
    if (
      !(await this.findFavoriteSummonerIdWithSoftDelete(
        userDto.userId,
        favoriteSummonerDto.summonerId,
      ))
    )
      await this.favoriteSummonerRepository.save(
        await favoriteSummonerDto.toFavoriteSummonerEntity(userDto.userId),
      );
  }
  
  private async restoreFavoriteSummoner(
    favoriteSummonerDto: FavoriteSummonerReq,
    userDto: UserReq,
  ): Promise<void> {
    const favoriteSummonerId = await this.findFavoriteSummonerIdWithSoftDelete(
      userDto.userId,
      favoriteSummonerDto.summonerId,
    );
    if (favoriteSummonerId)
      await this.favoriteSummonerRepository.restore(favoriteSummonerId.id);
  }

 

 

위 코드에서 아래와 같이 중복된 조회 쿼리를 이용하는 코드가 보입니다. 

 

await this.findFavoriteSummonerIdWithSoftDelete(
      userDto.userId,
      favoriteSummonerDto.summonerId,
    )

 

 

위의 코드를 saveFavoriteSummoner, restoreFavoriteSummoner 메서드에서 활용하고 있었습니다. 기존의 코드라면, 두 번의 조회 쿼리를 수행해야 하므로 상당히 비효율적입니다. 또한 하나의 트랜잭션으로 묶여 있지 않고, 각각의 트랜잭션에서 save 또는 restore가 수행되기에, 갑작스러운 문제로 API 호출에 실패할 경우에도 save 또는 restore 쿼리가 커밋될 수 있는 문제가 발생합니다. 이 문제를 해결해보겠습니다. 

 

 

먼저 조회 쿼리를 분리해보겠습니다. 

 

 

async createFavoriteSummoner(
    userDto: UserReq,
    favoriteSummonerDto: FavoriteSummonerReq,
  ): Promise<void> {
      await this.checkLimitFavoriteSummoner(userDto.userId);
      await this.redisClient.saveRedisSummonerRecord(favoriteSummonerDto);

      const [isFindSummonerRecordId, favoriteSummonerId] = await Promise.all([
        await this.isSummonerRecordBySummonerId(favoriteSummonerDto.summonerId),
        await this.findFavoriteSummonerIdWithSoftDelete(
          userDto.userId,
          favoriteSummonerDto.summonerId,
        ),
      ]);

      await this.saveOrRestoreTransaction(
        isFindSummonerRecordId,
        favoriteSummonerId,
        favoriteSummonerDto,
        userDto,
    } 
  }

 

 

각 메서드에 있던 쿼리를 밖으로 꺼내어 saveOrRestoreTransaction 메서드에 주입했습니다. 이를 통해 동일한 조회 쿼리를 두 번 활용하지 않아도 되게끔 설정했습니다. 또한 조회 쿼리의 경우 처리의 순서를 신경 쓸 필요 없으니, Promise.all을 활용하여 병렬적으로 처리하도록 설정했습니다. 

 

 

private async saveOrRestoreTransaction(
    isFindSummonerRecordId: boolean,
    favoriteSummonerId: FavoriteSummonerId,
    favoriteSummonerDto: FavoriteSummonerReq,
    userDto: UserReq,
  ) {
    const queryRunner: QueryRunner = this.connection.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();
    try {
      await this.saveSummonerRecord(
        isFindSummonerRecordId,
        favoriteSummonerDto,
        queryRunner,
      );
      await this.saveFavoriteSummoner(
        favoriteSummonerId,
        favoriteSummonerDto,
        userDto,
        queryRunner,
      );
      await this.restoreFavoriteSummoner(favoriteSummonerId, queryRunner);
      await queryRunner.commitTransaction();
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release();
    }
  }

 

 

또한 save 또는 restore 쿼리를 하나의 트랜잭션으로 관리하기 위해 QueryRunner를 활용했습니다. TypeORM의 Connection은 기본적으로 커넥션 풀을 활용하는데, 이때 실제 단일 데이터베이스 커넥션을 별도로 생성하여 세부적으로 트랜잭션을 관리하고 싶다면 QueryRunner를 사용할 수 있습니다. connection으로부터 createQueryRunner 함수를 통해 QueryRunner 인스턴스를 생성할 수 있습니다. 해당 인스턴스를 connect 함수를 통해 데이터베이스에 연결한 뒤 쿼리를 날리거나 트랜잭션을 수행할 수 있습니다. 

 

 

 

  • startTransaction(isolationLevel) 트랜잭션 시작
  • commitTransaction() 트랜잭션 커밋
  • rollbackTransaction() 트랜잭션 롤백

 

 

QueryRunner를 다 사용했다면 release를 통해 인스턴스를 해제해 줘야 합니다. 위와 같이 설정하여 트랜잭션을 제어할 수 있었습니다. 결과적으로 API를 보다 더 효율적으로 동작할 수 있도록 설정했습니다.

 

 

 

 

 

 


 

 

 

 

 

마치며

앞으로도 팀의 발전을 돕는 개발자가 되기 위해 노력하려 합니다. 팀에 필요한 부분이 무엇일지 고민하면서, 팀에 도움이 된다면, 열심히 공부해서 실무에 적용할 수 있는 개발자가 되기 위해 노력하고 싶습니다. 팀의 성장에 기여할 수 있는 개발자가 되겠습니다. 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

참고 및 출처

 

 

[TypeORM] 트랜잭션(Transaction) 사용하기

트랜잭션(Transaction)이란? 트랜잭션은 하나의 논리적 작업을 완료하기 위해 데이터베이스를 변화시키는 일련의 행동들을 하나의 단위로 바라보는 것이다. 예를 들어 '게시판에서 유저가 탈퇴한

dawitblog.tistory.com

 

Typeorm Transaction

트랜잭션은 요청을 처리하는 과정에서 데이터베이스에 변경이 일어나는 요청을 독립적으로 분리하고 에러가 발생했을 경우 이전 상태로 되돌리게 하기 위해 데이터베이스에서 제공하는 기능

velog.io

 

Promise.all 과 Transactions (feat. Node.js)

최근 팀에서 코드리뷰를 진행하던 중에 promise.all 과 트랜잭션에 대해 오해하고 있는 부분들을 발견하게 되어 정리하게 되었습니다. Promise.all (혹은 allSettled)을 사용하면 트랜잭션 내에서도 비동

jojoldu.tistory.com

 

https://typeorm.io/transactions

https://typeorm.io/query-runner#