동시성 문제 발생
어드민 페이지에서 사물함을 대여 중인 유저를 강제 반납 시키는 로직이 있다. 해당 로직은 단일 유저를 반납하는데 사용하면 전혀 문제가 없지만, 같은 사물함의 여러 유저를 일괄적으로 반납 시키려하면 DeadLock 발생한다.
유저를 강제로 반납 시키는 로직은 다음과 같다.
LocalDateTime now = LocalDateTime.now();
LentHistory userLentHistory = lentQueryService.getUserActiveLentHistory(userId);
Cabinet cabinet = cabinetQueryService.getCabinets(userLentHistory.getCabinetId());
List<LentHistory> cabinetLentHistories =
lentQueryService.findCabinetActiveLentHistories(cabinet.getCabinetId());
int userRemainCount = cabinetLentHistories.size() - 1;
LocalDateTime endedAt = userLentHistory.getEndedAt();
cabinetCommandService.changeUserCount(cabinet, userRemainCount);
lentCommandService.endLent(userLentHistory, now);
lentRedisService.setPreviousUserName(
cabinet.getCabinetId(), userLentHistory.getUser().getName());
if (cabinet.isLentType(SHARE)) {
LocalDateTime expiredAt = lentPolicyService.adjustSharCabinetExpirationDate(
userRemainCount, now, userLentHistory);
cabinetLentHistories.stream().filter(lh -> !lh.equals(userLentHistory))
.forEach(lh -> lentCommandService.setExpiredAt(lh, expiredAt));
}
BanType banType = banPolicyService.verifyBan(now, userLentHistory.getExpiredAt());
if (!banType.equals(BanType.NONE)) {
LocalDateTime unbannedAt = banPolicyService.getUnBannedAt(
endedAt, userLentHistory.getExpiredAt());
banHistoryCommandService.banUser(userId, endedAt, unbannedAt, banType);
}
Java
복사
이러한 상황에서 Deadlock이 왜 발생하는지 알아보고, 해결해보자.
원인 분석
먼저 Join이나 Fetch Join을 사용하면, 연관관계 데이터가 존재하는지 확인하기 위해 연관관계를 가지는 상대 테이블에 잠금이 전파된다. 이때 해당 테이블에 S Lock이 걸려 문제가 더 복잡해지므로, 먼저 알기 쉽게 Join 없이 테스트 해보았다.
우선적으로 이렇게 데이터를 수정하는 로직에서 Lock 없이 데이터를 조회하면 데이터 정합성 문제가 발생하는 것을 알고 있지만, 현재의 문제를 정확히 파악하기 위해 아무런 Lock 없이 조회 로직을 수행해봤다.
위의 로직을 보면 알 수 있듯, 조회 로직은 userId로 LentHistory select → cabinetId로 Cabinet select → cabinetId로 LentHistory select → Cabinet update → LentHistory update 순으로 이루어진다. 이러한 상황에서 아무런 Lock도 걸지 않고 로직을 수행해도 DeadLock이 발생한다.
*** (1) TRANSACTION:
TRANSACTION 442, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 1151, OS thread handle 281472497926448, query id 17927 172.18.0.1 root Updating
update `lent_history` set `cabinet_id`=112, `ended_at`=NULL, `expired_at`='2024-02-01 23:59:59.001199', `started_at`='2023-11-28 13:10:07.260901', `user_id`=479, `version`=5 where `lent_history_id`=19387 and `version`=4
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 9 page no 147 n bits 280 index PRIMARY of table `cabi_local`.`lent_history` trx id 442 lock_mode X locks rec but not gap waiting
Record lock, heap no 210 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
0: len 8; hex 8000000000004bbb; asc K ;;
1: len 6; hex 0000000001b9; asc ;;
2: len 7; hex 4d000001b00110; asc M ;;
3: len 8; hex 99b1ef6e820004f8; asc n ;;
4: len 8; hex 99b2d77ec0029183; asc ~ ;;
5: len 8; hex 99b1b8d28703fb25; asc %;;
6: len 8; hex 8000000000000070; asc p;;
7: len 8; hex 80000000000001df; asc ;;
8: len 8; hex 8000000000000005; asc ;;
*** (2) TRANSACTION:
TRANSACTION 441, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 1152, OS thread handle 281472496390448, query id 17926 172.18.0.1 root Updating
update `lent_history` set `cabinet_id`=112, `ended_at`=NULL, `expired_at`='2024-02-01 23:59:59.001272', `started_at`='2023-11-28 13:10:07.260901', `user_id`=410, `version`=5 where `lent_history_id`=19385 and `version`=4
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 9 page no 147 n bits 280 index PRIMARY of table `cabi_local`.`lent_history` trx id 441 lock_mode X locks rec but not gap
Record lock, heap no 210 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
0: len 8; hex 8000000000004bbb; asc K ;;
1: len 6; hex 0000000001b9; asc ;;
2: len 7; hex 4d000001b00110; asc M ;;
3: len 8; hex 99b1ef6e820004f8; asc n ;;
4: len 8; hex 99b2d77ec0029183; asc ~ ;;
5: len 8; hex 99b1b8d28703fb25; asc %;;
6: len 8; hex 8000000000000070; asc p;;
7: len 8; hex 80000000000001df; asc ;;
8: len 8; hex 8000000000000005; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 9 page no 147 n bits 280 index PRIMARY of table `cabi_local`.`lent_history` trx id 441 lock_mode X locks rec but not gap waiting
Record lock, heap no 211 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
0: len 8; hex 8000000000004bb9; asc K ;;
1: len 6; hex 0000000001ba; asc ;;
2: len 7; hex 4d000001b10110; asc M ;;
3: len 8; hex 99b1ef6e820004af; asc n ;;
4: len 8; hex 99b2d77ec0029183; asc ~ ;;
5: len 8; hex 99b1b8d28703fb25; asc %;;
6: len 8; hex 8000000000000070; asc p;;
7: len 8; hex 800000000000019a; asc ;;
8: len 8; hex 8000000000000005; asc ;;
*** WE ROLL BACK TRANSACTION (2)
------------
TRANSACTIONS
------------
Java
복사
Deadlock이 발생하는 이유는
1.
각자의 LentHistory에 update를 하며 X Lock(Record Lock) 획득
2.
같은 사물함 내의 LentHistory들을 순회하며 X Lock 획득 시도
3.
1번 과정에서 각각 자신의 LentHistory에 update를 하며 X Lock이 걸어놓았기 때문에 Deadlock 발생
1차 시도
Lock을 걸었을 때의 상황을 예측하는 것은 가능하지만, 조금 더 정확하게 분석하기 위해 모든 쿼리에 위와 같이 X Lock을 걸고 동작을 수행해보았다.
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-12-23 14:22:37 0xffff6c40c130
*** (1) TRANSACTION:
TRANSACTION 453, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 6 row lock(s)
MySQL thread id 1192, OS thread handle 281472496390448, query id 18326 172.18.0.1 root Statistics
select cabinet0_.`cabinet_id` as cabinet_1_3_, cabinet0_.`cabinet_place_id` as cabinet11_3_, cabinet0_.`col` as col2_3_, cabinet0_.`row` as row3_3_, cabinet0_.`lent_type` as lent_typ4_3_, cabinet0_.`max_user` as max_user5_3_, cabinet0_.`memo` as memo6_3_, cabinet0_.`status` as status7_3_, cabinet0_.`status_note` as status_n8_3_, cabinet0_.`title` as title9_3_, cabinet0_.`visible_num` as visible10_3_ from `cabinet` cabinet0_ where cabinet0_.`cabinet_id`=1 for update
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 7 page no 5 n bits 200 index PRIMARY of table `cabi_local`.`cabinet` trx id 453 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 14; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000000000; asc ;;
2: len 7; hex 80000000000000; asc ;;
3: len 4; hex 80000000; asc ;;
4: len 4; hex 80000000; asc ;;
5: len 5; hex 5348415245; asc SHARE;;
6: len 4; hex 80000004; asc ;;
7: len 4; hex 46554c4c; asc FULL;;
8: len 0; hex ; asc ;;
9: len 4; hex 80000059; asc Y;;
10: len 8; hex 8000000000000005; asc ;;
11: len 0; hex ; asc ;;
12: SQL NULL;
13: len 8; hex 8000000000000001; asc ;;
*** (2) TRANSACTION:
TRANSACTION 454, ACTIVE 0 sec fetching rows
mysql tables in use 1, locked 1
27 lock struct(s), heap size 3488, 51 row lock(s)
MySQL thread id 1191, OS thread handle 281472497926448, query id 18327 172.18.0.1 root Sending data
select lenthistor0_.`lent_history_id` as lent_his1_6_, lenthistor0_.`cabinet_id` as cabinet_2_6_, lenthistor0_.`ended_at` as ended_at3_6_, lenthistor0_.`expired_at` as expired_4_6_, lenthistor0_.`started_at` as started_5_6_, lenthistor0_.`user_id` as user_id6_6_, lenthistor0_.`version` as version7_6_ from `lent_history` lenthistor0_ where lenthistor0_.`cabinet_id`=1 and (lenthistor0_.`ended_at` is null) for update
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 7 page no 5 n bits 200 index PRIMARY of table `cabi_local`.`cabinet` trx id 454 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 14; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000000000; asc ;;
2: len 7; hex 80000000000000; asc ;;
3: len 4; hex 80000000; asc ;;
4: len 4; hex 80000000; asc ;;
5: len 5; hex 5348415245; asc SHARE;;
6: len 4; hex 80000004; asc ;;
7: len 4; hex 46554c4c; asc FULL;;
8: len 0; hex ; asc ;;
9: len 4; hex 80000059; asc Y;;
10: len 8; hex 8000000000000005; asc ;;
11: len 0; hex ; asc ;;
12: SQL NULL;
13: len 8; hex 8000000000000001; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 9 page no 147 n bits 280 index PRIMARY of table `cabi_local`.`lent_history` trx id 454 lock_mode X locks rec but not gap waiting
Record lock, heap no 8 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
0: len 8; hex 8000000000004bb6; asc K ;;
1: len 6; hex 000000000000; asc ;;
2: len 7; hex 80000000000000; asc ;;
3: SQL NULL;
4: len 8; hex 99b2b77ec00b6e57; asc ~ nW;;
5: len 8; hex 99b1b8d2850b6e57; asc nW;;
6: len 8; hex 8000000000000001; asc ;;
7: len 8; hex 800000000000044a; asc J;;
8: len 8; hex 8000000000000002; asc ;;
*** WE ROLL BACK TRANSACTION (1)
------------
TRANSACTIONS
------------
Java
복사
여기서 Deadlock이 발생하는 이유는
1.
각자의 LentHistory에 select를 하며 X Lock(Record Lock) 획득
2.
동일 Cabinet에 select를 하며 트랜잭션 A는 X Lock 획득, 다른 트랜잭션 B는 X Lock 대기
3.
해당 Cabinet에 있는 모든 LentHistory에 select 하며 트랜잭션 A에서 X Lock 획득 시도
4.
이미 B가 userId에 맞는 LentHistory에 X Lock을 획득한 상태이므로 DeadLock 발생
2차 시도(Fetch Join 도입)
단순히 Lock과 로직의 순서만 바꾸는 것으로는 해결이 힘들다고 판단하여, 연관관계 테이블 간의 잠금 전파를 고려하여 Fetch Join을 사용하는 로직으로 수정하여 다시 테스트 해보았다.
LentHistory userLentHistory = lentQueryService.getUserActiveLentHistoryWithLock(userId);
Cabinet cabinet = userLentHistory.getCabinet();
List<LentHistory> cabinetLentHistories =
lentQueryService.findCabinetActiveLentHistoriesWithLock(cabinet.getCabinetId());
Java
복사
userId로 LentHistory + Cabinet Fetch Join 조회 → cabinetId로 LentHistory 조회
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-12-23 15:04:19 0xffff6c376130
*** (1) TRANSACTION:
TRANSACTION 456, ACTIVE 0 sec starting index read
mysql tables in use 3, locked 3
LOCK WAIT 8 lock struct(s), heap size 1128, 6 row lock(s)
MySQL thread id 1241, OS thread handle 281472498233648, query id 19077 172.18.0.1 root Sending data
select lenthistor0_.`lent_history_id` as lent_his1_6_0_, user1_.`user_id` as user_id1_7_1_, cabinet2_.`cabinet_id` as cabinet_1_3_2_, lenthistor0_.`cabinet_id` as cabinet_2_6_0_, lenthistor0_.`ended_at` as ended_at3_6_0_, lenthistor0_.`expired_at` as expired_4_6_0_, lenthistor0_.`started_at` as started_5_6_0_, lenthistor0_.`user_id` as user_id6_6_0_, lenthistor0_.`version` as version7_6_0_, user1_.`blackholed_at` as blackhol2_7_1_, user1_.`deleted_at` as deleted_3_7_1_, user1_.`email` as email4_7_1_, user1_.`name` as name5_7_1_, user1_.`role` as role6_7_1_, cabinet2_.`cabinet_place_id` as cabinet11_3_2_, cabinet2_.`col` as col2_3_2_, cabinet2_.`row` as row3_3_2_, cabinet2_.`lent_type` as lent_typ4_3_2_, cabinet2_.`max_user` as max_user5_3_2_, cabinet2_.`memo` as memo6_3_2_, cabinet2_.`status` as status7_3_2_, cabinet2_.`status_note` as status_n8_3_2_, cabinet2_.`title` as title9_3_2_, cabinet2_.`visible_num` a
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 7 page no 5 n bits 200 index PRIMARY of table `cabi_local`.`cabinet` trx id 456 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 14; compact format; info bits 0
0: len 8; hex 8000000000000002; asc ;;
1: len 6; hex 000000000000; asc ;;
2: len 7; hex 80000000000000; asc ;;
3: len 4; hex 80000001; asc ;;
4: len 4; hex 80000000; asc ;;
5: len 5; hex 5348415245; asc SHARE;;
6: len 4; hex 80000004; asc ;;
7: len 4; hex 46554c4c; asc FULL;;
8: len 0; hex ; asc ;;
9: len 4; hex 8000005a; asc Z;;
10: len 8; hex 8000000000000005; asc ;;
11: len 3; hex 302f33; asc 0/3;;
12: SQL NULL;
13: len 8; hex 8000000000000001; asc ;;
*** (2) TRANSACTION:
TRANSACTION 457, ACTIVE 0 sec fetching rows
mysql tables in use 1, locked 1
29 lock struct(s), heap size 3488, 211 row lock(s)
MySQL thread id 1242, OS thread handle 281472497312048, query id 19079 172.18.0.1 root Sending data
select lenthistor0_.`lent_history_id` as lent_his1_6_, lenthistor0_.`cabinet_id` as cabinet_2_6_, lenthistor0_.`ended_at` as ended_at3_6_, lenthistor0_.`expired_at` as expired_4_6_, lenthistor0_.`started_at` as started_5_6_, lenthistor0_.`user_id` as user_id6_6_, lenthistor0_.`version` as version7_6_ from `lent_history` lenthistor0_ where lenthistor0_.`cabinet_id`=2 and (lenthistor0_.`ended_at` is null) for update
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 7 page no 5 n bits 200 index PRIMARY of table `cabi_local`.`cabinet` trx id 457 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 14; compact format; info bits 0
0: len 8; hex 8000000000000002; asc ;;
1: len 6; hex 000000000000; asc ;;
2: len 7; hex 80000000000000; asc ;;
3: len 4; hex 80000001; asc ;;
4: len 4; hex 80000000; asc ;;
5: len 5; hex 5348415245; asc SHARE;;
6: len 4; hex 80000004; asc ;;
7: len 4; hex 46554c4c; asc FULL;;
8: len 0; hex ; asc ;;
9: len 4; hex 8000005a; asc Z;;
10: len 8; hex 8000000000000005; asc ;;
11: len 3; hex 302f33; asc 0/3;;
12: SQL NULL;
13: len 8; hex 8000000000000001; asc ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 9 page no 147 n bits 280 index PRIMARY of table `cabi_local`.`lent_history` trx id 457 lock_mode X locks rec but not gap waiting
Record lock, heap no 21 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
0: len 8; hex 8000000000004bc4; asc K ;;
1: len 6; hex 000000000000; asc ;;
2: len 7; hex 80000000000000; asc ;;
3: SQL NULL;
4: len 8; hex 99b2fb7ec00d2dc0; asc ~ - ;;
5: len 8; hex 99b1b8d2880d2dc0; asc - ;;
6: len 8; hex 8000000000000002; asc ;;
7: len 8; hex 8000000000000421; asc !;;
8: len 8; hex 8000000000000003; asc ;;
*** WE ROLL BACK TRANSACTION (1)
------------
TRANSACTIONS
------------
Java
복사
Deadlock이 발생하는 이유는
1.
Fetch Join 로직에서 LentHistory에 대한 X Lock 획득
+ Cabinet에 대해 트랜잭션 A는 X Lock 획득, 트랜잭션 B는 X Lock 대기
2.
트랜잭션 A에서 해당 Cabinet에 포함된 LentHistory 조회 시 X Lock 획득 시도
3.
1번 로직에서 트랜잭션 B가 Cabinet에 대한 Lock은 얻지 못했지만, 자신의 LentHistory에 X Lock을 걸어놓았기 때문에 Deadlock 발생
추가 테스트 및 결과 분석
위의 테스트 외에도 다양한 조건으로 Fetch Join을 시도해보고 일부에만 Lock을 풀어보기도 하면서, 여러 조건들을 조금씩 바꾸어가며 수많은 테스트를 진행해보았다. 사실 테스트 하는 와중에는 단순하게 머리로만 생각하니까 이러면 될 것 같은데? 싶어도 Deadlock이 발생하는 경우가 많았다. 테스트를 반복하던 중에 이런 식으로 하면 끝도 없겠다 싶어서 글로 정리하다보니, 여러 조건들을 바꿔도 계속 Deadlock이 발생하는 원인을 알 수 있었고 그에 따른 해결책도 자연스럽게 떠올랐다.
먼저 위 로직은 userId를 기준으로 해당 유저의 현재 대여 중인 기록(LentHistory)을 조회하고, 그에 따라 LentHistory에 저장된 cabinetId를 받아 사물함의 정보 기반으로 해당 사물함을 대여 중인 다른 대여 기록들을 조회한다. 이는 현재 DB 구조가 User - LentHistory - Cabinet 형태로 연관관계를 가지고 있고, 그 관계가 OneToMany - ManyToOne 관계이므로 LentHistory가 모든 연관관계의 주인이 된다. 그로 인해 LentHistory에 CabinetId와 UserId가 저장되는데, 이런 구조로 인해 userId라는 정보만으로는 항상 LentHistory가 먼저 조회되어야 나머지 정보들도 조회해올 수 있다.
다만 여기서 문제가 되는 점은 Admin 페이지에서 userId로 반납을 할 수 있는 경우는 동일한 공유 사물함을 같이 사용하는 유저들을 일괄 반납하는 경우 밖에 없다는 것이다. 이 때문에 첫 조회인 LentHistory를 단일 userId로 조회를 할 때 Lock을 걸게되면, 이후의 Cabinet 조회 → LentHistory 조회 로직에서 무조건 Deadlock이 발생할 수 밖에 없다는 것이다. 그렇다고 해서 Lock을 걸지 않고 조회하더라도 update 구문에서 Deadlock이 발생하고 추가적으로 데이터 정합성 문제가 발생한다.
이를 위한 해결 방법은 대략 세 가지 정도 떠오른다.
1.
LentHistory에 Table Lock 걸기
2.
Sub Query를 통해 첫 조회 시 해당 Cabinet의 모든 LentHistory에 Lock을 걸게 만들기
3.
공유 사물함의 유저 일괄 반납을 userId마다 매번 요청 보내는 것이 아니라, userId들을 묶어서 하나의 요청으로 보내기
Table Lock
이전에 InnoDB의 락 종류를 공부해보면서, 어떤 락이 있고 어떤 식으로 데이터에 잠금을 거는지 공부해본 적이 있다.
위의 Deadlock 발생과 관련하여 트랜잭션 Lock 메세지 출력 로그를 보면, Record Lock을 걸었다는 말이 나온다. 사실 이는 이번 동시성 문제의 핵심을 관통하는 Lock 방식인데, 말 그대로 테이블의 레코드에 Lock을 거는 방식이다.
위 그림은 이번 동시성 문제에서 발생하는 원인을 나타낸 것인데, 같은 LentHistory 테이블이더라도 서로 다른 레코드에 각각 Lock이 걸어놓은 채로 다른 트랜잭션이 Lock을 건 레코드를 참조 하려고 하니 발생한 문제다.
Table Lock을 통한 해결책은 이처럼 LentHistory 테이블 자체에 Lock을 걸어, 다른 트랜잭션에서 Lock을 걸어놓지 못하게 하여 문제를 해결하는 방법이다. 하지만 JPA에서 지원하지 않고 테이블 락 방식은 동시성이 크게 떨어져 권장되지 않는다.
SubQuery로 해결하기
문제의 원인이 여러 트랜잭션에서 첫 조회 시 LentHistory 하나에만 X Lock을 거는 것이다. 때문에 첫 조회에서 해당 Cabinet의 모든 LentHistory에 X Lock을 걸어서 데이터를 가져와 해결하는 방법이다.
List<LentHistory> lentHistories =
lentQueryService.findUserActiveLentHistoriesInCabinet(userId);
Cabinet cabinet = cabinetQueryService.getCabinets(lentHistories.get(0).getCabinetId());
LentHistory userLentHistory = lentHistories.stream()
.filter(lh -> lh.getUserId().equals(userId)).findFirst()
.orElseThrow(() -> new RuntimeException("사용자가 빌린 사물함이 없습니다."));
Java
복사
이와 같이 서브 쿼리와 로직을 수정하고 실행해보면,
동시성 문제가 해결되었다…!
결론 + HTTP 요청 줄이는 방법
위 방법을 통해 동시성 문제를 해결하긴 했지만, 사실 요청에 대한 네트워크 부하를 고려하거나 userId들을 묶어서 한 번에 조회하여 처리할 수 있다는 점을 생각하면 가장 이상적인 해결책은 API 호출을 줄이는 방법이다. 사실 이 방식은 위 테스트들을 수행하기 전에도 이미 알고 있었지만, 원인 분석 없이 회피하는 방식으로는 깊게 공부할 수 없고 이 기회에 동시성 문제를 제대로 한 번 다루어보고 싶었다.
이미 동시성 문제를 해결한 코드를 git에 올려두었지만, 추후 프론트 분들과 이야기하고 HTTP 요청을 줄이는 방식으로 수정하게 될 것 같다. HTTP 요청이 많은 것과 그로 인해 쿼리가 많이 발생하는 것, 또 이를 해결하기 위한 Lock 적용으로 인한 성능적 저하 등을 고려했을 때 HTTP 요청을 줄이는 방식이 가장 적합하다.
추가적으로 현재의 서브쿼리 방식도 결국 4명의 유저를 전부 반납하는 경우에는, 첫 번째 요청 시 나머지 3명의 반납 기한 조정 + 두 번째 요청 시 나머지 2명의 반납 기한 조정 + 세 번째 요청 시 남은 1명 반납 기한 조정의 순으로 동작하기 때문에 불필요한 update가 최대 6번 발생하게 된다.
그동안 서브쿼리는 안좋은 것이라는 인식이 있었는데, 이번 동시성 문제를 해결해보면서 너무 남용하지 않고 필요에 따라 적절하게 사용한다면 괜찮을 것 같다는 생각을 하게 되었다.