ABOUT ME

-

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

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

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

    유창하게(Fluently) 구현하기

    애그리게이트와 엔트리 포인트, 리포지토리를 사용하여 대략적인 도메인 모델을 스케치했으므로 테스트 주도 방식을 적용하여 도메인 로직을 개발하자.

     

    첫 번째 아티클에서 설명한 것처럼 테스트 주도 개발 방식에서는 애플리케이션 코드를 작성하기 전에 실패하는 테스트부터 작성한다. 테스트를 작성할 때는 테스트 대상 객체의 인터페이스가 어떻게 사용될 지를 상상해 보는 것이 중요하다. 상상의 나래를 펴면서 테스트 중인 시나리오를 실행하기 위해 객체의 어떤 오퍼레이션을 어떤 순서로 호출하는 것이 효율적인지를 결정한다. 따라서 테스트를 작성함과 동시에 자연스럽게 사용하기 편리한 인터페이스를 설계하게 되는 부수적인 효과도 얻을 수 있다.

     

    객체의 인터페이스 설계와 관련해서 최근 플루언트 인터페이스(FLUENT INTERFACE)라는 방식이 관심을 끌고 있다. 유창한 인터페이스를 설명하기 위해 전통적인 방식의 객체 인터페이스부터 살펴보자.

     

    java에서 객체의 상태를 변경하는 setting 메서드를 작성하는 일반적인 관습은 메서드의 반환형을 void로 설정하는 것이다. 즉, setting 메서드는 값을 반환하지 않는다. 이 관습은 void 타입을 지원하는 C++, Java, C#과 같은 정적 타입 언어에서 널리 사용되는 방식으로 상태를 변경하는 메서드와 상태를 조회하는 메서드를 명시적으로 분리해야 한다는 명령-쿼리 분리(COMMAND-QUERY SEPARATION) 원칙을 따른다.

     

    플루언트 인터페이스는 명령-쿼리 분리 원칙은 위배하지만 읽기 쉽고 사용하기 편리한 객체 인터페이스를 설계할 수 있도록 한다. 플루언트 인터페이스는 메서드 체이닝(Method Chaining) 스타일에 기반을 둔다. Smalltalk와 Ruby처럼 void타입을 가지지 않는 동적 타입 언어에서는 명시적으로 값을 반환하지 않을 경우 자동적으로 메소드를 실행한 객체 자신이 반환된다. 즉, 메소드 내에서 명시적으로 return문을 호출하지 않으면 자동으로 return this가 호출된다. 따라서 특정 메소드를 호출한 후 반환된 객체를 사용하여 연속적으로 다른 메소드를 호출하는 것이 가능하다.

     

    java에서 메서드 체이닝 스타일을 가장 빈번히 사용하는 경우는 내부 구조가 복잡한 복합 객체를 생성하는 경우이다. 대표적인 경우가 Hibernate 프레임워크의 Configuration 클래스로 SessionFactory를 생성하기 위해 메서드 체이닝 스타일을 사용한다.

    SessionFactory sessionFactory = new Configuration()
      .configure("/persistence/auction.cfg.xml")
      .setProperty(Environment.DEFAULT_SCHEMA, "CAVEATEMPTOR")
      .addResource("auction/CreditCard.hbm.xml")
      .buildSessionFactory();

     

    이 스타일을 가장 광범위하게 수용한 코드는  TimeAndMoney 라이브러리로, 코드의 가독성을 향상하고 객체의 흐름을 효과적으로 전달하기 위해 메서드 체이닝 스타일을 사용한다. 다음은 TimeAndMoney 라이브러리의 테스트 코드에서 발췌한 Money의 인터페이스이다.

    assertEquals(new BigDecimal(2.50),
      Money.dollars(5.00)
       .dividedBy(Money.dollars(2.00))
       .decimalValue(1, Rounding.UNNECESSARY));

    메서드 체이닝 스타일을 도메인 객체 인터페이스의 설계에 적용한 것이 플루언트 인터페이스 방식이다. 도메인 모델에서 플루언트 인터페이스를 사용하기에 적절한 경우는 애그리게이트 내부를 생성하는 단계가 간단하지 않지만 빌더(BUILDER)와 같은 별도의 팩토리(FACTORY) 객체를 도입할 경우 불필요한 복잡성(Needless Complexity)의 악취가 나는 경우이다. 주문 도메인에서는 주문 애그리게이트를 생성하기 위해 플루언트 인터페이스 스타일을 사용한다.

     

    주문 처리를 테스트하기 위한 테스트 클래스를 작성하자. 각 테스트를 고립시키기 위해 setUp() 메서드를 오버라이딩하여 테스트 픽스처(fixture)를 초기화한다.

    public class OrderTest extends TestCase {
      private Customer customer;   
      private OrderRepository orderRepository;
      private ProductRepository productRepository;
    
      public void setUp() throws Exception {
        Registrar.init();
        orderRepository = new OrderRepository();
    
        productRepository = new ProductRepository();
        productRepository.save(new Product("상품1", 1000));
        productRepository.save(new Product("상품2", 5000));
    
        customer = new Customer("CUST-01", "홍길동", "경기도 안양시", 200000);
      }

     

    테스트 코드를 작성하면서 도메인 객체에게 의미가 명확한 오퍼레이션을 할당하도록 노력하자. 오퍼레이션의 명칭은 의도를 드러내는 이름(INTENTION REVEALING NAME) 패턴을 따르도록 한다. 오퍼레이션은 구현 전략이나 알고리즘과 독립적으로 오퍼레이션을 호출할 사용자의 사용 의도에 적합한 이름을 가져야 한다. 즉, 오퍼레이션의 이름은 메서드의 내부 구현 방식이나 컴퓨터의 관점이 아니라 이를 사용하는 클라이언트의 관점을 반영해야 한다. 의도를 드러내는 이름 패턴을 따른 메소드의 경우 가독성이 높아진다.

     

    우선 두 가지 상품을 주문한 후 주문의 총액을 계산하는 테스트 코드를 작성하자. 주문 애그리게이트를 생성하기 위해 플루언트 인터페이스 스타일을 사용한다.

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

     

    가격이 1,000원인 “상품 1”을 10개 주문하고, 가격이 5,000원인 “상품 2”를 20개 주문한 후 총액이 110,000원인지를 테스트한다. Order 객체는 주문 애그리게이트의 엔트리 포인트이므로 OrderRepository를 사용하여 등록한다. 리파지토로를 사용하기 때문에 주문이 시스템 내에 유일하게 하나만 존재하도록 제어할 수 있으며 상태 변경을 추적할 수 있다.

     

    위 코드는 아직 컴파일조차 되지 않는다. 실패하는 테스트를 가졌으니 이 테스트를 통과시키도록 하자. Customer 클래스에 newOrder() 메서드를 추가하는 것으로 시작하자. long 타입이었던  mileage를 값 객체인 Money 타입으로 변경했음에 주목하자.

    public class Customer extends EntryPoint {
      private String customerNumber;
      private String name;
      private String address;
      private Money mileage;
      private Money limitPrice;
    
      public Customer(String customerNumber, String name, String address, 
          long limitPrice) {
    
        super(customerNumber);
        this.customerNumber = customerNumber;
        this.name = name;
        this.address = address;
        this.limitPrice = new Money(limitPrice);
      }
    
      public Order newOrder(String orderId) {
        return Order.order(orderId, this);
      }
    
      public boolean isExceedLimitPrice(Money money) {
        return money.isGreaterThan(limitPrice);
      }
    }

     

    Customer 클래스에 고객의 주문 한도를 검증하기 위해 필요한 limitPrice 속성을 추가했다. limitPrice 속성은 Customer 객체 생성 시 생성자의 인자로 전달되어 초기화된다. limitPrice 속성을 Customer 클래스에 추가했으므로 정보 전문가(INFORMATION EXPERT) 패턴에 따라 한도액을 검증하는 isExceedLimitPrice() 메소드를 Customer 클래스에 추가했다.

    newOrder() 메서드는 엔트리 포인트 검색에 사용될 주문 ID를 인자로 받아 새로운 Order를 생성한다. Order는 order() 생성 메서드(CREATION METHOD)를 사용하여 Order를 생성한다.

    public class Order extends EntryPoint {
      private Set<OrderLineItem> lineItems = new HashSet<OrderLineItem>();
      private Customer customer;
    
      public static Order order(String orderId, Customer customer) {
        return new Order(orderId, customer);
      }
    
      Order(String orderId, Customer customer) {
        super(orderId);
        this.customer = customer;
      }
    }

    Order 클래스는 주문 애그리게이트의 엔트리 포인트이므로 EntryPoint 클래스를 상속받고 검색 키로 orderId를 사용한다. order() 생성 메서드는 Order 클래스의 생성자를 호출해서 새로운 Order 인스턴스를 생성하고 Customer와의 연관 관계를 설정한다. order() 생성 메서드를 통해서만 객체를 생성할 수 있도록 제한하기 위해 생성자에게 public이 아닌 기본 가시성을 부여했다.

     

    주문 항목을 생성하는 with() 메서드를 추가하자. 주문 애그리게이트의 생성 인터페이스에 메서드 체이닝 스타일을 적용하기로 했으므로 with() 메서드는 this를 반환한다. Order는 주문 애그리게이트의 엔트리 포인트이므로 주문 항목이 추가될 때마다 주문 총액이 고객의 한도액을 초과했는지 여부를 검증하는 책임을 진다.

    public class Order extends EntryPoint {
      public Order with(String productName, int quantity) throws OrderLimitExceededException {
        return with(new OrderLineItem(productName, quantity));
      }
    
      private Order with(OrderLineItem lineItem) throws OrderLimitExceededException {
        if (isExceedLimit(customer, lineItem)) {
          throw new OrderLimitExceededException();
        }
    
        lineItems.add(lineItem);           
        
        return this;
      }
    
      private boolean isExceedLimit(Customer customer, OrderLineItem lineItem) {
        return customer.isExceedLimitPrice(getPrice().add(lineItem.getPrice()));
      }
    }

     

    with() 메서드는 제품 명과 수량을 인자로 전달 받아 OrderLineItem 인스턴스를 생성한다. 이 때 주문 애그리게이트의 불변식을 검증하기 위해 isExceedLimit() 메소드를 호출한다. isExceedLimit() 메소드는 현재 주문 총액을 구한 후 Customer 클래스의 isExceedLimitPrice()를 호출하여 주문 가격이 고객의 한도액을 초과했는지 여부를 체크한다. isExceedLimitPrice() 메소드는 한도액 초과 시 OrderLimitExceededException을 던진다.

     

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

    728x90
Designed by Tistory.