Skip to content

TypeORM에서 하나의 Transaction 내에 발생한 오류가 Rollback되지 않는 문제 #376

sichoi42 edited this page Oct 8, 2022 · 17 revisions

1. 이슈 발생 배경



기존 발생 문제


/v3/api/lent/:cabinet_id API로 대여를 요청할 때 대여가 가능한 상황이라면 다음 프로세스가 수행됩니다.

1. lent 테이블에 값을 추가한다. 
2. 현재 캐비넷의 상태에 따라 다음 상태를 업데이트 하는 동작이 수행된다.
3. 해당 대여 요청으로 캐비넷이 꽉차거나 이미 만료 시간이 설정된 사물함을 대여하게 되면 만료시간을 설정하는 동작이 이루어진다.

즉, 하나의 API에서 DB에 업데이트 되는 정보가 2가지 이상이므로 Consistency가 깨지는 문제가 발생할 수 있습니다.
ex) ⚠️ 1번 요청은 성공을 했는데 2번 요청을 수행하다가 오류가 발생하면 상태가 변경되지 않은 상태로 대여 기록이 남아있는 문제가 발생.



기존 문제 해결 방법

이 문제를 해결하기 위해 위 일련의 과정을 하나의 Transaction으로 묶어 도중에 하나의 요청이라도 실패를 하게 되면
변경사항을 모두 Rollback을 시키도록 해결하려고 했습니다. 그러나....




2. 새로운 문제, 요청 수행 결과가 Rollback되지 않는다?!


Transaction 처리 코드 형식


async createMany(users: User[]) {
  const queryRunner = this.dataSource.createQueryRunner();

  await queryRunner.connect();
  await queryRunner.startTransaction();
  try {
    await queryRunner.manager.save(users[0]);
    await queryRunner.manager.save(users[1]);

    await queryRunner.commitTransaction();
  } catch (err) {
    // since we have errors lets rollback the changes we made
    await queryRunner.rollbackTransaction();
  } finally {
    // you need to release a queryRunner which was manually instantiated
    await queryRunner.release();
  }
}

위 코드는 Nest.js 공식 문서에서 추천한 방식입니다.
DateSource 클래스의 QueryRunner를 이용하여 Transaction을 이용하도록 가이드가 제시되어있습니다.
기존에는 이 형태 그대로 Cabi 서비스에도 적용을 했습니다.



오늘의 주제인 Rollback되지 않는 문제 발생


Cabi의 DDL에서 cabinet 테이블에는 다음과 같은 Column 있습니다.

`cabinet_status` varchar(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '사물함 상태',

기능을 구현하는 도중 cabinet_status 가 될 수 있는 상태가 아래와 같이 변경되었습니다.

💡 BEFORE

enum CabinetStatusType {
  AVAILABLE = 'AVAILABLE',
  FULL = 'FULL',
  BROKEN = 'BROKEN',
  BANNED = 'BANNED',
}

💡 AFTER

enum CabinetStatusType {
  AVAILABLE = 'AVAILABLE',
  NOT_SET_EXPIRE_FULL = 'NOT_SET_EXPIRE_FULL',
  SET_EXPIRE_FULL = 'SET_EXPIRE_FULL',
  SET_EXPIRE_AVAILABLE = 'SET_EXPIRE_AVAILABLE',
  BROKEN = 'BROKEN',
  BANNED = 'BANNED',
}

이렇게 변경한 이유는 현재 사물함 정책을 준수하기 위함입니다. 현재 사물함 정책은 다음과 같습니다.

1. 공유사물함은 최대 3인이 이용할 수 있다. 
2. 3인이 대여를 하기 전에는 무기한으로 사용을 할 수 있다.
3. 3인이 대여를 하는 순간, 3번째 대여자의 `lent_time`을 기준으로 45일 동안 대여가 가능합니다. (`expire_time`이 `lent_time + 45일`로 설정)
4. 이때 모든 사용자의 만료시간이 3번째 대여자의 `expire_time`으로 설정됩니다.

이와 같은 이유로 캐비넷의 상태는 6가지가 됩니다.

1. 만료시간이 설정되지 않았고 자리가 남아있는 상태
2. 만료시간이 설정되지 않았고 자리가 꽉 찬 상태
3. 만료시간이 설정되었고 자리가 꽉 찬 상태
4. 만료시간이 설정되었고 자리가 남아있는 상태
5. 고장난 상태
6. 임시 비활성화된 상태

이 과정에서

`cabinet_status` varchar(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '사물함 상태',

이 부분 때문에 NOT_SET_EXPIRE_FULL , SET_EXPIRE_AVAILABLE 이 두가지 상태의 바이트 수가 16바이트를 넘어
캐비넷의 상태를 업데이트 하는 과정에서 에러가 발생했습니다.
따라서 이 경우 lent 테이블에 추가했던 값을 롤백하는 작업이 이루어져야 합니다.

⚠️ 그러나 이 방법을 이용하니 예상과는 달리 쿼리문 수행 도중 문제가 발생했을 때 기존 변경사항을 롤백하는 과정이 이루어지지 않았습니다.



쿼리문은 문제가 없어보이는데...?


START TRANSACTION;
SELECT `Cabinet`.`cabinet_id` AS `Cabinet_cabinet_id`, `Cabinet`.`cabinet_num` AS `Cabinet_cabinet_num`, `Cabinet`.`location` AS `Cabinet_location`, `Cabinet`.`floor` AS `Cabinet_floor`, `Cabinet`.`section` AS `Cabinet_section`, `Cabinet`.`cabinet_status` AS `Cabinet_cabinet_status`, `Cabinet`.`lent_type` AS `Cabinet_lent_type`, `Cabinet`.`max_user` AS `Cabinet_max_user`, `Cabinet`.`min_user` AS `Cabinet_min_user`, `Cabinet`.`memo` AS `Cabinet_memo`, `Cabinet`.`title` AS `Cabinet_title` FROM `cabinet` `Cabinet` WHERE (`Cabinet`.`cabinet_id` = 83) LIMIT 1; -- PARAMETERS: [83]
INSERT INTO `lent`(`lent_id`, `lent_user_id`, `lent_cabinet_id`, `lent_time`, `expire_time`) VALUES (DEFAULT, 110819,83,'2022-10-03 15:08:33',null); -- PARAMETERS: [110819,83,"2022-10-03T06:08:54.252Z",null]
UPDATE `cabinet` SET `cabinet_status` = 'NOT_SET_EXPIRE_FULL' WHERE `cabinet_id` IN (83); -- PARAMETERS: ["NOT_SET_EXPIRE_FULL",83]
ROLLBACK;

그래서 실제 수행된 쿼리문을 살펴보니 쿼리문 수행은 문제 없이 출력이 되어있습니다.
실제 저 쿼리문을 Datagrip을 통해 실행하니 정상적으로 Rollback이 이루어집니다.



출력과 실제 동작 순서가 일치하지 않는다면..?


이 일련의 과정은 async await을 통해 수행됩니다. 따라서 비동기 처리 함수에서 로그 출력은 동기화가 되지 않으므로
실제 동작이 일어난 시점과 로그가 출력된 시점이 다를 수도 있다는 의견을 joopark님이 제시해주셨습니다.
DB에 접속하여 쿼리문을 날리는 일련의 과정도 네트워킹 과정이므로
Wireshark를 통해 network adapter에서 실제로 패킷이 오고간 기록을 확인했습니다.(Packet Sniff)

💡 그러나 이 기록도 실제 출력과 일치했습니다. 즉, 쿼리문 수행 순서가 엉켜서 생긴 문제는 아니었다는 것입니다.



3. 문제 해결 !! 🎉



typeorm github에 관련 이슈가 있는지 찾아보다가 결과를 못찾아서 구글링을 하다보니
https://stackoverflow.com/questions/68697866/typeorm-transaction-with-query-builder
스택 오버플로우에 저희와 동일한 문제가 발생한 사례가 있었습니다.
질문자분은 스스로 문제를 해결하고 해결 방법을 공유해주셨는데요,
그 해결방법대로 코드를 수정해봤습니다.



QueryRunner를 인자로 넣어서 쿼리문 수행


async lentCabinet(
    user_id: number,
    cabinet_id: number,
  ): Promise<void> {
    const lent_time = new Date();
    let expire_time: Date | null = null;
    await this.lentRepository.createQueryBuilder(this.lentCabinet.name)
    .insert()
    .into(Lent)
    .values({
      lent_user_id: user_id,
      lent_cabinet_id: cabinet_id,
      lent_time: lent_time,
      expire_time: expire_time,
    })
    .execute();
  }

위 코드는 기존에 작성되어있던 `Repository` 함수입니다.

async lentCabinet(
    user_id: number,
    cabinet_id: number,
    queryRunner: QueryRunner,
  ): Promise<void> {
    const lent_time = new Date();
    let expire_time: Date | null = null;
    await this.lentRepository.createQueryBuilder(this.lentCabinet.name, queryRunner)
    .insert()
    .into(Lent)
    .values({
      lent_user_id: user_id,
      lent_cabinet_id: cabinet_id,
      lent_time: lent_time,
      expire_time: expire_time,
    })
    .execute();
  }

그리고 위는 새로 작성한 정상적으로 Rollback이 이루어지는 코드입니다.

Transaction을 시작했던 QueryRunner의 인스턴스를
그대로 Repository함수에서 createQueryBuilder 함수의 인자로 넣어주어야 정상적으로 롤백이 이루어집니다.
기존에 사용했던 코드로는 transaction을 시작한 인스턴스와 실제로 쿼리문을 수행하게되는 인스턴스가 서로 달라
lentCabinet Repository 수행문이 하나의 Transaction으로 취급하지 않아 롤백이 되지 않았던 것으로 추정됩니다.

Clone this wiki locally