[CSO Project] 7 - 재고 감소의 동시성 처리
StoreItem(매장 내 재고) 의 Entity 스펙은 아래와 같다.
public class StoreItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Integer stock;
@Column(nullable = false)
private Integer saleCnt;
@Column
private Integer recommend_stock;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column
private LocalDateTime modifiedAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "store_id", nullable = false)
private Store store;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id", nullable = false)
private Item item;
public void saleStock(StoreItemSaleDto storeItemSaleDto) {
if (this.stock - storeItemSaleDto.saleCnt() < 0) {
throw new CustomException(ExceptionCode.CAN_NOT_SALE_MORE);
} else {
this.stock -= storeItemSaleDto.saleCnt();
}
this.saleCnt += storeItemSaleDto.saleCnt();
}
}
Service 스펙은 아래와 같다. 동시성 제어를 위해 DB단 비관적 락으로 StoreItem 을 불러온다.
@Service
@RequiredArgsConstructor
public class StoreItemServiceImplV1 implements StoreItemService {
private final StoreItemRepository storeItemRepository;
private final StoreService storeService;
private final ItemService itemService;
@Override
@Transactional
public void saleStoreItem(Long storeItemId, StoreItemSaleDto storeItemSaleDto) {
StoreItem storeItem = storeItemRepository.findByIdWithPessimisticLock(storeItemId).orElseThrow(
() -> new CustomException(ExceptionCode.NOT_FOUND_STORE_ITEM)
);
storeItem.saleStock(storeItemSaleDto);
}
// 그 외 메서드 생략
}
왜 동시성 문제가 일어나는 걸까?
레이스 컨디션(Race Condition)이 일어났기 때문이다.
레이스 컨디션이란 여러 쓰레드를 사용하는 환경에서 발생하며, 현재 작업이 다른 작업에 의해서 예상치 못한 상황이 발생하게 되는 것을 의미한다.
사진을 보면 쉽게 이해 될 것이다.
100에서 3개의 재고가 줄어들어 97이 돼야 하는데 99가 된 것을 확인 할 수 있다.
만약 이런 결과가 쌓이다 보면 총 50개의 재고감소가 일어났을 때, 재고를 확인 해봤을 때 78 or 90 or 53 이런 식으로 예상치 못한 결과를 확인 할 수 있다.
어떻게 해결?
비관적 락
비관적 락은 DB 단에 X-Lock 을 설정해서 동시성을 제어하는 방법이다.
DB단에서 해당 자원의 소유를 트랜잭션 단위로 수행하게 한다.
즉, A 트랜잭션이 끝날 때 까지 해당 자원을 소유함으로써 다른 트랙잭션에서는 해당 데이터를 수정할 수 없게 된다.
Spring Data JPA 에서는 비관적 락을 이렇게 사용한다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT st"
+ " FROM StoreItem st"
+ " WHERE st.id = :storeId")
Optional<StoreItem> findByIdWithPessimisticLock(@Param("storeId") Long storeId);
- LockModeType.PESSIMISTIC_WRITE: X-LOCK 쿼리
- LockModeType.PESSIMISTIC_READ: S-LOCK 쿼리
비관적 락을 수행할 때는 DeadLock 에 대해서 조심해야한다.
DeadLock 은 A와 B가 비관적 락이 걸려있고, 서로를 필요로 할 때, 서로 안 열어주려고 하니 락이 죽는 것을 의미한다.
낙관적 락
Version 이라는 관리 컬럼을 추가하고 업데이트 시 Where 문에 version 을 추가하여 해당 엔티티가 불일치 시, 재시도 하게 하는 것을 말한다.
DB단에 락을 걸지 않기에 성능이 좋지만, 재시도 로직을 작성해줘야한다.
public class StoreItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Integer stock;
@Version
private Long version; // 추가
// 이후 생략
}
@Lock(LockModeType.OPRIMISTIC)
@Query("SELECT st"
+ " FROM StoreItem st"
+ " WHERE st.id = :storeId")
Optional<StoreItem> findByIdWithOptimisticLock(@Param("storeId") Long storeId);
이렇게 작성하면 쿼리는 어떻게 날라갈까?
UPDATE
st.stock = 99, st.version = st.version + 1
FROM
STOCK st
WHERE
st.id = 1 and st.version = 1
이렇게 나간다. 그러면 version이 +1 됐으니 다른 곳에서는 WHERE 문으로 찾으려 할 때 실패하겠지?
그래서 재시도 로직을 작성해줘야 한다.
@PatchMapping("/brand/store/storeItem/{storeItemId}/stock_sell")
public String saleStoreItem(
@PathVariable Long storeItemId,
@RequestBody @Valid StoreItemSaleDto storeItemSaleDto
) {
while (true) {
try {
storeItemService.saleStoreItem(storeItemId, storeItemSaleDto);
break;
} catch (Exception e) {
Tread.sleep(50);
}
}
return "redirect:/store/storeItem" + storeItemId;
}
saleStoreItem 이 끝나면 break 문을 타고 while 문을 빠져 나올 것이다.
만약 오류가 나면 잠깐 기다렸다 다시 시도를 한다.
네임드 락
시간 나면 추가하겠습니다.
레디스
시간 나면 추가하겠습니다.
그래서 결국 비관적 락으로 선택하여 개발했다.
DeadLock을 조심해야하지만 아직 DeadLock 이 일어날 만한 상황이 안보이고, ERD 수정이 필요 없고, 재고 감소 로직은 중요한 거니까 성능이 그렇게 좋지 않아도 될 거 라는 생각이 있었다.
하지만 위 생각은 언제나 바뀔 수 있다!
공부하다보면 바뀔 수도 있고 바뀌면 나중에 추가하겠다