[OOP] 우아한 객체지향 리뷰 (3단계)

청포도봉봉이·2024년 5월 9일
1

우아한 객체지향

목록 보기
3/3

우아한테크세미나에서 조영호님께서 발표해주신 강의를 정리했습니다.

강의 링크

3단계 코드

우아한 객체지향 3단계 리뷰

일단 참조 없는 객체 그룹으로 나눈다면

아래와 같이 그룹 단위의 영속성 저장소로 변경 가능하다.

즉 관계형DB 대신 몽고DB로 어디까지 저장을 해야 할까라고 고민한다면 위와 같은 그룹으로 저장을 하면 된다.

즉 그룹은 트랜잭션 / 조회 / 비즈니스 제약의 단위이다.

객체참조 되어있던 코드를 그룹으로 분리시켜 그룹간의 참조를 끊는다면 컴파일 에러가 발생할 것이다.

객체를 직접 참조하는 로직을 다른 객체로 옮기자

public class OrderValidator {
	public void validate(Order order) {
    
    }
}

컴파일 에러 발생 #1 - 주문

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("메뉴가 변경됐습니다.");
    }
}

OrderValidator를 이용한 OrderService 구현

@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();
    }
}

위와 같은 설계가 좋은 이유?

1. 객체지향은 여러 객체를 오가며 로직 파악

객체 참조를 이용한 설계는 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 로직을 한 눈에 파악할 수 있다.

2. 높은 응집도 객체

위와 같이 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 하고 위해 다른 객체를 참조해야 한다면 찢어내야 한다. 찢어내야 응집도가 증가한다. 이거에 관한 트레이드 오프는 잘 생각해야 한다.

컴파일 에러 발생 #2 - 배달완료

본질: 도메인 로직의 순차적 실행

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());
    }
}

해결 방법

1. 절차 지향 로직 (OrderValidator와 동일)

마찬가지로 OrderDeliveredService 라는 서비스를 만들고 배달 완료 로직을 전부 이동 시킨다.

아래와 같이 될 것이다.

2. 도메인 이벤트 (Domain Event) 퍼블리싱

다른 방법도 더 존재한다.

절차 지향적인 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();
    }
}

OrderService 의존성 주입

class OrderService {
	private OrderDeliveredService orderDeliveredService;

    @Transactional
    public void deliverOrder(Long orderId) {
        orderDeliveredService.deliverOrder(orderId);
    }
}

OrderDeliveredService 추가 전

OrderDeliveredService 추가 후

OrderDeliveredService가 생김으로써 Order에서 Delivery로 가는 의존성도 있고 Delivery에서 Order로 가는 의존성도 생기게 된다. 즉 의존성 사이클이 생긴다.

인터페이스를 이용해서 의존성을 역전시키자

의존성 역전의 원리 (Dependency Inversion Principle)

패키지 간의 사이클이 돌때 해결하는 방법은?

  1. 추상적인 중간 객체를 만들어서 변환한다.
  2. 인터페이스 추상 클래스로 추상화를 넣어 의존성을 역전시킨다.

이렇게 하면 사이클이 없어진다.

도메인 이벤트를 이용한 의존성 제거

Order가 Shop을 직접 호출하던 로직을

public class Order {
	public void delivered() {
    	this.orderStatus = OrderStatus.DELIVERED;
        this.shop.billCommissionFee(calculateTotalPrice());
    }
}

Order가 Domain Event를 발행하도록 수정

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 메서드를 제공하고 커밋을 할때 이벤트가 발행된다.

Shop 이벤트 핸들러 (Spring 이벤트 리스너 사용)

@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로 비동기로 처리하는 것이다.

Delivery 이벤트 핸들러 (Spring 이벤트 리스너 사용)

@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();
    }
}

Domian Event 추가 후

의존성 사이클 발생

shoporder 사이의 사이클이 발생하게 된다.

@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 패키지에 있기 때문이다.

위와 같이 패키지를 분리한다.

Event Handler가 의존하는 코드를 Shop에서 분리한다

Event Handler에서 Shop과 Billing 사용

Billing 을 새로 만든 패키지에 포함시킨다

패키지를 찢을때는 도메인 적으로 새로운 개념을 만드는 것이다. 위에서는 Shop이라는 객체에서 정산이라는 새로운 도메인을 만든 것이다.

사이클이 사라진 패키지 의존성

정리

패키지 의존성 사이클을 제거하는 3가지 방법

1. 새로운 객체로 변환 (중간 객체 생성)

2. 의존성 역전

3. 새로운 패키지 추가

3가지 중 어떤 걸 사용할지는 트레이프 오프를 고려해봐야 한다.

도메인 단위 모듈화

도메인 이벤트를 통한 협력

도메인 단위로 시스템 분리 가능

System Event를 통한 시스템 통합

profile
서버 백엔드 개발자

0개의 댓글