ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 도메인 주도 설계의 적용 - 2. 애그리게이트와 리포지토리 4부
    [옛날 글들] 도메인 주도 설계 2024. 4. 12. 16:56
    728x90

    이전글 : 도메인 주도 설계의 적용 - 2. 애그리게이트와 리포지토리 3부

     

    이 글은 제가 2008년 6월부터 10월까지 5개월간 마이크로소프트웨어에 연재했던 "도메인 주도 설계의 적용"이라는 원고의 원글입니다.  잡지에 맞추어 편집을 하는 과정에서 지면 상의 제약으로 인해 수정되거나 삭제된 부분이 있어 제 블로그에 원글을 올립니다. 도메인 주도 설계(Domain-Driven Design)에 관심 있는 분들에게 도움이 되었으면 합니다. 

     

    OrderLineItem은 상품 정보를 알고 있는 책임을 지닌 Product 클래스와 연관 관계를 가지며, 상품의 수량(quantity)을 속성으로 포함한다. OrderLineItem의 생성자에 전달된 productName Product 엔트리 포인트를 검색하기 위해 사용하는 검색 키이다. Product 참조 객체인 동시에 엔트리 포인트이므로 productName을 가지는 Product 인스턴스는 시스템 내에서 유일해야 한다. 따라서 Product를 관리하는 ProductRepository로부터 해당 인스턴스를 얻어 OrderLineItem product 속성에 할당한다. getPrice() 메서드는 현재 주문 항목의 가격을 반환하는 메서드로 상품 가격에 상품 수량을 곱한 금액을 반환한다.

    package org.eternity.customer;
    
    public class OrderLineItem {
      private Product product;
      private int quantity;
    
      private ProductRepository productRepository = new ProductRepository();
    
      public OrderLineItem(String productName, int quantity) {
        this.product = productRepository.find(productName);
        this.quantity = quantity;
      }
         
      public Money getPrice() {
        return product.getPrice().multiply(quantity);
      }
    
      public Product getProduct() {
        return product;
      }
    }

     

    Product는 상품 명과 상품의 가격을 알 책임을 지닌 엔트리 포인트로 상품 명을 검색 키로 사용한다.

    package org.eternity.customer;
    
    import org.eternity.common.EntryPoint;
    
    public class Product extends EntryPoint {
      private Money price;
      private String name;     
    
      public Product(String name, long price) {
        super(name);
        this.price = new Money(price);
      }     
    
      public Product(String name, Money price) {
        super(name);
        this.price = price;
      }
    
      public Money getPrice() {
        return price;
      }      
    
      public String getName() {
        return name;
      }
    }

     

    OrderLineItem.getPrice() 메서드를 구현했으므로 Order에 전체 주문 가격을 구할 수 있는 메서드를 추가할 수 있다. Order.getPrice() 메소드는 주문 항목들의 전체 가격을 더한 금액을 반환한다.

    public class Order {
      public Money getPrice() {
        Money result = new Money(0);
        
        for(OrderLineItem item : lineItems) {
          result = result.add(item.getPrice());
        }
        
        return result;
      }
    }

     

    고객의 주문 한도액을 초과하지 않는 정상적인 주문 처리 시나리오를 테스트했으므로 이번에는 주문 총액이 고객의 주문 한도액을 초과하는 경우를 테스트해 보자. 주문 총액이 고객의 주문 한도액을 초과하는 경우 with() 메서드는OrderLimitExceededException 예외를 던져야 한다.

    public class OrderTest extends TestCase {
      public void testOrderLimitExceed() {
        try {
           customer.newOrder("CUST-01-ORDER-01")
                   .with("상품1", 20)
                   .with("상품2", 50);
                   
           fail();
        } catch(OrderLimitExceededException ex) {
          assertTrue(true);
        }
      }
    }

     

    녹색 막대다. 테스트가 있어서 좋은 점은 자신도 모르게 느끼게 되는 막연한 두려움과 공포를 조절 가능한 수위로 낮춰 준다는 점이다. 테스트가 통과하면 그다음 단계로 나아갈 수 있는 용기를 얻을 수 있다. 녹색 막대를 통해 용기를 얻었으니 한 걸음 더 전진해 보자.

     

    다음은 주문 시에 동일한 상품을 두 번으로 나누어서 구매하는 경우를 테스트해 보자. 다음과 같이 고객이 “상품1”“상품 1”을 두 번의 주문 항목으로 나누어 구매할 경우 주문 가격이 정확한지를 검증하는 테스트를 작성한다.

    public class OrderTest extends TestCase {
      public void testOrderWithEqualProductsPrice() throws Exception{
        Order order = customer.newOrder("CUST-01-ORDER-01")
                              .with("상품1", 5)
                              .with("상품2", 20)
                              .with("상품1", 5);
                              
        orderRepository.save(order);
        assertEquals(new Money(110000), order.getPrice());
      }
    }

     

    테스트를 통과한다. 동일한 상품을 여러 개의 주문 항목으로 나누어도 주문 총액을 정확하게 계산한다. 가만있자. 그러고 보니 동일한 상품에 대한 별도의 주문 항목은 어떻게 취급해야 할까? 상품이 동일하므로 하나의 주문 항목으로 보아야 할까, 아니면 별도의 독립적인 주문 항목으로 취급해야 할까? 고민할 필요 없다. 고객에게 물어보면 금방 답이 나올 테니까. 주문 업무를 담당하는 고객에게 물어보니 동일한 상품을 나누어 요청하더라도 업무 상으로는 이들을 취합하여 동일한 주문 항목으로 처리한다고 한다. 새로운 도메인 규칙을 알게 되었으니 구현에 앞서 주문 항목의 개수를 검증하기 위한 테스트를 작성하자.

    public class OrderTest extends TestCase {
      public void testOrdreLineItems() throws Exception {
        Order order = customer.newOrder("CUST-01-ORDER-01")
                              .with("상품1", 5)
                              .with("상품2", 20)
                              .with("상품1", 5);                          
        orderRepository.save(order);            
    
        assertEquals(2,order.getOrderLineItemSize());
      }
    }

     

    Order 클래스에 주문 항목의 개수를 반환하는 getOrderLineItemSize() 메서드를 추가한다.

    public class Order {
      public int getOrderLineItemSize() {
        return lineItems.size();
      }
    }

    빨간 막대다! 동일한 상품이더라도 개별적으로 추가되는 경우에는 별도의 주문 항목으로 취급하는 것 같다. 요구사항이란 놈은 정말 변덕이 심한 것 같다. 어떤 때는 요구사항이 변경됐다는 비보를 듣지 않으면 하루 종일 불안하고 초조하기까지 하니 말이다. 그러나 걱정할 것 없다. 우리에게는 비장의 무기인 테스트가 있다. 추가 요구사항을 반영하다가 기존 코드를 망가트리는 일 역시 비일비재하다. 테스트가 존재하면 코드의 어떤 부분이 망가져 버렸는지 즉각적으로 피드백받을 수 있다. 기존 코드가 망가지는 것을 막을 수는 없더라도 망가졌을 때 비상 경고음이 울리도록 조치를 취해 두는 것이 여러모로 안전하다. 회귀 테스트를 믿어라. 그러면 복이 올지니.

     

    그럼 어디부터 고쳐 볼까? 아무래도 Order with() 메서드에서 이미 등록된 상품을 주문하면 두 주문 항목을 합치도록 해야 할 것 같다.

    public class Order {
      private Order with(OrderLineItem lineItem) throws OrderLimitExceededException {
        if (isExceedLimit(customer, lineItem)) {
          throw new OrderLimitExceededException();
        }
    
        for(OrderLineItem item : lineItems) {
          if (item.isProductEqual(lineItem)) {
            item.merge(lineItem);
            return this;
          }
        }           
    
        lineItems.add(lineItem);           
        
        return this;
      }
    }

     

    주문 항목을 추가할 때 OrderLineItem.isProducEqual() 메서드를 호출하여 현재까지 등록된 주문 항목 내에 동일한 상품에 대한 주문 정보가 있는지 체크한다. 존재할 경우 하나의 주문 항목으로 병합하도록 OrderLineItem.merge() 메서드를 호출한다. 이제 OrderLineItem isProducEqual() 메서드와 merge() 메서드를 구현하자.

    public class OrderLineItem {
      public boolean isProductEqual(OrderLineItem lineItem) {
        return product == lineItem.product;
      }
    
      public OrderLineItem merge(OrderLineItem lineItem) {
        quantity += lineItem.quantity;
        return this;
      }
    }

     

    테스트를 실행해 보자. 녹색 막대다. 새로운 요구사항에 대한 테스트뿐만 아니라 기존 코드에 대한 테스트도 모두 성공적으로 통과했다. 마음이 편안하다. 축배라도 한잔 해야 하지 않을까. 역시 테스트가 있어 너무 든든하다.

     

    다음글 : 도메인 주도 설계의 적용 - 2. 애그리게이트와 리포지토리 5부

     

    728x90
Designed by Tistory.