[재고 관리 #2] 트러블슈팅: 재고 관리 시스템 구조 변경
주문을 하나씩 동기적으로 처리하는 것은 성능에 매우 좋지 않은 영향을 끼칩니다. 프로젝트 구조를 변경함으로써 문제를 해결하는 사례를 소개합니다.
🌱 Overview
트랜잭션과 락을 적절하게 활용하여 최대한 병렬적으로 처리하고 싶지만, 데드락이 발생하는 문제로 인해 쉽게 도입하지 못했습니다. 결국 주문 처리를 동기적으로 처리해야만 문제를 해결할 수 있는 상황에서 구조적인 변경을 통해 성능을 끌어올릴 수 있는 방안을 고민해봅니다.
🟤 처음의 기획과 기능을 다시 고려합니다.
이 기능에 대한 기획과 기능은 다음과 같습니다.
- 주문을 처리함으로써 현재 어떤 주문이 처리되었는지를 확인할 수 있습니다.
- 현재 상품의 재고가 얼마나 남아있는지 확인할 수 있습니다.
위의 기능을 구현하기 위해 처음 모델링했던 테이블은 이러합니다.
- 주문을 처리하면 주문 상태(status)를 변경합니다.
- 주문을 처리하면 주문에 해당하는 상품의 재고량을 감소합니다.
…
주문을 처리하면 해당 Table에 접근하여 데이터(Row)를 모두 수정해야합니다. 이 과정에서 데드락이 발생하게 됩니다.
🌱 본격적인 트러블 슈팅
주문 처리
라는 기능과 재고 처리
라는 기능을 구현하기 위해 Table 데이터를 업데이트함으로써 기능을 구현하려 했습니다. 하지만 꼭 데이터베이스에 모든 상태를 다 저장해야할까?
라는 의문이 들었습니다.
주문을 처리하는 로직은 그대로 DB 데이터에 업데이트하지만, 재고량은 일일히 update하지 않고 쿼리로 문제를 해결할 수도 있지 않을까 생각해보았습니다. 상품 테이블에는 상품의 총 재고량을 저장합니다. 총 재고량에서 주문된 상품의 개수를 모두 더해 차감하면 현재 재고량을 알 수 있지 않을까 판단했습니다.
select quantity from item where item_id = 'x';
-----------------
select sum(a.count) as count
from order_detail a
join order b
on a.order_id = b.order_id
where b.order_status = 'COMPLETE' and a.item_id = 'x'
group by a.item_id;
먼저 item 테이블에서 해당 상품의 총 재고량을 조회합니다. 이후 두번째 쿼리를 통해 현재 특정 상품이 얼마나 처리되었는지를 파악할 수 있습니다.
마무리로 총재고량에서 상품 처리량을 차감하면 현재 남아있는 재고를 파악할 수 있습니다.
…
이를 통해 재고가 차감될 때마다 상품 개수만큼 일일히 차감해야했던 전 로직과 달리 select 쿼리만으로 현재 재고를 파악할 수 있게 되었습니다. 이 방법을 통해 상품 Row에 락을 걸어야할 필요가 사라지게 되어 동기적으로 처리하던 로직을 병렬적으로 처리할 수 있게 되었습니다. 이는 곧 성능의 향상으로 이어집니다.
🟤 문제가 발생하는 로직 2
수정해야할 로직 중 하나를 더 살펴보고자 합니다. 트랜잭션과 락을 무분별하게 사용하면 다음과 같은 상황에서도 좋지 않은 성능을 유발할 수 있습니다.
- 근로자에게 주문 할당
초기 기획에서는 근로자가 order_status가 WAIT인 주문 건을 무작위로 배정받았습니다. 하지만 이 부분은 성능이 저하될 요인으로 작용되었습니다.
@Transactional
public OrderToEmployeeResponse dispatchWaitedOrderToEmployee(Long employeeId) {
Order waitingStatusOrder = orderRepository.findWaitingStatusOrder();
Long orderId = waitingStatusOrder.getId();
Boolean isLocked = redisOrderLockRepository.lock(orderId);
while (!Boolean.TRUE.equals(isLocked)) {
waitingStatusOrder = orderRepository.findWaitingStatusOrder();
orderId = waitingStatusOrder.getId();
}
orderRepository.update(orderId,
waitingStatusOrder.toUpdateOrderWhenDispatchToEmployee(employeeId, OrderStatus.PROCESS));
return OrderToEmployeeResponse.of(orderId, orderDetailRepository.findByOrderId(orderId));
}
//-------------------
@Mapper
public interface OrderMapper {
@Select("select id, order_status, total_count, center_id, employee_id from orders where order_status = 'WAITING' limit 1 for update")
Optional<Order> findWaitingStatusOrder();
}
근로자가 WAIT인 주문 건을 받는 최초로 구현된 코드입니다.
주문이 처리되어 있는지 확인하기 위해 Redis를 분산락으로 활용하였습니다. 하지만 보기에도 성능상 좋아보이지 않은 이유는 while문 때문입니다. while문으로 처리할 데이터가 이미 Redis 분산락에 있다면 다시 새로운 데이터를 DB에서 조회하여 Redis 분산락에 등록되어 있는지를 확인합니다. 이 때 데이터 정합성을 위해 Transaction으로 묶어주었는데, 해당 메소드가 update되어 처리될 때까지 조회한 모든 상품 데이터는 비관적 락에 걸려 각 상품 데이터에 접근한 모든 요청이 대기됩니다.
…
해당 문제를 처리하는 방법은 의외로 간단한데, 근로자가 직접 처리할 주문 데이터를 선택하는 것입니다. 이 Flow가 생겨나게 된다면 근로자에게 랜덤으로 상품이 배정받을 때의 성능저하를 줄일 수 있습니다.
🌱 정리
사실 재고처리와 주문 처리는 기술적으로 해결할 수 있는 방법입니다. 이런 상황에서 Message Queue를 사용함으로써 문제를 해결할 수 있습니다.
Message Queue는 데이터베이스와 서버 간의 연결 등을 느슨하게 하여 결합도를 낮춥니다. 데이터베이스가 처리해야하는 작업을 메시지 큐를 사용하여 비동기적으로 사용할 수 있게 되어 현재 상황을 기술적으로 타파하기에 좋은 기술입니다.
하지만, Message Queue 제품 중 Kafka를 학습하기보다 이미 몇번 활용한 적이 있는 Redis를 사용하여 문제를 기술적으로 해결하고자 합니다.