[SpringBoot] 상품 주문과 동시성 제어 (트랜잭션 격리 수준, 공유락과 베타락)
이번 포스팅에서는 JPA 트랜잭션의 동시성을 제어하는 방법에 대해서 알아보겠다.
우선 작성한 상품 주문 로직에 대해서 살펴보겠다.
상품 주문 로직 작성
▶ OrderService.java
@Transactional
public Long order(Long id, Long itemId, int count){
//엔티티 조회
Member member = memberRepository.findOne(id);
Item item = itemRepository.findOne(itemId);
//배송정보 생성
Delivery delivery = new Delivery();
delivery.setAddress(member.getAddress());
//주문상품 생성
OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
//주문 생성
Order order = Order.createOrder(member, delivery, orderItem);
//주문 저장
orderRepository.save(order);
return order.getId();
}
다음은 주문을 처리하는 비즈니스 로직이다.
요청받은 멤버 ID, 상품 ID, 주문상품 수량을 통해 주문 정보에 해당 정보들을 포함하여 Insert하고, 상품의 재고량을 줄이는 로직이다.
▶ ItemRepository.java
public Item findOne(Long id){
return em.find(Item.class, id);
}
주문한 상품을 상품 목록에서 Select하고 있다.
▶ OrderItem.java
//생성 로직
public static OrderItem createOrderItem(Item item, int orderPrice, int count){
OrderItem orderItem = new OrderItem();
orderItem.setItem(item);
orderItem.setOrderPrice(orderPrice);
orderItem.setCount(count);
item.removeStock(count); //재고 줄임
return orderItem;
}
엔티티 클래스에서 작성한 주문 내역에 들어갈 주문상품정보를 생성하는 로직이다.
상품, 가격, 수량 등이 들어가며, removeStock을 통해 현재 상품의 재고량을 줄인다.
▶ Item.java
public void removeStock(int quantity){
int restStock = this.stockQuantity - quantity;
if(restStock < 0){
throw new NotEnoughStockException("Need More Stock");
}
this.stockQuantity = restStock;
}
엔티티 클래스에서 작성한 Item의 재고량을 감소시키는 로직이다.
▶ Order.java
public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems){
Order order = new Order();
order.setMember(member);
order.setDelivery(delivery);
for(OrderItem orderItem: orderItems){
order.addOrderItem(orderItem);
}
order.setStatus(OrderStatus.ORDER);
order.setOrderDate(LocalDateTime.now());
return order;
}
엔티티 클래스에서 작성한 주문 내역을 생성하는 로직이다.
상품 주문 결과 확인
상품을 등록한 화면이다. 재고가 100개인 것을 확인할 수 있다.
50개의 상품에 대해서 주문을 요청하였다.
주문 내역이 추가된 것을 확인할 수 있다.
상품의 재고량은 50개로 줄었다.
문제점
여기까지만 보면 문제가 없다. 강의상에서도 여기까지만 설명되었는데, 만약 동시에 5명이 주문한다면 어떻게 될까?
이럴 경우에 문제가 생겼다.
동시에 여러개의 주문 요청
다음과 같이 Postman을 통해 생성한 CRUL을 통해 여러개의 주문 요청을 날려보았다.
1개의 상품을 5번 주문하는 것을 동시에 요청하도록 하였다.
정상적으로 5개의 주문 내역이 확인되었으므로 5번의 요청에 대해 처리를 했다는 것을 알 수 있다.
그렇지만 원래 재고량인 50에서, 5를 뺀 만큼의 45개의 재고량이 남을 것으로 기대했지만 실제로는 49개가 되었다.
문제점
▶ Item.java
public void removeStock(int quantity){
int restStock = this.stockQuantity - quantity;
if(restStock < 0){
throw new NotEnoughStockException("Need More Stock");
}
this.stockQuantity = restStock;
}
재고량을 감소시키는 로직을 구현하기 위해서 아래의 과정을 거치고 있다.
현재 재고량을 가져온 후 -> 뺄 재고량을 계산하여 -> 현재 재고량을 계산한 재고량으로 갱신
그렇지만 동시에 여러개의 요청이 들어올 시에는 아래의 문제점을 가진다.
1번 요청으로 재고량이 업데이트되기 이전에, 2번 요청이 현재 재고량을 가져와 계산하게 되어 문제가 되는 것이다.
즉 해당 비즈니스 로직의 처리는 트랜잭션의 격리 환경 속에서 순차적으로 일어나야 한다는 점이다.
해결하기
문제를 해결하기 위해서 트랜잭션을 격리된 환경에서 실행되도록 변경하려고 한다.
- I(Isolation) : 격리성
- 일련의 트랜잭션을 작업 중 다른 트랜잭션의 연산 작업이 끼어들지 못하도록 격리시키는 것
- 트랜잭션은 다른 트랜잭션으로부터 독립성을 유지되어 있어야 하며, 같은 자원을 사용한다면 동시에 실행될 수 없음
- 철수의 작업 동안에서는 영희의 작업은 기다려야 함
현재 트랜잭션이 실행중인 경우에는 다른 트랜잭션에서 자원을 사용하는 것을 제어하는 것이다.
그러기 위해서 트랜잭션의 Isolation Level을 올리거나, 공유락/베타락을 걸어 시도해보려고 한다.
Isolation Level
https://sjh9708.tistory.com/40
해당 내용은 트랜잭션의 격리 레벨에 따라 자원에 접근할 수 있는 수준을 정리한 글이다.
격리 수준 레벨을 가장 높은 레벨인 SERIALIZABLE로 올려서 해당 문제를 해결해보는 것을 시도해보려고 한다.
Serializable
- 트랜잭션 내에서의 Select된 자원들은 공유 잠금된다.
- 공유락 : 리소스의 WRITE 제한
- 베타락 : 리소스의 READ/WRITE 제한
- 트랜잭션 내에서 공유 잠금된 데이터의 수정 및 입력 불가능이 보장된다.
- Phantom Read가 발생하지 않는다.
- 가장 단순하면서 가장 엄격한 관리 수준이다.
- 성능 측면에서는 동시 처리 성능이 가장 낮다.
Serializable 격리수준 적용
@Transactional(isolation = Isolation.SERIALIZABLE)
public Long order(Long id, Long itemId, int count){
//엔티티 조회
Member member = memberRepository.findOne(id);
Item item = itemRepository.findOne(itemId);
//배송정보 생성
Delivery delivery = new Delivery();
delivery.setAddress(member.getAddress());
//주문상품 생성
OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
//주문 생성
Order order = Order.createOrder(member, delivery, orderItem);
//주문 저장
//Order의 Member과 Delivery, Item의 Cascade가 ALL로 되어있어 다른 엔티티도 디비에 저장이 된다.
orderRepository.save(order);
return order.getId();
}
- @Transactional(isolation = Isolation.SERIALIZABLE)
- 트랜잭션의 격리환경을 다음과 같이 설정할 수 있다.
해결되었을까?
결론부터 말하면 해결되지 않았다. 한번 이유를 다시 살펴보도록 하자.
@Transactional(isolation = Isolation.SERIALIZABLE)
public Long order(Long id, Long itemId, int count){
//선택한 상품
Item item = itemRepository.findOne(itemId);
//...
//주문 생성
Order order = Order.createOrder(member, delivery, orderItem);
return order.getId();
}
비즈니스 로직 안에서 상품을 선택한 후, 주문을 진행하고 있으며. 주문 시, 선택한 상품의 재고량을 계산하여 감소시킨다
SERIALIZABLE 격리 수준에서 트랜잭션이 데이터를 읽을 때, 데이터베이스는 일반적으로 공유 잠금(Shared Lock)을 설정한다.
이것은 다른 트랜잭션이 해당 데이터를 읽을 수 있도록 허용하지만, 쓰기 작업(UPDATE, DELETE 등)은 차단한다.
즉 트랜잭션이 진행되는 동안 다른 트랜잭션에서 WRITE는 할 수 없지만 READ는 가능하다는 것이다
따라서 수정하는 것은 트랜잭션이 끝날 때 까지, 다른 트랜잭션에서는 불가능하다.
그렇지만 맨 처음 Read에는 제한이 되지 않았으므로, 동시에 요청이 들어올 때 다른 트랜잭션에서도 재고량을 포함한 데이터의 상태를 가져오는 것은 문제가 없다.
public void removeStock(int quantity){
int restStock = this.stockQuantity - quantity;
if(restStock < 0){
throw new NotEnoughStockException("Need More Stock");
}
this.stockQuantity = restStock;
}
모든 트랜잭션들이 READ는 수행했으므로 this.stockQuantity는 모두 50으로 동일했던 것이다.
공유락과 베타락
공유락과 베타락은 비관적 락의 한 종류이다. 잠금을 걸어 트랜잭션에서 선점한 자원에 대해서는 다른 트랜잭션에서의 접근이 제한된다고 하였다.
- 공유락 : 리소스의 WRITE 제한
- 베타락 : 리소스의 READ/WRITE 제한
https://sjh9708.tistory.com/66
JPA에서 낙관적 락의 사용방법은 다음에 다룰 기회가 있으면 다루어보겠다.
Serializable 격리 수준에서 Write는 막았지만, Read를 허용했던 것이 문제였으므로,
베타락을 적용하여 Read까지 막아보도록 하자.
▶ ItemRepository.java
public Item findOne(Long id, LockModeType lock){
return em.find(Item.class, id, lock);
}
▶ OrderService.java
@Transactional(isolation = Isolation.SERIALIZABLE)
public Long order(Long id, Long itemId, int count){
//엔티티 조회
//...
Item item = itemRepository.findOne(itemId, LockModeType.PESSIMISTIC_WRITE);
//배송정보 생성
//...
//주문상품 생성
OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
//주문 저장
//...
return order.getId();
}
em.find() 메서드에 LockModeType.PESSIMISTIC_WRITE을 적용하였다.
이는 베타적 잠금을 걸겠다는 의미이고, "이 자원은 내가 사용중이니 누구도 읽지도 쓰지도 마세요" 라는 의미이다.
결과 확인
SERIALIZABLE 격리 수준은 트랜잭션 간의 간섭을 줄이기 위해 강력한 격리 메커니즘을 제공하지만, 이는 데이터베이스의 구현 방식에 따라 여전히 동시성 문제가 발생할 수 있다.
LockModeType.PESSIMISTIC_WRITE를 사용하면 명시적으로 자원에 대해 잠금을 설정하여 동시성 문제를 더 확실하게 해결할 수 있다.
데드락
격리 수준의 설정과 락을 사용할 때에는 데드락에 유의해야 한다.
두 개의 트랜잭션 1, 2가 각각 데이터 A, B에 락을 걸어 선점하고 있을 때 1은 B를 요구하고, 2는 A를 요구하는 상황이라면 데드락이 발생한 상황이다.
데드락을 회피하기 위해서 모든 트랜잭션이 얻는 락의 순서를 정하여 환형 대기조건을 없애 방지하는 방법 등을 고려할 수 있다.
- 자원 A, B, C의 잠금 순서는 항상 A, B, C 순서로!
https://sjh9708.tistory.com/12
정리
- 데이터의 일관성 유지가 중요한 경우에는 트랜잭션 격리 환경을 높이거나 Lock을 사용하는 것을 고려해야 한다.
- 트랜잭션의 격리 환경을 높이고 Lock을 사용하면 데이터의 일관성을 유지하고, 동시성을 제어할 수 있다는 장점이 있다.
- 하지만 반대로 동시성을 제어하므로, 성능 면에서 떨어지는 것을 감수하여야 한다. 또한, 데드락에 유념하여 사용해야 한다.
- 따라서 일관성이 중요하고 금전과 같은 중요하고 민감한 데이터 위주로 사용을 고려해보는 것이 좋겠다.
해당 포스팅은 본 강의 수강을 따라가면서 작성합니다.
'Backend > Spring' 카테고리의 다른 글
[SpringBoot] API 문서 생성 - Swagger 연동하기 (SpringFox) (0) | 2023.07.25 |
---|---|
[SpringBoot] JPA 사용에 MariaDB 연결하기 (0) | 2023.07.25 |
[Spring Boot/JPA] 영속성 컨텍스트와 준영속 컨텍스트 (0) | 2023.05.09 |
[SpringBoot] Repository/Service/Controller 계층 개발 (0) | 2023.05.09 |
[SpringBoot] Repository와 EntityManager 의존성 주입 (0) | 2023.05.02 |