-
Notifications
You must be signed in to change notification settings - Fork 14
TypeORM에서 하나의 Transaction 내에 발생한 오류가 Rollback되지 않는 문제 #376
/v3/api/lent/:cabinet_id API
로 대여를 요청할 때 대여가 가능한 상황이라면 다음 프로세스가 수행됩니다.
1. lent 테이블에 값을 추가한다.
2. 현재 캐비넷의 상태에 따라 다음 상태를 업데이트 하는 동작이 수행된다.
3. 해당 대여 요청으로 캐비넷이 꽉차거나 이미 만료 시간이 설정된 사물함을 대여하게 되면 만료시간을 설정하는 동작이 이루어진다.
즉, 하나의 API에서 DB에 업데이트 되는 정보가 2가지 이상이므로 Consistency
가 깨지는 문제가 발생할 수 있습니다.
ex)
이 문제를 해결하기 위해 위 일련의 과정을 하나의 Transaction
으로 묶어 도중에 하나의 요청이라도 실패를 하게 되면
변경사항을 모두 Rollback
을 시키도록 해결하려고 했습니다. 그러나....
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 서비스에도 적용을 했습니다.
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)
💡 그러나 이 기록도 실제 출력과 일치했습니다. 즉, 쿼리문 수행 순서가 엉켜서 생긴 문제는 아니었다는 것입니다.
typeorm github에 관련 이슈가 있는지 찾아보다가 결과를 못찾아서 구글링을 하다보니
https://stackoverflow.com/questions/68697866/typeorm-transaction-with-query-builder
스택 오버플로우에 저희와 동일한 문제가 발생한 사례가 있었습니다.
질문자분은 스스로 문제를 해결하고 해결 방법을 공유해주셨는데요,
그 해결방법대로 코드를 수정해봤습니다.
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
으로 취급하지 않아 롤백이 되지 않았던 것으로 추정됩니다.