[재고 관리 #3] 트러블슈팅: Redis를 활용하여 DB 데드락 해결하기
RDBMS의 트랜잭션과 락으로 인한 데드락을 Redis 기술을 활용하여 해결한 사례를 소개합니다.
🌱 Overview
이전 글(구조적 변경을 통해 이슈 해결하기)에서 다뤘던 DB 데드락을 구조적 변경 대신에 기술적으로 어떻게 풀 것인지 고민해보았습니다.
아무래도 구조나 기획을 변경하게 되면 기존의 계획과는 다른 방향을 가지고 서비스가 완성될 여지가 있기 때문에 기술적으로도 어떠한 이슈를 해결할 수 있는 역량을 쌓아야합니다.
동시적으로 DB의 상품 Table에 접근해서 락을 거는 행위가 데드락을 발생시키는 근본적인 원인이라 생각하여, 해당 프로세스를 어떻게 해결할 것인지를 고민하였습니다.
🌱 Redis로 재고 관리하기
Redis는 다방면에서 활용할 수 있는 정말 유용한 제품이라고 생각합니다. Redis를 통한 분산락, Scale out 고려시 JWT의 토큰이나 Session을 저장하는 스토리지, Geo API 지원을 활용한 위치 데이터 가공 등 많은 상황에서 사용될 수 있습니다.
현재 상황에서는 Redis을 통해 재고관리를 해보고자 합니다.
…
❗️ Redis 재고관리의 주의사항
Redis는 In-Memory 기반의 스토리지이기 때문에 메인 메모리에 데이터를 저장합니다. 이는 곧 Redis에 저장하는 데이터는 휘발된다는 특징이 있기 때문에 지속성 측면에서 고려해야할 사항들이 많습니다. (물론 메인메모리에 접근하기 때문에 접근 및 저장속도는 월등하게 빠릅니다.)
만일 Redis 서버에 장애가 발생한다면, Redis에 있는 데이터는 모조리 삭제되어 복구가 불가능해집니다. 이에 대비하여 Redis 서버를 Cluster로 구성하여 SPOF의 상황에 대처해야합니다.
🟤 왜 Redis일까요?
Redis는 싱글 스레드 기반으로 동작하는 In-Memory 스토리지입니다. 이 의미는 조회하여 값을 수정하는 단계만 원자적(Atomic)으로 처리가 가능하다면 데이터 정합성에 대한 이슈를 해결할 수 있습니다.
기존에 MySQL을 통한 데이터 수정시 데이터의 정합성이 깨지는 이유는 다음과 같습니다.
- SQL 요청은 병렬적으로 처리됩니다.
- threadA가 a라는 데이터를 조회하여 수정하려는 찰나 threadB가 a라는 데이터를 조회합니다.
- threadA가 처리한 a를 DB에 반영합니다.
- 하지만 threadB가 처리한 a를 다시 DB에 반영할 때는 3번에서 threadA가 처리한 데이터를 기반으로 처리하는 것이 아니라 2번에서 조회한 a 데이터를 기반으로 처리하기 때문에 3번으로 인한 데이터 반영은 처리되지 않은 것처럼 됩니다.
- 데이터 처리가 오류를 가지고 오게 됩니다.
…
Redis에는 병렬적으로 데이터를 하더라도 데이터를 조회하여 수정하는 과정을 원자적으로 처리하는 API가 있습니다. 싱글 스레드 기반으로 원자적으로 처리된 데이터를 조회한다면, threadA가 조회하여 처리한 데이터를 threadB가 바로 조회할 수 있기 때문에 데이터 정합성을 보장할 수 있습니다.
❗️ Redis의 트랜잭션
재고를 처리할 때 현재 있는 재고가 0이라면 주문을 할 수 없습니다. 이 경우 MySQL이라면 트랜잭션을 활용하여 데이터를 롤백할 수 있지만, Redis에서의 트랜잭션은 롤백 기능을 지원하지 않습니다.
그렇기 때문에 처리 중 예외 상황이 발생했을 경우를 대비하여 다시 재고를 원상태로 돌려놓는 보상 트랜잭션을 별도로 구현하였습니다.
🌱 구현된 코드
public Long completeOrder(Long orderId, Long employeeId) {
// Redis 재고에서 먼저 에러 발생 시 뒤의 MySQL 에 쿼리 요청하는 작업에 접근하지 않습니다.
List<OrderDetail> findOrderDetails = orderDetailRepository.findByOrderId(orderId);
// Redis에 저장되어 있는 상품 재고 처리
redisItemStockRepository.decreaseItemStock(findOrderDetails);
// 재고 처리 완료 update
Order findOrder = orderRepository.findById(orderId);
orderRepository.update(orderId, findOrder.toUpdateOrderWhenComplete(OrderStatus.COMPLETE, employeeId));
...
return orderId;
}
구현된 코드는 의외로 단순합니다.
- orderDetailRepository에서 주문된 아이디에 해당하는 상품과 상품 개수를 조회합니다.
- 조회한 상품 리스트에서 decreaseItemStock이라는 메소드를 통해 재고를 감소시킵니다.
- decreaseItemStock 메소드가 성공하면 주문 아이디의 상태값을
COMPLETE
로 변경시킵니다.
아래는 decreaseItemStock 메소드에 관련된 코드입니다.
public void decreaseItemStock(List<OrderDetail> orderDetails) {
// Redis의 상품 처리 중 예외 발생시 롤백할 자료구조
Map<String, Integer> backupItem = new HashMap<>();
// 상품 리스트를 순환하면서 상품 재고 차감
for (OrderDetail orderDetail : orderDetails) {
// 상품에 해당하는 KEY 생성
String generatedKey = generateItemKey(orderDetail.getItemId());
// 상품 재고 차감
Long decrement = redisItemStockTemplate.opsForValue().decrement(generatedKey, orderDetail.getCount());
// 상품 재고 차감된 이후 롤백 자료구조에 처리한 상품 정보 캐싱
backupItem.put(generatedKey, orderDetail.getCount());
// 상품 재고 처리 확인
backupItemStock(backupItem, decrement);
}
}
//----------
private void backupItemStock(Map<String, Integer> backupItem, Long decrement) {
if (decrement == null || decrement < 0) {
for (String itemKey : backupItem.keySet()) {
redisItemStockTemplate.opsForValue().increment(itemKey, backupItem.get(itemKey));
}
throw new NotEnoughStockException("재고가 부족합니다.");
}
}
decreaseItemStock 메소드는 다음과 같은 Flow를 가집니다.
- 먼저 처리할 상품 아이디(key), 차감할 상품 개수(value)로 예외가 발생시 롤백할 자료구조를 생성합니다.
- redisItemStockTemplate.opsForValue().decrement() 메소드를 사용하여 원자적으로 redis의 value를 조회하고 차감합니다.
- 만약 차감된 상품 개수가 음수가 된다면 상품이 모자라다는 의미이기 때문에 이전에 처리하고 백업한 상품들을 롤백합니다. (increment를 통해 롤백합니다.)
…
이러한 과정을 통해 데이터의 정합성을 지키면서 상품을 데드락 없이 재고 처리할 수 있게 하였습니다.
🌱 마무리
보통 Redis는 In-Memory 기반의 스토리지이기 때문에 유실되지 않아야할 데이터는 가급적 사용하지 않습니다. 상품의 재고는 중요한 자원이라고 생각하여 무조건 Redis를 사용하지 않아야하는 것 아닌가? 라는 의문이 들었지만, 이미 유명 기업에서 Redis를 활용한 재고 관리 사례를 접하게 되어 생각을 전환하는 계기가 되었습니다.