상황
재고가 1개 남은 도서를 2명의 대여자가 대여 버튼을 동시에 눌렀을 때 재고를 초과하는 상황이 발생을 방지하고자 한다.
예를 들어, 선착순 쿠폰을 100개만 발급하고자 하는 경우랑 비슷하다고 보면 될 것같다.
user가 대여 버튼을 누르면 Kafka를 이용하여 product-service로 대여 데이터를 전송한다.
USER-SERIVCE
// UserController
@PostMapping("/addDeliveryOrder/{userId}")
public void addDeliveryOrder(@PathVariable Long userId, @RequestBody RequestAddDeliveryOrder requestAddDeliveryOrder) {
productService.addDeliveryOrder(userId, requestAddDeliveryOrder);
}
// ProductService (user-service에서 product연관 Service를 모아둔 곳)
public class ProductService {
private final DeliveryOrderKafkaProducer orderKafkaProducer;
public void addDeliveryOrder(Long userId, RequestAddDeliveryOrder requestAddDeliveryOrder) {
requestAddDeliveryOrder.setRenterUserId(userId);
orderKafkaProducer.send(TopicConfig.addDeliveryOrder, requestAddDeliveryOrder); //kafka로 데이터 전송
}
}
PRODUCT-SERVICE
싱글 스레드 기반으로 동작하는 Redis의 원자적 연산 기능(INCR 명령)을 이용하여, 현재 재고 개수까지만 주문 수를 증가시키는 방식을 도입하였다.
Redis를 사용한 이유?
Redis의 INCR 명령은 원자적(Atomic) 연산으로 특정 키의 값을 원자적으로 증가시킨다.
이는 여러 클라이언트가 동시에 접근해도 정확히 1씩 증가시키는 것을 보장한다.
따라서 Redis를 이용하여 동시성 문제를 해결하고 데이터의 일관성을 유지하는 데에 유용하게 사용될 수 있다.
💡 원자적 연산 기능
여러 클라이언트가 동시에 접근하는 환경에서 데이터 일관성을 유지하기 위해서는 연산이 원자적으로 실행되어야 한다.
Redis의 INCR와 같은 명령은 이러한 원자적 특성을 제공하여, 명령이 중간에 끼어들거나 여러 명령이 동시에 실행되는 것을 방지한다.
이는 단순 연산 기능으로는 해결할 수 없는 문제이다.
💡 원자성(Atomicity)
'INCR' 명령은 하나의 Redis 명령으로써, 동시에 여러 클라이언트가 접근해도 중간에 끼어들거나 중복 실행되지 않고, 항상 정확히 1씩 증가시킨다.
💡 Transation의 특징(ACID)
원자성 (Atomicity): INCR 명령은 하나의 Redis 명령으로써, 동시에 여러 클라이언트가 접근해도 중간에 끼어들거나 중복 실행되지 않고, 항상 정확히 1씩 증가시킨다.중간에 다른 작업이 끼어들지 않고, 전체가 실행되거나 전혀 실행되지 않는 특성을 말한다.다시 말해, 연산이 끝나기 전까지는 다른 프로세스나 스레드에서 이 연산을 간섭할 수 없다.
일관성 (Consistency): 연산이 성공적으로 수행되면 항상 일관된 결과를 반환한다.즉, 동일한 입력에 대해 항상 동일한 출력을 보장한다.
격리성 (Isolation): 다른 연산들과 독립적으로 실행되며, 다른 연산의 영향을 받지 않는다.따라서 다른 연산들과 동시에 실행되어도 결과에 영향을 주지 않는다.
지속성 (Durability): 연산이 성공적으로 완료된 후에는 변경 사항이 영구적으로 저장된다.
Transaction ACID에 대해 더 알아보기
#1
싱글 스레드 기반으로 동작하는 Redis를 이용하여 현재 재고 개수에서 주문 수만큼 차감하는 방식을 활용했다.
먼저 Key를 productId로 하도록 하고 초기화한다.
// 주문 수량 초기화
productCountRepository.resetOrderCount(String.valueOf(productId));
#2
productId별로 주문량(orderedQuentity)을 증가시킨다.
// 주문량 증가 및 결과 확인
Long orderCount = productCountRepository.increment(String.valueOf(productId), orderedQuantity);
#3
주문량(orderCount)이 재고(currentStock)를 초과하지 않았을 경우에만 주문이 가능하도록 설정해주었다.
// orderCount가 몇까지 증가했는지 확인
if (orderCount != null && orderCount <= currentStock && currentStock != 0) {
// 재고량 업데이트
// 등록된 상품 재고량도 주문량만큼 -되고 "저장"이되어야 함..
byProductId.setStockQuantity(resultStock);
// 재고량이 0이면 UNAVAILABLE, 남아있으면 AVAILABLE, 하지만 rental-service 넘어가는건 RENTED
if(resultStock == 0) {
byProductId.setRentalStatus(RentalStatus.UNAVAILABLE);
} else {
byProductId.setRentalStatus(RentalStatus.AVAILABLE);
}
productRepository.save(byProductId);
}
PRODUCT-SERIVCE 코드 전체보기
// DeliveryOrderConsumer
public class DeliveryOrderConsumer {
private final ProductRepository productRepository;
private final AddRentedDeliveryOrderProducer addRentedDeliveryOrderProducer;
private final ProductCountRepository productCountRepository;
@KafkaListener(topics = TopicConfig.addDeliveryOrder)
public void addDeliveryOrderResult(AddDeliveryOrderDto addDeliveryOrderDto) {
for (OrderDto orderDto : addDeliveryOrderDto.getOrderList()) {
Long productId = orderDto.getProductId();
ProductEntity byProductId = productRepository.findByProductId(productId);
// 주문량이 재고량 못 넘어가게 제한
Long currentStock = byProductId.getStockQuantity(); // 현재 재고량
Long orderedQuantity = orderDto.getQuantity(); // 주문량
long resultStock = currentStock - orderedQuantity; // 현재 재고량 - 주문량
// 주문 수량 초기화
productCountRepository.resetOrderCount(String.valueOf(productId));
// 주문량 증가 및 결과 확인
Long orderCount = productCountRepository.increment(String.valueOf(productId), orderedQuantity);
// orderCount가 몇까지 증가했는지 확인
if (orderCount != null && orderCount <= currentStock && currentStock != 0) {
// 재고량 업데이트
// 등록된 상품 재고량도 주문량만큼 -되고 "저장"이되어야 함..
byProductId.setStockQuantity(resultStock);
// 재고량이 0이면 UNAVAILABLE, 남아있으면 AVAILABLE, 하지만 rental-service 넘어가는건 RENTED
if(resultStock == 0) {
byProductId.setRentalStatus(RentalStatus.UNAVAILABLE);
} else {
byProductId.setRentalStatus(RentalStatus.AVAILABLE);
}
productRepository.save(byProductId);
// rental-service : 대여원장으로 넘겨줌
AddRentedDeliveryOrderDto build = AddRentedDeliveryOrderDto.builder()
.productId(byProductId.getProductId())
.ownerUserId(byProductId.getOwnerUserId())
.renterUserId(addDeliveryOrderDto.getRenterUserId())
.rentalStartDate(addDeliveryOrderDto.getRentalStartDate())
.rentalEndDate(addDeliveryOrderDto.getRentalEndDate())
.paymentMethod(addDeliveryOrderDto.getPaymentMethod())
.bookName(byProductId.getBookName())
.category(byProductId.getCategory())
.productStatus(byProductId.getProductStatus())
.rentalStatus(RentalStatus.RENTED)
.rentalQuantity(orderDto.getQuantity())
.rentalPrice(byProductId.getRentalPrice())
.rentalMethod(byProductId.getRentalMethod())
.rentalLocation(byProductId.getRentalLocation())
.build();
addRentedDeliveryOrderProducer.send(TopicConfig.addRentedDeliveryOrder, build);
} else {
log.error("Ordered quantity exceeds available stock for productId: " + productId);
}
}
}
}
새롭게 알게 된 점
1. Kafka
예를 들어, 대여할 때 Kafka를 사용하여 USER-SERVICE에서 PRODUCT-SERVICE로 데이터를 전송해주면 DB에 대여한다고 직접적으로 요청하는 것보다 DB에 부하가 덜 걸린다.
왜냐하면 Kafka로 데이터베이스에 바로 100개를 요청하는게 아니라 consumer에서 요청 조절해주기 때문이다.
2. Redis
Redis는 싱글 스레드 방식으로 동작한다. 동시에 하나의 요청만 처리하고, 다른 요청은 큐(Queue)에 넣어 순차적으로 처리한다. 이는 위에서 적용했던 INR 명령어처럼 데이터의 원자성을 유지할 수 있게 해준다는 것을 할게 되었다.
싱글 스레드 방식을 동작하면
단순성: 싱글 스레드 구조는 설계와 구현이 단순하며, 복잡성을 줄일 수 있고,
메모리 접근 효율: 메모리에 직접 접근하여 데이터를 읽고 쓸 수 있어 빠른 응답 시간을 제공할 수 있다.
원자성 보장: 싱글 스레드로 작업을 처리함으로써 데이터의 일관성과 원자성을 보다 쉽게 유지할 수 있다.
Redis는 이러한 싱글 스레드 구조 덕분에 매우 빠른 응답 시간과 높은 처리량을 제공할 수 있으며,
메모리 캐싱, 세션 스토어, 메시지 브로커 등 다양한 용도로 사용된다.
'Project > Collabo Project' 카테고리의 다른 글
[Villion] 이 책과 함께 구매한 도서 목록 구현하기 (0) | 2024.08.14 |
---|---|
[Villion] JPA 양방향에서 단방향으로 변경 (0) | 2024.07.26 |
[Villion] MSA 구조 어떻게 해야 보기 좋을까..? (0) | 2024.07.12 |
[Villion] 찜한 상품 폴더 만들기(Gson 사용) (0) | 2024.06.25 |
[Villion] Docker를 실행 할 때 데이터 넣기 (0) | 2024.05.30 |