시나리오
사용자 5명이 상품의 재고가 2개가 남았을 때 동시에 주문을 했을 때 정상적으로 동작하려면 3명의 사용자는 예외 처리(품절)가 되어야 합니다.
코드의 일부분을 보면 재고가 주문한 수량보다 많으면 예외 처리가 되어야 합니다.
request.getOrderItemDTOList().forEach(orderItemDto -> {
Item item = itemRepository.findById(orderItemDto.getItemId()).get();
orderItemDto.getOrderDetailDTOList().forEach(orderDetailDTO -> {
if (orderDetailDTO.getItemOptionId() != null) { // 옵션이 있는 상품인 경우
ItemOption itemOption = itemOptionRepository.findById(orderDetailDTO.getItemOptionId()).get();
if (orderDetailDTO.getAmount() > itemOption.getStock()) { // 재고보다 주문 수량이 많은 경우
throw new OrderHandler(ErrorStatus.LACK_OF_STOCK);
}
} else { // 단일 상품인 경우
if (orderDetailDTO.getAmount() > item.getStock()) {
throw new OrderHandler(ErrorStatus.LACK_OF_STOCK);
}
}
});
});
postman으로 테스트를 진행했습니다.
재고는 2개가 남은 상황입니다.
사용자 5명이 동시에 주문을 해보았습니다.
재고가 2개밖에 없었는데 주문이 4개가 들어와 버렸습니다.
로그를 확인해 보았습니다.
2024-08-02 13:30:16.875 DEBUG 19149 --- [nio-8080-exec-5] o.h.engine.jdbc.spi.SqlExceptionHelper
: could not execute statement [n/a]
2024-08-02 13:30:16.876 WARN 19149 --- [nio-8080-exec-5] o.h.engine.jdbc.spi.SqlExceptionHelper
: SQL Error: 1213, SQLState: 40001
2024-08-02 13:30:16.876 ERROR 19149 --- [nio-8080-exec-5] o.h.engine.jdbc.spi.SqlExceptionHelper
: Deadlock found when trying to get lock; try restarting transaction
2번 transaction에서 데드락이 일어나는 것을 확인했습니다.
그런데 3,4,5는 lock없이 그냥 작동한 것을 확인했습니다.
lock을 따로 설정해주지 않았지만 mysql에서는 s-lock과 x-lock 기능이 있습니다.
아래에서 db로그를 보면서 왜 데드락이 발생했는데 분석 해보겠습니다.
db 로그를 보면 어떤 문제인지 정확히 알 수 있습니다.
재고를 조회하기 위해 item 테이블 해당 레코드에 s-lock을 획득합니다.
RECORD LOCKS space id 405 page no 7 n bits 88 index PRIMARY of table
`thegoods-dev-db`.`item` trx id 142455 lock mode S locks rec but not gap
그런데 해당 트랜잭션에서 x-lock을 얻기 위해서 대기를 하게 됩니다.
RECORD LOCKS space id 405 page no 7 n bits 88 index PRIMARY of table
`thegoods-dev-db`.`item` trx id 142455 lock_mode X locks rec but not gap waiting
이유는 동시에 발생한 트랜잭션에 있습니다. 2번 트랜잭션이 재고를 조회하기 위해 item 테이블 같은 레코드에 s-lock을 획득합니다. s-lock을 획득하면서 해당 레코드에 변경이 불가능하게 됩니다.
s-lock은 동시에 획득가능합니다.
RECORD LOCKS space id 405 page no 7 n bits 88 index PRIMARY of table `thegoods-dev-db`.`item` trx id 142456 lock mode S locks rec but not gap
문제 원인 : 해당 레코드에 동시에 s-lock을 획득했기 때문에 1번 트랜잭션에서 해당 레코드의 재고량을 변경하려고 하면 2번 트랜잭션의 s-lock이 걸려 있기 때문에 재고량을 변경할 수 없게 됩니다.
2번 트랜잭션도 x-lock을 기다리는 것을 확인할 수 있고 데드락이 발생하게 됩니다.
RECORD LOCKS space id 405 page no 7 n bits 88 index PRIMARY of table `thegoods-dev-db`.`item` trx id 142456 lock_mode X locks rec but not gap waiting
해결하기
jpa를 이용해서 transaction에 lock을 걸어서 다른 트랜잭션이 데이터에 접근하지 못하도록 제어하였습니다.
낙관적 lock vs 비관적 lock
비관적 lock을 선택했는데 이유는 여러 사용자가 동일한 데이터에 접근했을 때 충돌을 방지해서 데이터 일관성을 유지해야하기 때문입니다.
일단 jpa을 이용해서 비관적 lock을 적용했습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM Item e WHERE e.id = :id")
Optional<Item> findByIdWithLock(@Param("id") Long id);
또 문제 발생
그런데 자꾸 jmeter로 5개의 스레드로 동시에 요청을 보냈는데 재고는 2개인데 5개의 주문이 들어오는 상황이 발생했습니다.
분명 비관적 lock을 적용하면 해당 db 레코드에 접근을 못하는데 이런일이 발생하는지 찾아보았습니다.
문제 원인 예상
스레드 5개가 읽기를 동시에 하는데 처음 읽을 때 재고가 2개인 것을 읽어 가서 문제가 생기는거 같습니다.
그래서 읽기까지 lock을 걸었더니 데드락이 걸리네요..
일단 로직을 실행하는 중간에 item 재고가 0이 되었으면 롤백을 시켜야 해서 재고를 확인하고 예외 처리를 하는 코드를 추가해 보았지만 해결되지 않았습니다.
이런 저런 삽질을 하다가 테스트에 성공했는데 문제의 원인은 JPA에서 사용하는 1차 캐시가 원인이었던 것 같습니다.
JPA는 동일한 트랜잭션 내에서 동일한 엔티티를 조회할 때 1차 캐시를 사용합니다. 따라서 데이터베이스에서 변경된 값을 즉시 반영하지 않을 수 있습니다.
1차 캐시를 무효화하고, 엔티티의 상태를 데이터베이스와 동기화했더니 주문이 정상적으로 들어오는 것을 확인할 수 있었습니다.
//추가된 코드
entityManager.refresh(item);
정확한 원인이 아닐 수 있어서 조언 해주시면 감사하겠습니다!
'Dev' 카테고리의 다른 글
N+1 문제 해결해서 성능 개선하기 (0) | 2024.12.04 |
---|---|
일단 단위 테스트부터 테스트 코드 작성해보기 (0) | 2024.12.04 |
과도한 트래픽에 대한 방어하기 (2) | 2024.12.04 |
GCP에 ElasticSearch 띄워서 검색 기능 구현하기 (2) | 2024.12.04 |
위치 기반으로 글 조회 기능 구현 (1) | 2024.12.04 |