우아한테크세미나에서 조영호님께서 발표해주신 강의를 정리했습니다.
아래와 같이 그룹 단위의 영속성 저장소로 변경 가능하다.
즉 관계형DB 대신 몽고DB로 어디까지 저장을 해야 할까라고 고민한다면 위와 같은 그룹으로 저장을 하면 된다.
즉 그룹은 트랜잭션 / 조회 / 비즈니스 제약의 단위이다.
객체참조 되어있던 코드를 그룹으로 분리시켜 그룹간의 참조를 끊는다면 컴파일 에러가 발생할 것이다.
public class OrderValidator {
public void validate(Order order) {
}
}
OrderValidator
라는 새 객체를 만들고 컴파일 에러가 나는 validate 로직을 아래와 같이 옮겨준다.
그렇게 모은다면 아래와 같이 될 것이다.
@Component
public class OrderValidator {
public void validate(Order order) {
validate(order, getShop(order), getMenus(order));
}
void validate(Order order, Shop shop, Map<Long, Menu> menus) {
if (!shop.isOpen()) {
throw new IllegalArgumentException("가게가 영업중이 아닙니다.");
}
if (order.getOrderLineItems().isEmpty()) {
throw new IllegalStateException("주문 항목이 비어 있습니다.");
}
if (!shop.isValidOrderAmount(order.calculateTotalPrice())) {
throw new IllegalStateException(String.format("최소 주문 금액 %s 이상을 주문해주세요.", shop.getMinOrderAmount()));
}
for (OrderLineItem item : order.getOrderLineItems()) {
validateOrderLineItem(item, menus.get(item.getMenuId()));
}
}
private void validateOrderLineItem(OrderLineItem item, Menu menu) {
if (!menu.getName().equals(item.getName())) {
throw new IllegalArgumentException("기본 상품이 변경됐습니다.");
}
for(OrderOptionGroup group : item.getGroups()) {
validateOrderOptionGroup(group, menu);
}
}
private void validateOrderOptionGroup(OrderOptionGroup group, Menu menu) {
for(OptionGroupSpecification spec : menu.getOptionGroupSpecs()) {
if (spec.isSatisfiedBy(group.convertToOptionGroup())) {
return;
}
}
throw new IllegalArgumentException("메뉴가 변경됐습니다.");
}
}
@Service
public class Orderservice {
private OrderMapper orderMapper;
private OrderValidator orderValidator;
private OrderRepository orderRepository;
@Transactional
public void placeOrder(Cart cart) {
Order order = orderMapper.mapFrom(cart);
order.place(orderValidator);
orderRepository.save(order);
}
}
public class Order {
public void place(OrderValidator orderValidator) {
orderValidator.validate(this);
ordered();
}
}
객체 참조를 이용한 설계는 Validation 로직을 여러 객체를 오가면서 파악해야 한다.
하지만 OrderValidator를 만든다면
@Component
public class OrderValidator {
public void validate(Order order) {
validate(order, getShop(order), getMenus(order));
}
void validate(Order order, Shop shop, Map<Long, Menu> menus) {
if (!shop.isOpen()) {
throw new IllegalArgumentException("가게가 영업중이 아닙니다.");
}
if (order.getOrderLineItems().isEmpty()) {
throw new IllegalStateException("주문 항목이 비어 있습니다.");
}
if (!shop.isValidOrderAmount(order.calculateTotalPrice())) {
throw new IllegalStateException(String.format("최소 주문 금액 %s 이상을 주문해주세요.", shop.getMinOrderAmount()));
}
for (OrderLineItem item : order.getOrderLineItems()) {
validateOrderLineItem(item, menus.get(item.getMenuId()));
}
}
private void validateOrderLineItem(OrderLineItem item, Menu menu) {
if (!menu.getName().equals(item.getName())) {
throw new IllegalArgumentException("기본 상품이 변경됐습니다.");
}
for(OrderOptionGroup group : item.getGroups()) {
validateOrderOptionGroup(group, menu);
}
}
private void validateOrderOptionGroup(OrderOptionGroup group, Menu menu) {
for(OptionGroupSpecification spec : menu.getOptionGroupSpecs()) {
if (spec.isSatisfiedBy(group.convertToOptionGroup())) {
return;
}
}
throw new IllegalArgumentException("메뉴가 변경됐습니다.");
}
}
전체 Validation 로직을 한 눈에 파악할 수 있다.
위와 같이 validateion 로직이 Order
에 있을 경우 응집도가 낮다. 응집도란 관련된 책임의 집합이다. 이걸 다르게 말한다면 같이 변경되는 것들이 함께 있는다면 그것은 응집도가 높은 것이다.
따라서 높은 응집도의 객체(단일 책임 원칙)로 만들 수 있다.
public class Order {
public void place(OrderValidator orderValidator) {
orderValidator.validate(this);
ordered();
}
private void ordered() {
this.orderStatus = OrderStatus.ORDERED;
}
public void payed() {
this.orderStatus = OrderStatus.PAYED;
}
public void delivered() {
this.orderStatus = OrderStatus.DELIVERED;
}
public Money calculateTotalPrice() {
return Money.sum(orderLineItems, OrderLineItem::calculatePrice);
}
}
때로는 위의 코드처럼 절차지향이 객체지향보다 좋을 수 있다. 하지만 절차지향은 테스트하기 어렵다는 단점이 존재한다.
간단한 상태 체크를 하는 로직이면 객체안에 validation 로직이 존재해도 크게 문제되지 않지만 해당 객체를 validation 하고 위해 다른 객체를 참조해야 한다면 찢어내야 한다. 찢어내야 응집도가 증가한다. 이거에 관한 트레이드 오프는 잘 생각해야 한다.
public class OrderService {
@Transactional
public void deliverOrder(Long orderId) {
order.delivered(); // 에러 발생
delivery.complete();
}
}
public class Order {
public void delivered() {
this.orderStatus = OrderStatus.DELIVERED;
this.shop.billCommissionFee(calculateTotalPrice());
}
}
마찬가지로 OrderDeliveredService
라는 서비스를 만들고 배달 완료 로직을 전부 이동 시킨다.
아래와 같이 될 것이다.
다른 방법도 더 존재한다.
public class OrderDeliveredService {
@Transactional
public void deliverOrder(Long orderId) {
Order order = orderRepository.findById(orderId) ...
Shop shop = shopRepository.findById(order.getShopId()) ...
Delivery delivery = deliveryRepository.findById(orderId) ...
order.delivered();
shop.billCommissionFee(order.calculateTotalPrice());
delivery.complete();
}
}
class OrderService {
private OrderDeliveredService orderDeliveredService;
@Transactional
public void deliverOrder(Long orderId) {
orderDeliveredService.deliverOrder(orderId);
}
}
OrderDeliveredService
가 생김으로써 Order
에서 Delivery
로 가는 의존성도 있고 Delivery
에서 Order
로 가는 의존성도 생기게 된다. 즉 의존성 사이클이 생긴다.
이렇게 하면 사이클이 없어진다.
public class Order {
public void delivered() {
this.orderStatus = OrderStatus.DELIVERED;
this.shop.billCommissionFee(calculateTotalPrice());
}
}
public class Order extends AbstractAggregateRoot<Order> {
public void delivered() {
this.orderStatus = OrderStatus.DELIVERED;
registerEvent(new OrderDeliveredEvent(this));
}
}
public class OrderDeliveredEvent {
private Order order;
public OrderDeliveredEvent(Order order) {
this.order = order;
}
public Long getOrderId() {
return order.getId();
}
public Long getShopId() {
return order.getShopId();
}
public Money getTotalPrice() {
return order.calculateTotalPrice();
}
}
Spring Data Aggregate Abstraction을 이용
Spring Data Aggregate Abstraction
를 상속 받으면 registerEvent
메서드를 제공하고 커밋을 할때 이벤트가 발행된다.
@Component
public class BillShopWithOrderDeliveredEventHandler {
private ShopRepository shopRepository;
private BillingRepository billingRepository;
public BillShopWithOrderDeliveredEventHandler(ShopRepository shopRepository, BillingRepository billingRepository) {
this.shopRepository = shopRepository;
this.billingRepository = billingRepository;
}
@Async
@EventListener
@Transactional
public void handle(OrderDeliveredEvent event) {
Shop shop = shopRepository.findById(event.getShopId())
.orElseThrow(IllegalArgumentException::new);
Billing billing = billingRepository.findByShopId(event.getShopId())
.orElse(new Billing(event.getShopId()));
billing.billCommissionFee(shop.calculateCommissionFee(event.getTotalPrice()));
}
}
위의 방법은 @Async
로 비동기로 처리하는 것이다.
@Component
public class CompleteDeliveryWithOrderDeliveredEventHandler {
private DeliveryRepository deliveryRepository;
public CompleteDeliveryWithOrderDeliveredEventHandler(DeliveryRepository deliveryRepository) {
this.deliveryRepository = deliveryRepository;
}
@Async
@EventListener
@Transactional
public void handle(OrderDeliveredEvent event) {
Delivery delivery = deliveryRepository.findById(event.getOrderId()).orElseThrow(IllegalArgumentException::new);
delivery.complete();
}
}
shop
과 order
사이의 사이클이 발생하게 된다.
@Componenet
public class BillShopWithOrderDeliveredEventHandler {
@Async
@EventListener
@Transactional
public void handle(OrderDeliveredEvent event) {
Shop shop = shopRepository.findById(event.getShopId())
.orElseThrow(IllegalArgumentException::new);
Billing billing = billingRepository.findByShopId(event.getShopId())
.orElse(new Billing(event.getShopId()));
billing.billCommissionFee(shop.calculateCommissionFee(event.getTotalPrice()));
}
}
위와 같이 BillShopWithOrderDeliveredEventHandler
의 handler 에서 OrderDeliveredEvent
를 파라미터로 받기 때문에 일시적인 사이클이 발생한다.
위와 같은 사이클이 발생하는 이유는 Event Hander가 Shop 패키지에 있기 때문이다.
위와 같이 패키지를 분리한다.
패키지를 찢을때는 도메인 적으로 새로운 개념을 만드는 것이다. 위에서는 Shop이라는 객체에서 정산이라는 새로운 도메인을 만든 것이다.
패키지 의존성 사이클을 제거하는 3가지 방법
3가지 중 어떤 걸 사용할지는 트레이프 오프를 고려해봐야 한다.