[토이 프로젝트] 동시성 이슈 : 데드락 발생 원인
카테고리: Ayu-coupon
들어가며
쿠폰 발행 및 사용 플랫폼을 개발하면서, 쿠폰 발행 기능을 구현하였습니다.
쿠폰 발행 기능을 구현할 때, 주의해야 할 점은 쿠폰이 발급될 수 있는 양보다 많이 발급되는 초과 지급입니다.
쿠폰 초과 지급같은 현상은 여러 스레드가 쿠폰 재고 데이터 동시에 접근하기 때문에 발생하며, 이러한 현상을 동시성 이슈
라고 합니다.
만약 동시성 이슈를 고려하지 않는다면, 위의 그림과 같이 쿠폰 초과 지급
와 같은 이슈가 발생하는데요.
그 뿐만아니라, 주의하지 않으면 데드락
발생 후 트랜잭션 롤백되는 이슈도 발생합니다.
본 포스팅은 쿠폰 발급 기능을 구현하면서 발생했던 데드락
이 발생하는 이유와 해결 과정에 대해서 설명하려합니다.
전체 코드는 github를 참고해주세요
쿠폰 지급 관련 코드
아래는 쿠폰 지급과 관련한 엔티티와 쿠폰 지급 서비스 코드입니다.
DB에는 quantity
가 1인 Coupon
데이터가 저장되어 있습니다.
엔티티
@Entity
@Table(name = "coupon")
public class Coupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long couponId;
@Column(name = "quantity")
private Long quantity; // 쿠폰의 남은 수량
public void decrease() {
if (quantity <= 0) throw new IllegalStateException("쿠폰의 재고가 없습니다.");
quantity--;
}
}
===
@Entity
@Table(name = "user_coupon")
public class UserCoupon extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userCouponId;
@ManyToOne
@JoinColumn(name = "coupon_id")
private Coupon coupon; // 발급된 쿠폰에 대한 정보를 가지고 있는 엔티티
}
위의 코드를 간단히 설명하면 다음과 같습니다.
Coupon
: 발급 가능한 쿠폰의 남은 수량
에 대한 정보를 가지고 있는 쿠폰 엔티티
UserCoupon
: 사용자에게 발급된 유저 쿠폰 엔티티
서비스 코드
@RequiredArgsConstructor
@Service
public class IssueCouponService {
private final CouponRepository couponRepository;
private final UserCouponRepository userCouponRepository;
@Transactional
public void issue(Long couponId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(RuntimeException::new);
coupon.decrease();
UserCoupon issuedCoupon = new UserCoupon(coupon);
userCouponRepository.save(issuedCoupon);
}
}
위의 코드는 동시성 이슈도 해결하지 못할 뿐더러, 아쉽게도 아래의 메세지와 함께 데드락도 발생합니다.
could not execute statement [Deadlock found when trying to get lock; try restarting transaction]
테스트 코드
@Autowired
private IssueCouponService issueCouponService;
@Test
public void 재고가_1_남은_쿠폰에_대해_동시에_2개의_발급_요청 () throws InterruptedException {
//given
Long couponId = 1L;
int numberOfThreads = 2;
ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
//when
for (int i = 1; i <= numberOfThreads; i++) {
service.execute(() -> {
issueCouponService.issue(couponId);
latch.countDown();
});
}
latch.await();
}
데드락 발생 이유
먼저 왜 데드락이 발생했는 확인하기 위하여 mysql의 show engine innodb status
명령어를 통해 확인하였습니다.
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-10-01 03:53:45 0x16ff3b000
*** (1) TRANSACTION:
TRANSACTION 104474, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 1582, OS thread handle 6195179520, query id 14185 localhost 127.0.0.1 root updating
update coupon set quantity=0 where coupon_id=1
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 3829 page no 4 n bits 72 index PRIMARY of table `coupon_db`.`coupon` trx id 104474 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 3829 page no 4 n bits 72 index PRIMARY of table `coupon_db`.`coupon` trx id 104474 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
*** (2) TRANSACTION:
TRANSACTION 104475, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 1583, OS thread handle 6196293632, query id 14186 localhost 127.0.0.1 root updating
update coupon set quantity=0 where coupon_id=1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 3829 page no 4 n bits 72 index PRIMARY of table `coupon_db`.`coupon` trx id 104475 lock mode S locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 3829 page no 4 n bits 72 index PRIMARY of table `coupon_db`.`coupon` trx id 104475 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
*** WE ROLL BACK TRANSACTION (2)
위의 내용을 통해 데드락이 발생한 이유는 Coupon.coupon_id = 1
에 공유 락이 걸려있는 상태에서 2개의 트랜잭션이 각각 coupon 업데이트 쿼리를 질의 했기 때문인 것을 알 수 있었습니다.
처음 기대했던 질의 순서는 다음 그림과 같습니다.
하지만 실제 DB에 질의된 쿼리는 아래와 같습니다.
Id Command Argument
1519 Query SET autocommit=0
1520 Query SET autocommit=0
1520 Query select c1_0.coupon_id,c1_0.quantity from coupon c1_0 where c1_0.coupon_id=1
1519 Query select c1_0.coupon_id,c1_0.quantity from coupon c1_0 where c1_0.coupon_id=1
1520 Query insert into user_coupon (coupon_id) values (1)
1519 Query insert into user_coupon (coupon_id) values (1)
1519 Query update coupon set quantity=0 where coupon_id=1
1520 Query update coupon set quantity=0 where coupon_id=1
1519 Query commit
1519 Query SET autocommit=1
1520 Query rollback // 트랜잭션 롤백 발생
1520 Query SET autocommit=1
위의 쿼리들을 그림으로 표현하면 다음과 같습니다.
즉 다음과 같은 상황입니다.
외래키 제약 조건을 충족하기 위해 user_coupon 테이블에 레코드를 insert할 때, coupon 테이블의 해당 레코드도 shared lock에 의해 잠기게 됩니다.
따라서, 트랜잭션 1과 트랜잭션 2는 coupon 테이블의 해당 레코드에 lock이 풀리길 서로 기다리는 상태, 즉 데드락이 발생합니다.
@Transactional
으로는 데드락과 동시성 문제 해결 할 수 없다.
스프링의 @Transactional
을 사용하여, 동시성 문제를 해결하려 했습니다.
트랜잭션을 통해 연산을 하면 재고 관련 데이터에 락을 걸주면서 동시성 문제가 해결 될 것이라 막연히 예상했기 때문이었는데요.
결국 race condition
이 발생했기 때문에 두 개의 쓰레드(요청)이 하나의 공유된 자원을 동시에 읽고, 업데이트를 하려고 했기 때문에 예상과는 다른 결과가 나왔습니다.
그렇다면, 트랜잭션이 serial
하게 실행될 수 있도록 하면 어떨까요?
JPA의 쓰기 지연을 조심하자
트랜잭션을 serial
하게 실행시키기 위해, 아래의 코드와 같이 synchronized
키워드를 추가했지만 여전히 could not execute statement [Deadlock found when trying to get lock; try restarting transaction]
메세지와 함께 데드락이 발생했습니다.
@RequiredArgsConstructor
@Service
public class IssueCouponService {
private final CouponRepository couponRepository;
private final UserCouponRepository userCouponRepository;
@Transactional
// synchronized 키워드 추가
public synchronized void issue(Long couponId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(RuntimeException::new);
coupon.decrease();
UserCoupon issuedCoupon = new UserCoupon(coupon);
userCouponRepository.save(issuedCoupon);
}
}
원인을 파악하기 위해, 또 한번 DB에 질의된 쿼리를 확인했습니다.
Id Command Argument
1583 Query SET autocommit=0
1582 Query SET autocommit=0
1582 Query select c1_0.coupon_id,c1_0.quantity from coupon c1_0 where c1_0.coupon_id=1
1582 Query insert into user_coupon (coupon_id) values (1)
1583 Query select c1_0.coupon_id,c1_0.quantity from coupon c1_0 where c1_0.coupon_id=1
1583 Query insert into user_coupon (coupon_id) values (1)
// 쿼리의 순서가 달라짐
1582 Query update coupon set quantity=0 where coupon_id=1
1583 Query update coupon set quantity=0 where coupon_id=1
1582 Query commit
1582 Query SET autocommit=1
1583 Query rollback
1583 Query SET autocommit=1
위에서 확인했던 쿼리의 순서와 조금 달라졌었습니다.
이전의 쿼리들은 질의를 트랜잭션이 번갈아 가면서 select
, insert
, update
질의를 했었습니다.
이번에는 insert 질의까지는 트랜잭션이 serial
하게 진행됬지만, update 쿼리는 맨 마지막으로 순서가 바뀌었습니다.
그 이유는 JPA의 쓰기 지연
기능과 synchronized
의 적용 범위때문입니다.
synchronized
키워드를 사용한 임계 범위 설정은 IssueCouponService.issue()
메서드 범위로 한정됩니다.
하지만 실제 update
질의는 IssueCouponService.issue()
범위 밖에서 실행 되고, 위에서 발생했던 데드락
문제가 발생합니다.
따라서 코드를 아래와 같이 수정할 필요가 있습니다.
@RequiredArgsConstructor
@Service
public class IssueCouponService {
private final CouponRepository couponRepository;
private final UserCouponRepository userCouponRepository;
@Transactional
public synchronized void issue(Long couponId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(RuntimeException::new);
coupon.decrease();
UserCoupon issuedCoupon = new UserCoupon(coupon);
// 저장과 동시 바로 영속성 컨텍스트(쓰기 지연 SQL 저장소) 비우기
userCouponRepository.saveAndFlush(issuedCoupon);
}
}
synchronized
는 근복적인 해결책이 아니다.
saveAndFlush()
메서드와 synchronized
을 사용해서 동시성 문제와 데드락 문제를 한번에 해결했지만, 여전히 이러한 방법은 근복적인 해결책이 아닙니다.
synchronized
를 통한 임계 구역은 결국 코드를 실행하는 프로세스
내로 한정되기 때문에, 하나의 DB를 공유하는 다수의 서버(프로세스)에서 쿠폰 발급 요청이 들어온다면, 위에서 다루었던 문제를 그대로 마주하게 될 것입니다.
따라서 비관적 락
, 낙관적 락
등과 같은 다른 방법을 통해서 동시성 및 데드락 이슈를 해결하는 것이 좋을 것 같습니다.
끝까지 봐주셔서 감사합니다!
댓글 남기기