요구사항 추가
- 주문 생성 시 재고 확인 및 개수 차감 후 생성하기
- 재고는 상품번호를 가진다.
- 재고와 관련 있는 상품 타입은 병 음료, 베이커리이다.
구현코드
https://github.com/beginner0107/cafekiosk/commit/b905364759754c114e9ac8923ba3d6505b7bc79b
https://github.com/beginner0107/cafekiosk/commit/df44d4f82dab4b459d78d63e768e236870690bf1
Stock 엔티티의 추가
@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class Stock extends BaseEntity {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
private String productNumber;
private int quantity;
@Builder
public Stock(String productNumber, int quantity) {
this.productNumber = productNumber;
this.quantity = quantity;
}
public static Stock create(String productNumber, int quantity) {
return Stock.builder()
.productNumber(productNumber)
.quantity(quantity)
.build();
}
public boolean isQuantityLessThen(int quantity) {
return this.quantity < quantity;
}
public void deductQuantity(int quantity) {
if (quantity > this.quantity) throw new IllegalArgumentException("차감할 재고 수량이 없습니다.");
this.quantity -= quantity;
}
}
StockRepository Test
@DataJpaTest
class StockRepositoryTest {
@Autowired
private StockRepository stockRepository;
@DisplayName("상품번호 리스트로 재고를 조회한다.")
@Test
void findAllByProductNumberIn() {
// given
Stock stock1 = Stock.create("001", 1);
Stock stock2 = Stock.create("002", 2);
Stock stock3 = Stock.create("003", 3);
stockRepository.saveAll(List.of(stock1, stock2, stock3));
// when
List<Stock> stocks = stockRepository.findAllByProductNumberIn(List.of("001", "002"));
// then
assertThat(stocks).hasSize(2)
.extracting("productNumber", "quantity")
.containsExactlyInAnyOrder(
tuple("001", 1),
tuple("002", 2)
);
}
@DisplayName("재고의 수량이 제공된 수량보다 작은지 확인한다.")
@Test
void isQuantityLessThan() {
// given
Stock stock = Stock.create("001", 1);
int quantity = 2;
// when
boolean result = stock.isQuantityLessThen(quantity);
// then
assertThat(result).isTrue();
}
@DisplayName("재고를 주어진 개수만큼 차감할 수 있다.")
@Test
void deductQuantity() {
// given
Stock stock = Stock.create("001", 1);
int quantity = 1;
// when
stock.deductQuantity(quantity);
// then
assertThat(stock.getQuantity()).isZero();
}
@DisplayName("재고보다 많은 수의 수량으로 차감 시도하는 경우 예외가 발생한다.")
@Test
void deductQuantity2() {
// given
Stock stock = Stock.create("001", 1);
int quantity = 2;
// when & then
assertThatThrownBy(() -> stock.deductQuantity(quantity))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("차감할 재고 수량이 없습니다.");
}
}
createOrder() 기능 추가
@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final StockRepository stockRepository;
public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTime) {
List<String> productNumbers = request.getProductNumbers();
List<Product> products = findProductsBy(productNumbers);
// 재고 차감 체크가 필요한 상품들 filter
List<String> stockProductNumbers = products.stream()
.filter(product -> ProductType.containsStockType(product.getType()))
.map(Product::getProductNumber)
.collect(toList());
// 재고 엔티티 조회
List<Stock> stocks = stockRepository.findAllByProductNumberIn(stockProductNumbers);
Map<String, Stock> stockMap = stocks.stream()
.collect(toMap(Stock::getProductNumber, s -> s));
// 상품별 counting
Map<String, Long> productCountingMap = stockProductNumbers.stream()
.collect(groupingBy(p -> p, counting()));
// 재고 차감 시도
for (String stockProductNumber : new HashSet<>(stockProductNumbers)) {
Stock stock = stockMap.get(stockProductNumber);
int quantity = productCountingMap.get(stockProductNumber).intValue();
if (stock.isQuantityLessThen(quantity)) {
throw new IllegalArgumentException("재고가 부족한 상품이 있습니다.");
}
stock.deductQuantity(quantity);
}
Order order = Order.create(products, registeredDateTime);
Order savedOrder = orderRepository.save(order);
return OrderResponse.of(savedOrder);
}
private List<Product> findProductsBy(List<String> productNumbers) {
List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);
Map<String, Product> productMap = products.stream()
.collect(toMap(Product::getProductNumber, p -> p));
return productNumbers.stream()
.map(productMap::get)
.collect(toList());
}
}
설명
- List<String> productNumbers = request.getProductNumbers();
- 상품번호("001", "002" ..) 아메리카노 2잔을 주문했는지, 라떼 주문했는지 상품번호를 가지고 온다.
- List<Product> products = findProductsBy(productNumbers);
- 예시로 상품번호가 "001", "001", "002"로 들어왔으면 아메리카노 2, 라떼 1잔 이다.
- 그러나 DB에서 상품을 조회해올 때, 중복 상품인 아메리카노는 하나의 객체가 되어서 나온다.
- 그렇기 때문에 Map에 key: product_number, value: Product객체 이렇게 담아준다음
- 실제로 키오스크 고객에게 받은 주문 List를 stream으로 순회하면서 key값으로 product_number를 넘겨주고, value값으로 List를 채워서 반환하게 된다.
- List 자료구조의 특징으로 순서를 보장하고, 중복을 보장하기 때문에 가능하다.
- Map역시 이런 상황에서 조회를 할 때 O(1)의 시간복잡도를 가지기 때문에 적합한 선택이라고 할 수 있다.
- 처음에는 이런 생각이 들었다. Map에서 조회를 해올 때 value값인 Product가 중복이 되어서 나오는게 아닌가?(참조값 == 주소값)
- 곰곰히 생각해보니, List에 담는데 참조 값이 같은건 의미가 없다는 것을 깨달았다. List자료구조가 어차피 중복을 허용하기 때문
- List<String> stockProductNumbers = products.stream()
.filter(product -> ProductType.containsStockType(product.getType()))
.map(Product::getProductNumber)
.collect(toList());- db에서 가져온 상품 객체들을 순회하면서, 상품의 상태(status)가 병 음료 or 베이커리 인지 파악한 후(filter) stream의 체이닝 메서드 map을 통해 필요한 요소인 String Type의 productNumber를 리스트로 받아 반환한다.
- List<Stock> stocks = stockRepository.findAllByProductNumberIn(stockProductNumbers);
- 상품번호를 통해 재고 엔티티를 조회해온다.
- Map<String, Stock> stockMap = stocks.stream()
.collect(toMap(Stock::getProductNumber, s -> s));- 다시 Map으로 담아준다. key: product_number, value: Stock 객체(db에서 조회해온)
- Map<String, Long> productCountingMap = stockProductNumbers.stream()
.collect(groupingBy(p -> p, counting()));- "001", "001", "002" 주문이 들어왔을 때 (아메리카노2, 라떼1)
- groupBy 사용해서 아메리카노: 2, 라떼: 1 이런 형식으로 바꿔 맵으로 반환한 것
- for (String stockProductNumber : new HashSet<>(stockProductNumbers)) {
Stock stock = stockMap.get(stockProductNumber);
int quantity = productCountingMap.get(stockProductNumber).intValue();
if (stock.isQuantityLessThen(quantity)) {
throw new IllegalArgumentException("재고가 부족한 상품이 있습니다.");
}
stock.deductQuantity(quantity);
}- Grouping을 했기 때문에 중복 값을 넘기면 Exception이 발생하게 된다.
- HashSet으로 감싸주고, 상품객체를 넣어놨던 stockMap에 product_number를 넘겨 상품객체를 가져온다.
- groupBy한 Map의 value값을 가져오고(감소시킬)
- 재고가 부족하면 예외를 던진다.
- 마지막으로 재고를 감소시킨다.
유의해야할 점1
if (stock.isQuantityLessThen(quantity)) {
throw new IllegalArgumentException("재고가 부족한 상품이 있습니다.");
}
public void deductQuantity(int quantity) {
if (quantity > this.quantity) throw new IllegalArgumentException("차감할 재고 수량이 없습니다.");
this.quantity -= quantity;
}
- 하나는 Service 에서 예외를 던지고 있고
- deductQuantity는 Entity에서 예외를 던지고 있다. 둘다 Businness Layer에 속하는 건 맞다.
- 왜 비슷한 로직을 두번 체크하는 것일까?
- 관점을 다르게 봐야 한다.
- 서비스에서 진행한 체크는 주문 생성 로직을 수행하다가 Stock에 대한 재고 차감을 시도하는 과정이고
- Stock 자체의 메서드 같은 경우에서는 Stock은 외부 서비스가 어떻게 구성되어 있는지 모른다.
- 단위테스트이고, 수량을 차감한다고 했을 때 항상 보장을 해줘야 한다.
- 올바른 수량 차감 로직이 수행되었다는 것을, 이 메서드가 보장을 해줘야 하기 때문에 Entity에서 예외를 던지는 것과 서비스에서 체크 후 예외를 던지는 것은 다른 상황인 것
- deductQuantity를 다른 곳에서 쓸 수도 있다.!
유의해야할 점2
- 재고를 감소하는 것은 그리 간단한 로직이 아니다.
- 동시성 문제가 발생하기 때문이다.
- 동시성이란? 멀티 스레드 or 멀티 프로세스를 사용한다고 가정할 때 특정 자원에 동시에 접근하게 되는 경우 충돌이 일어나게 된다.
- 재고 수량이 2개였는데 동시에 접근해서 1을 감소시켰다.
- 값이 0이라 생각이 들겠지만, 1이 될 수도 있다.
- optimistic lock과 pessimistic lock에 대해 알아둘 필요가 있다.
낙관적인 락(Optimistic Lock)
- 동작 방식:
- 여러 트랜잭션이 동일한 데이터를 동시에 읽을 수 있다.
- 데이터를 읽을 때 버전 정보를 함께 기록하고, 업데이트할 때 해당 버전이 변경되지 않았는지 확인한다.
- 변경되지 않았다면 업데이트를 수행하고, 변경되었다면 충돌이 감지되어 처리된다.
- 장점
- 읽기 연산이 빠르고 충돌이 발생하지 않을 때 성능이 우수하다.
- 단점
- 충돌이 발생했을 때 롤백 및 재시도가 필요할 수 있다.
- 여러 트랜잭션이 동일한 데이터를 변경하려고 할 때 성능이 저하될 수 있다.
비관적인 락(Pessimistic Lock):
- 동작 방식:
- 트랜잭션이 데이터를 읽을 때 해당 데이터에 대한 잠금을 설정한다.
- 다른 트랜잭션이 해당 데이터를 읽을 때까지 다른 트랜잭션이나 스레드에서 해당 데이터에 대한 변경을 막는다.
- 읽기 연산이 느릴 수 있다.
- 장점:
- 충돌이 발생하지 않으며, 간단하게 사용할 수 있다.
- 트랜잭션 간에 데이터 일관성을 보장한다.
- 단점:
- 성능 저하가 발생할 수 있다.
- 동시성이 낮아질 수 있다.
느낀점
- 어떤 기능 하나를 테스트하기 위해서 중간 중간 연관되어 있는 테스트를 만들어야 하는 경우가 많았다.
- 귀찮고 번거롭다고 느낄 수 있지만, 추후에 변경이 일어났을 때 과감한 기능 변경을 가능하게 만든다.
- 동시성 문제에 대해서 한번 더 생각하게 되었다. 비관적인 잠금의 경우
- 교착상태(DeadLock)가 일어날 수 있지 않을까.. java의 경우 syncronized를 붙인 경우인거 같다.
- 상호배제: 둘 이상의 스레드가 하나의 자원에 동시에 접근할 수 없다.
- 점유대기: A스레드가 A자원을 사용하고 있고, B스레드가 A자원을 사용할 차례가 왔다. A스레드의 작업이 지연되어서 B스레드는 대기를 무한정으로 하게 된다.
- 비선점: A스레드가 자원을 선점하고 있으면, B스레드가 와서 잠시 사용할께, 이런 작업이 불가능하다는 것
- 순환대기: 스레드 여러개가 위의 점유 대기상황처럼 서로가 가지고 있는 자원에 대해 마치 원이 순환하듯이 물고 물리는 관계가 된다는 의미
'Spring관련 기술 > 테스트코드' 카테고리의 다른 글
Presentation Layer 테스트 (2) (0) | 2023.12.22 |
---|---|
Presentation Layer 테스트(1) (0) | 2023.12.19 |
Business Layer 테스트 (1) (0) | 2023.12.14 |
Persistence Layer 테스트 (1) | 2023.12.14 |
Spring & JPA 기반 테스트 (0) | 2023.12.13 |