반응형
package com.zerobase.cms.order.application;
import com.zerobase.cms.order.domain.model.Product;
import com.zerobase.cms.order.domain.model.ProductItem;
import com.zerobase.cms.order.domain.product.AddProductCartForm;
import com.zerobase.cms.order.domain.redis.Cart;
import com.zerobase.cms.order.exception.CustomException;
import com.zerobase.cms.order.exception.ErrorCode;
import com.zerobase.cms.order.service.CartService;
import com.zerobase.cms.order.service.ProductSearchService;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CartApplication {
private final ProductSearchService productSearchService;
private final CartService cartService;
public Cart addCart(Long customerId, AddProductCartForm form) {
Product product = productSearchService.getByProductId(form.getId());
if (product == null) {
throw new CustomException(ErrorCode.NOT_FOUND_PRODUCT);
}
Cart cart = cartService.getCart(customerId);
if (!addAble(cart, product, form)) {
throw new CustomException(ErrorCode.ITEM_COUNT_NOT_ENOUGH);
}
return cartService.addCart(customerId, form);
}
public Cart updateCart(Long customerId, Cart cart) {
// 실질적으로 변하는 데이터
// 상품의 삭제, 수량 변경
cartService.putCart(customerId, cart);
return getCart(customerId);
}
// 1. 장바구니에 상품을 추가했다.
// 2. 상품의 가격이나 수량이 변동된다.
public Cart getCart(Long customerId) {
Cart cart = refreshCart(cartService.getCart(customerId));
cartService.putCart(cart.getCustomerId(), cart);
Cart returnCart = new Cart();
returnCart.setCustomerId(customerId);
returnCart.setProducts(cart.getProducts());
returnCart.setMessages(cart.getMessages());
cart.setMessages(new ArrayList<>());
// 메시지 없는 것
cartService.putCart(customerId, cart);
return returnCart;
// 2. 메세지를 보고 난 다음에는, 이미 본 메세지는 스팸이 되기 때문에 제거한다.
}
public void clearCart(Long customerId) {
cartService.putCart(customerId, null);
}
protected Cart refreshCart(Cart cart) {
// 1. 상품이나 상품의 아이템의 정보, 가격, 수량이 변경되었는지 체크하고 알람 제공
// 2. 상품의 수량, 가격을 우리가 임의로 변경한다.
Map<Long, Product> productMap = productSearchService.getListByProductIds(
cart.getProducts().stream().map(Cart.Product::getId).collect(Collectors.toList()))
.stream().collect(Collectors.toMap(Product::getId, product -> product));
for (int i = 0; i < cart.getProducts().size(); i++) {
Cart.Product cartProduct = cart.getProducts().get(i);
Product product = productMap.get(cartProduct.getId());
if (product == null) {
cart.getProducts().remove(cartProduct);
i--;
cart.addMessage(cartProduct.getName() + "상품이 삭제되었습니다.");
continue;
}
Map<Long, ProductItem> productItemMap = product.getProductItems().stream()
.collect(Collectors.toMap(ProductItem::getId, productItem -> productItem));
List<String> tmpMessages = new ArrayList<>();
for (int j = 0; j < cartProduct.getItems().size(); j++) {
Cart.ProductItem cartProductItem = cartProduct.getItems().get(i);
ProductItem productItem = productItemMap.get(cartProductItem.getId());
if (productItem == null) {
cartProduct.getItems().remove(cartProductItem);
j--;
tmpMessages.add(cartProductItem.getName() + "옵션이 삭제되었습니다.");
continue;
}
boolean isPriceChanged = false;
boolean isCountNotEnough = false;
if (!cartProductItem.getPrice().equals(productItemMap.get(cartProductItem.getId()).getPrice())) {
isPriceChanged = true;
cartProductItem.setPrice(cartProductItem.getPrice());
}
if (!cartProductItem.getCount().equals(productItemMap.get(cartProductItem.getId()).getCount())) {
isCountNotEnough = true;
cartProductItem.setCount(cartProductItem.getCount());
}
if (isPriceChanged && isCountNotEnough) {
tmpMessages.add(cartProductItem.getName() + "가격 변동, 수량이 부족하여 구매 가능한 최대치로 변경되었습니다.");
} else if (isPriceChanged) {
tmpMessages.add(cartProductItem.getName() + "가격이 변동되었습니다.");
} else if (isCountNotEnough) {
tmpMessages.add(cartProductItem.getName() + "수량이 부족하여 구매 가능한 최대치로 변경되었습니다.");
}
}
if (cartProduct.getItems().size() == 0) {
cart.getProducts().remove(cartProduct);
i--;
cart.addMessage(cartProduct.getName() + "상품의 옵션이 모두 없어져 구매가 불가능합니다.");
}
else if (tmpMessages.size() > 0) {
StringBuilder builder = new StringBuilder();
builder.append(cartProduct.getName() + "상품의 변동 사항: ");
for (String message : tmpMessages) {
builder.append(message);
builder.append(", ");
}
cart.addMessage(builder.toString());
}
}
return cart;
}
private boolean addAble(Cart cart, Product product, AddProductCartForm form) {
Cart.Product cartProduct = cart.getProducts().stream()
.filter(p -> p.getId().equals(form.getId()))
.findFirst().orElse(Cart.Product.builder().id(product.getId())
.items(Collections.emptyList())
.build());
Map<Long, Integer> cartItemCountMap = cartProduct.getItems().stream()
.collect(Collectors.toMap(Cart.ProductItem::getId, Cart.ProductItem::getCount));
Map<Long, Integer>currentItemCountMap = product.getProductItems().stream()
.collect(Collectors.toMap(ProductItem::getId, ProductItem::getCount));
return form.getItems().stream().noneMatch( // 모든 아이템이 아래 조건을 만족하지 않으면 true 반환
formItem -> {
Integer cartCount = cartItemCountMap.get(formItem.getId());
if (cartCount == null) {
cartCount = 0;
}
Integer currentCount = currentItemCountMap.get(formItem.getId());
return formItem.getCount() + cartCount > currentCount; // 장바구니에 추가 불가한 조건
});
}
}
코드 설명: 장바구니 관리 애플리케이션 (CartApplication)
이 코드는 온라인 쇼핑몰의 장바구니 기능을 관리하는 애플리케이션 서비스(CartApplication)입니다.
- 장바구니에 상품 추가: addCart 메서드
- 장바구니 업데이트: updateCart 메서드
- 장바구니 조회: getCart 메서드
- 장바구니 비우기: clearCart 메서드
- 장바구니 상품 정보 갱신: refreshCart 메서드
이 코드는 Spring Framework를 기반으로 작성되었으며, Redis를 사용하여 장바구니 정보를 저장하고 관리합니다.
1. 클래스 구조 및 의존성
- @Service: Spring의 어노테이션으로, 해당 클래스가 Spring Bean으로 관리되어야 함을 나타냅니다. 즉, Spring 컨테이너가 이 클래스의 인스턴스를 생성하고 관리합니다.
- @RequiredArgsConstructor: Lombok 라이브러리의 어노테이션으로, final 필드나 @NonNull 어노테이션이 붙은 필드에 대한 생성자를 자동으로 생성해줍니다. 여기서는 productSearchService와 cartService를 주입받기 위한 생성자가 만들어집니다.
- ProductSearchService: 상품 정보를 검색하는 서비스. 상품의 존재 여부, 상세 정보 등을 가져오는 데 사용됩니다.
- CartService: 장바구니 데이터를 Redis에 저장하고 관리하는 서비스. 장바구니 추가, 조회, 업데이트, 삭제 등의 기능을 제공합니다.
2. 메서드별 상세 설명
private final로 변수를 선언하는 것은 Java에서 중요한 객체 지향 프로그래밍 원칙과 관련이 있습니다.
1. private 키워드
- 접근 제어 (Access Control): private은 클래스 외부에서의 접근을 막는 접근 제어자입니다. 즉, 해당 변수는 선언된 클래스 내에서만 직접 접근하고 수정할 수 있습니다.
- 캡슐화 (Encapsulation): private을 사용함으로써 클래스의 내부 구현을 숨기고 외부로부터의 직접적인 접근을 제한하여 캡슐화를 구현합니다. 이는 클래스의 무결성을 유지하고, 예기치 않은 방식으로 데이터가 변경되는 것을 방지합니다.
- 유지보수성 향상: 내부 구현이 외부 코드에 직접적으로 의존하지 않기 때문에, 클래스 내부의 변경이 외부 코드에 미치는 영향을 최소화하여 유지보수성을 향상시킵니다.
2. final 키워드
- 불변성 (Immutability): final은 변수에 값이 할당된 후에는 그 값을 변경할 수 없도록 만듭니다. 즉, 변수가 "상수"가 됩니다.
- 안전성: final 변수는 한 번 초기화되면 변경할 수 없으므로, 멀티스레드 환경에서 동시에 접근하더라도 데이터의 일관성을 보장합니다.
- 가독성 향상: final 변수는 값이 변하지 않는다는 것을 명시적으로 나타내므로 코드의 의도를 명확하게 하고 가독성을 높입니다.
- 성능 향상: 컴파일러는 final 변수의 값을 미리 알고 최적화를 수행할 수 있기 때문에, 경우에 따라 성능 향상에 도움이 될 수 있습니다.
private final을 함께 사용하는 이유
private final을 함께 사용하는 것은 다음과 같은 이점을 제공합니다.
- 강력한 캡슐화: private으로 외부 접근을 막고, final로 값 변경을 막음으로써 클래스의 내부 상태를 더욱 안전하게 보호합니다.
- 의존성 주입 (Dependency Injection) 용이: ProductSearchService와 CartService는 해당 클래스의 핵심적인 의존성입니다. final로 선언하면 생성자를 통해 반드시 초기화해야 하므로, 의존성 주입을 통해 객체를 생성할 때 필요한 의존성을 명확하게 지정할 수 있습니다. 이는 코드의 테스트 용이성을 높이고, 결합도를 낮춰 유지보수를 용이하게 합니다.
- 불변 객체 (Immutable Object) 생성: private final 필드만으로 구성된 클래스는 불변 객체를 만들 수 있습니다. 불변 객체는 스레드 안전하고, 예측 가능하며, 오류 발생 가능성을 줄여줍니다.
- 예측 가능한 동작: 변수의 값이 변경되지 않음을 보장함으로써 코드의 동작을 예측하기 쉽게 만들고, 디버깅을 용이하게 합니다.
요약
private final로 변수를 선언하는 것은 캡슐화, 불변성, 의존성 주입, 스레드 안전성, 가독성, 유지보수성을 높이기 위한 좋은 방법입니다. 특히, 클래스의 핵심적인 의존성을 나타내는 변수에 private final을 사용하는 것은 객체 지향 설계 원칙을 준수하고, 코드의 품질을 향상시키는 데 도움이 됩니다.
2.1. addCart(Long customerId, AddProductCartForm form)
- 기능: 장바구니에 상품을 추가합니다.
- 상품 존재 여부 확인:
- productSearchService.getByProductId(form.getId())를 호출하여 상품 ID(form.getId())에 해당하는 상품 정보를 가져옵니다.
- 만약 상품이 존재하지 않으면 (product == null), CustomException을 발생시켜 ErrorCode.NOT_FOUND_PRODUCT 에러를 반환합니다.
- 기존 장바구니 정보 가져오기:
- cartService.getCart(customerId)를 호출하여 customerId에 해당하는 장바구니 정보를 가져옵니다.
- 상품 추가 가능 여부 확인:
- addAble(cart, product, form) 메서드를 호출하여 장바구니에 해당 상품을 추가할 수 있는지 확인합니다 (재고 확인).
- 만약 추가할 수 없으면 (!addAble(...)), CustomException을 발생시켜 ErrorCode.ITEM_COUNT_NOT_ENOUGH 에러를 반환합니다.
- 장바구니에 상품 추가:
- cartService.addCart(customerId, form)를 호출하여 장바구니에 상품을 추가하고, 업데이트된 장바구니 정보를 반환합니다.
- 상품 존재 여부 확인:
2.2. updateCart(Long customerId, Cart cart)
- 기능: 장바구니 정보를 업데이트합니다 (상품 삭제, 수량 변경 등).
- 장바구니 정보 업데이트:
- cartService.putCart(customerId, cart)를 호출하여 customerId에 해당하는 장바구니 정보를 업데이트합니다.
- putCart 메서드는 일반적으로 Redis에 장바구니 정보를 저장하는 역할을 합니다.
- 업데이트된 장바구니 정보 반환:
- getCart(customerId)를 호출하여 업데이트된 장바구니 정보를 다시 가져와서 반환합니다.
- 장바구니 정보 업데이트:
2.3. getCart(Long customerId)
- 기능: 장바구니 정보를 조회합니다.
- 장바구니 정보 갱신:
- refreshCart(cartService.getCart(customerId))를 호출하여 장바구니 정보를 갱신합니다.
- refreshCart 메서드는 상품 정보, 가격, 수량 변경 여부를 확인하고, 변경된 사항이 있으면 장바구니 정보를 업데이트합니다.
- 갱신된 장바구니 정보 저장:
- cartService.putCart(cart.getCustomerId(), cart)를 호출하여 갱신된 장바구니 정보를 저장합니다.
- 반환할 장바구니 객체 생성 및 정보 설정:
- Cart returnCart = new Cart();를 사용하여 새로운 Cart 객체를 생성합니다.
- returnCart.setCustomerId(customerId);를 사용하여 customerId를 설정합니다.
- returnCart.setProducts(cart.getProducts());를 사용하여 상품 목록을 설정합니다.
- returnCart.setMessages(cart.getMessages());를 사용하여 메시지 목록을 설정합니다.
- 기존 장바구니의 메시지 초기화:
- cart.setMessages(new ArrayList<>());를 사용하여 기존 장바구니의 메시지를 초기화합니다.
- cartService.putCart(customerId, cart);를 호출하여 초기화된 메시지를 저장합니다.
- 반환할 장바구니 객체 반환:
- return returnCart;를 사용하여 반환할 장바구니 객체를 반환합니다.
- 장바구니 정보 갱신:
2.4. clearCart(Long customerId)
- 기능: 장바구니를 비웁니다.
- cartService.putCart(customerId, null)를 호출하여 customerId에 해당하는 장바구니 정보를 null로 설정합니다.
- 이는 Redis에서 해당 장바구니 정보를 삭제하는 것과 같습니다.
- cartService.putCart(customerId, null)를 호출하여 customerId에 해당하는 장바구니 정보를 null로 설정합니다.
2.5. refreshCart(Cart cart)
- 기능: 장바구니에 담긴 상품의 정보가 변경되었는지 확인하고, 변경된 경우 장바구니 정보를 갱신합니다.
- 장바구니에 담긴 상품 ID 목록 추출:
- cart.getProducts().stream().map(Cart.Product::getId).collect(Collectors.toList())를 사용하여 장바구니에 담긴 상품 ID 목록을 추출합니다.
- 상품 ID 목록으로 상품 정보 조회:
- productSearchService.getListByProductIds(productIds)를 호출하여 상품 ID 목록에 해당하는 상품 정보를 조회합니다.
- stream().collect(Collectors.toMap(Product::getId, product -> product))를 사용하여 상품 ID를 키로, 상품 정보를 값으로 하는 Map을 생성합니다.
- 장바구니 상품 목록 순회:
- for (int i = 0; i < cart.getProducts().size(); i++)를 사용하여 장바구니 상품 목록을 순회합니다.
- Cart.Product cartProduct = cart.getProducts().get(i);를 사용하여 현재 상품을 가져옵니다.
- Product product = productMap.get(cartProduct.getId());를 사용하여 상품 ID에 해당하는 상품 정보를 가져옵니다.
- 상품 정보 변경 여부 확인 및 처리:
- 4.1. 상품이 삭제된 경우:
- if (product == null)을 사용하여 상품이 삭제되었는지 확인합니다.
- 상품이 삭제된 경우, cart.getProducts().remove(cartProduct);를 사용하여 장바구니에서 해당 상품을 제거합니다.
- cart.addMessage(cartProduct.getName() + "상품이 삭제되었습니다.");를 사용하여 사용자에게 메시지를 추가합니다.
- 4.2. 상품 아이템 정보 변경 여부 확인 및 처리:
- product.getProductItems().stream().collect(Collectors.toMap(ProductItem::getId, productItem -> productItem))를 사용하여 상품 아이템 ID를 키로, 상품 아이템 정보를 값으로 하는 Map을 생성합니다.
- for (int j = 0; j < cartProduct.getItems().size(); j++)를 사용하여 상품 아이템 목록을 순회합니다.
- Cart.ProductItem cartProductItem = cartProduct.getItems().get(i);를 사용하여 현재 상품 아이템을 가져옵니다.
- ProductItem productItem = productItemMap.get(cartProductItem.getId());를 사용하여 상품 아이템 ID에 해당하는 상품 아이템 정보를 가져옵니다.
- 상품 아이템이 삭제된 경우, 가격이 변경된 경우, 수량이 부족한 경우 등에 따라 메시지를 추가하고, 장바구니 정보를 갱신합니다.
- 4.3. 상품 아이템이 모두 삭제된 경우:
- if (cartProduct.getItems().size() == 0)을 사용하여 상품 아이템이 모두 삭제되었는지 확인합니다.
- 상품 아이템이 모두 삭제된 경우, cart.getProducts().remove(cartProduct);를 사용하여 장바구니에서 해당 상품을 제거합니다.
- cart.addMessage(cartProduct.getName() + "상품의 옵션이 모두 없어져 구매가 불가능합니다.");를 사용하여 사용자에게 메시지를 추가합니다.
- 4.4. 상품 아이템 정보가 변경된 경우:
- if (tmpMessages.size() > 0)을 사용하여 상품 아이템 정보가 변경되었는지 확인합니다.
- 상품 아이템 정보가 변경된 경우, 변경된 내용을 메시지로 추가합니다.
- 4.1. 상품이 삭제된 경우:
- 갱신된 장바구니 정보 반환:
- return cart;를 사용하여 갱신된 장바구니 정보를 반환합니다.
- 장바구니에 담긴 상품 ID 목록 추출:
2.6. addAble(Cart cart, Product product, AddProductCartForm form)
- 기능: 장바구니에 상품을 추가할 수 있는지 확인합니다 (재고 확인).
- 장바구니에 동일한 상품이 있는지 확인:
- cart.getProducts().stream().filter(p -> p.getId().equals(form.getId())).findFirst().orElse(...)를 사용하여 장바구니에 동일한 상품이 있는지 확인합니다.
- 만약 동일한 상품이 없으면, 새로운 Cart.Product 객체를 생성합니다.
- 장바구니에 담긴 상품 아이템의 수량 정보 Map 생성:
- cartProduct.getItems().stream().collect(Collectors.toMap(Cart.ProductItem::getId, Cart.ProductItem::getCount))를 사용하여 장바구니에 담긴 상품 아이템의 수량 정보를 Map으로 생성합니다.
- 현재 상품의 아이템별 최대 수량 정보 Map 생성:
- product.getProductItems().stream().collect(Collectors.toMap(ProductItem::getId, ProductItem::getCount))를 사용하여 현재 상품의 아이템별 최대 수량 정보를 Map으로 생성합니다.
- 추가하려는 상품 아이템별 수량과 현재 재고 비교:
- form.getItems().stream().noneMatch(...)를 사용하여 추가하려는 상품 아이템별 수량과 현재 재고를 비교합니다.
- formItem.getCount() + cartCount > currentCount는 장바구니에 추가하려는 상품 아이템의 수량과 이미 장바구니에 담긴 상품 아이템의 수량을 합한 값이 현재 재고보다 큰 경우 true를 반환합니다.
- noneMatch는 모든 아이템이 조건을 만족하지 않으면 true를 반환하므로, 최종적으로 장바구니에 상품을 추가할 수 있으면 true를, 추가할 수 없으면 false를 반환합니다.
- 장바구니에 동일한 상품이 있는지 확인:
3. 핵심 개념 및 추가 설명
- Stream API: Java 8부터 도입된 Stream API는 컬렉션 데이터를 효율적으로 처리하기 위한 기능입니다. stream(), map(), filter(), collect() 등의 메서드를 사용하여 데이터를 변환하고 수집할 수 있습니다.
- Optional: Java 8부터 도입된 Optional은 값이 null일 수 있는 변수를 처리하기 위한 클래스입니다. Optional.ofNullable(), orElse() 등의 메서드를 사용하여 null 처리를 간결하게 할 수 있습니다.
- Redis: Redis는 인메모리 데이터 저장소로, 빠른 읽기/쓰기 속도를 제공합니다. 장바구니 데이터와 같이 자주 접근하는 데이터를 Redis에 저장하여 성능을 향상시킬 수 있습니다.
- Lombok: Lombok은 반복적인 코드를 줄여주는 Java 라이브러리입니다. @Getter, @Setter, @NoArgsConstructor, @AllArgsConstructor, @RequiredArgsConstructor 등의 어노테이션을 사용하여 boilerplate 코드를 자동으로 생성할 수 있습니다.
- Exception Handling: 예외 처리는 프로그램 실행 중에 발생할 수 있는 예외 상황을 처리하는 메커니즘입니다. try-catch 블록을 사용하여 예외를 처리하고, throw 키워드를 사용하여 예외를 발생시킬 수 있습니다.
4. 개선할 점
- 예외 처리: 현재 코드는 상품이 없거나 재고가 부족할 경우 CustomException을 발생시키지만, 더 다양한 예외 상황에 대한 처리가 필요할 수 있습니다. 예를 들어, Redis 연결 실패, 데이터 변환 오류 등에 대한 예외 처리를 추가할 수 있습니다.
- 트랜잭션 관리: 장바구니 데이터는 여러 번의 읽기/쓰기 작업으로 구성될 수 있습니다. 트랜잭션 관리를 통해 데이터의 일관성을 유지할 수 있습니다.
- 로깅: 로깅을 통해 프로그램 실행 과정을 기록하고, 오류 발생 시 디버깅에 활용할 수 있습니다.
- 테스트 코드: 테스트 코드를 작성하여 코드의 안정성을 확보할 수 있습니다.
반응형