ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 도메인 주도 설계의 적용-4.ORM과 투명한 영속성 4부
    [옛날 글들] 도메인 주도 설계 2024. 5. 2. 20:01
    728x90

    이전글 : 도메인 주도 설계의 적용-4.ORM과 투명한 영속성 3부

     

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

    영속성 관리 리포지토리

    Hibernate는 투명한 영속성을 지원하는 오픈 소스 ORM으로 Java 커뮤니티에서 사용되는 ORM의 표준이다. Hibernate EJB 3.0의 엔티티 빈 스펙인 JPA에 큰 영향을 끼쳤으며 JBoss의 엔티티빈 구현체로 포함되어 있다. Spring 프레임웍은 Hibernate를 통합하기 편리하도록 여러 가지 지원 클래스들을 제공한다. 주문 시스템 역시 Hibernate를 적용하기 위해 Spring의 지원 클래스를 사용할 것이다.

    다음은 주문 시스템의 도메인 모델과 매핑될 데이터 모델이다
    .

    그림 3 주문 도메인의 데이터 모델


    데이터베이스는 HSQLDB를 사용한다. HSQLDB 다음과 같은 명령을 사용하여 실행시키면 된다
    .

    java -classpath lib\hsqldb.jar org.hsqldb.Server

     

    Hibernate는 XML 형식과 Annotation 형식의 메타 데이터 매핑(METADATA MAPPING)을 지원한다. 본 아티클에서는 XML 형식의 메타 데이터 매핑을 사용한다. XML 파일은 각 엔트리 포인트 별로 작성한다. , Customer, Product, Order 별로 하나의 XML 매핑 파일을 작성하고 OrderLineItem에 대한 매핑 정보는 Order 매핑 파일에 포함한다.

    우선 Product PRODUCTS 테이블 간의 매핑을 살펴보자. 지금까지는 도메인 객체들을 메모리 컬렉션 내에 관리하기 위해 EntryPoint라는 레이어 슈퍼타입(LAYER SUPERTYPE)을 상속받아 구현했다. EntryPoint Registrar 클래스가 도메인 객체를 클래스별로 관리할 수 있도록 강제하기 위해 추가된 상위 클래스이다. 따라서 지금까지의 도메인 모델은 Registrar라는 인프라 스트럭처와 결합되어 있었다이처럼 하위 인프라 스트럭처에 의해 사용되는 클래스나 인터페이스에 도메인 레이어의 클래스들이 의존하는 것을 침투적(invasive)이라고 한다.

    Spring
     Hibernate는 비침투적이다. 비침투적인 프레임워크를 사용할 경우 도메인 클래스들은 하위 인프라스트럭처에 독립적이다. , 프레임워크의 특정 인터페이스를 구현하거나 클래스를 상속받을 필요가 없다. 우리가 궁극적으로 지향하는 순수한 객체 지향 모델로 도메인 모델을 구성할 수 있는 것이다

    Order 
    클래스와 EntryPoint 간의 상속 관계를 제거하자. 이제 Order는 어떤 하위 인프라 스트럭처에도 의존하지 않는 순수한 객체이다. 이처럼 어떤 인프라 스트럭처 코드에도 의존하지 않는 순수한 Java 객체를 POJO(Plain Old Java Object)라고 한다. 주문 엔티티의 추적성을 보장하기 위해서는 Order 클래스에 식별자 필드(IDENTITY FIELD)를 추가해야 한다. , ORDERS 테이블의의 주 키를 Order 클래스의 속성으로 포함시켜야 한다. ORDERS 테이블의 주 키와 매핑될 Long 타입의 id 속성을 추가하자.

    packageorg.eternity.customer;
    
    import java.util.HashSet;
    import java.util.Set;
    
    public class Order {
      private Long id;
      private String orderId;
      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) {
        this.customer = customer;
        this.orderId = orderId;
      }
    
      public Long getId() {
        return id;
      }
    }

     

    EntryPoint로부터의 상속 관계가 제거되었으며 id를 반환하기 위한 getter 메소드가 추가되었다. id를 반환하는 getter 메소드를 추가한 이유는 대부분의 엔터프라이즈 애플리케이션이 프리젠테이션 레이어에서 해당 도메인 객체의 id를 필요로 하기 때문이다.

    이제 equals() hashCode()를 구현하자. 앞에서 설명한 바와 같이 equals() hashCode()에서는 대리 키 대신 자연 키를 비교해야 한다. Order를 유일하게 식별하면서도 잘 변하지 않는 값이 무엇일까? orderId를 사용하면 될 것 같다

    public class Order {
      String getOrderId() {
        return orderId;
      }
    
      publicboolean equals(Object object) {
        if (object == this) {
          return true;
        }
    
        if (!(object instanceof Order)) {
          return false;
        }
    
        final Order other = (Order)object;
        return this.orderId.equals(other.getOrderId());
      }
    
      public int hashCode() {
        return this.orderId.hashCode();
      }
    }


    equals()
    에서 파라미터로 전달된 객체의 orderId 파라미터를 비교하지 않고 별도의 getOrderId() 메소드를 호출한 이유는 비교 대상으로 Proxy 객체가 전달될 수도 있기 때문이다. 따라서 equals()에서 비교를 수행하기 위해서는 getter 메소드를 직접 호출해서 지연 로딩이 일어나도록 해야 한다. 도메인 모델이 하부 인프라스트럭처에 완전히 독립적일 수 없다는 것이 바로 이런 이유 때문이다. ORM으로 Hibernate를 쓰는 경우 Hibernate에 의한 제약사항이 도메인 모델의 구현에 미세하나마 영향을 미친다. 그러나 자기 캡슐화(self-encapsulation)는 좋은 습관이므로 지킨다고 해서 손해 볼 것은 없을 것이다.

     

    OrderLineItem에도 Long 타입의 식별자 필드를 추가한다. @Cofigurable Annotation이 계속 사용되고 있음에 주목하자. @Cofigurable Annotation Spring 컨테이너 외부에서 생성되는 객체에 Spring 컨테이너에서 선언된 빈을 의존 삽입하기 위해 사용된다. 여기에서는 Hibernate가 생성하는 OrderLineItem 객체에 ProductRepository 타입의 빈을 의존 삽입하기 위해 사용되고 있다.

    package org.eternity.customer;
    
    import org.springframework.beans.factory.annotation.Configurable;
    
    @Configurable(value="orderLineItem",preConstruction=true)
    public class OrderLineItem {
      private Long id;
      private Product product;
      private int quantity;
    
      private ProductRepository productRepository;
    
      public OrderLineItem() {
      }
    
      public OrderLineItem(String productName, int quantity) {
        this.product = productRepository.find(productName);
        this.quantity = quantity;
      }
    
      public void setProductRepository(ProductRepository productRepository) {
        this.productRepository = productRepository;
      }
    }

     

    OrderLineItem Order HashSet에 저장되기 때문에 equals() hashCode()를 반드시 오버라이딩해야 한다. 하나의 Order 내에는 하나의 Product에 대해 하나의 OrderLineItem 만이 존재해야 한다는 도메인 규칙이 있으므로 Product quantity를 사용하여 비교를 수행하면 될 것 같다. OrderLineItem 역시 지연 로딩 문제를 방지하기 위해 getQuantity() 메소드를 사용한다.

    public class OrderLineItem {
      int getQuantity() {
        return quantity;
      }
    
      public boolean equals(Object object) {
        if (object == this) {
          return true;
        }
    
        if (!(object instanceof OrderLineItem)) {
          return false;
        }
    
        final OrderLineItem other = (OrderLineItem)object;
    
        return this.product.equals(other.getProduct())
          && this.quantity == other.getQuantity();
      }
     
      public int hashCode() {
        int result = 17;
        result = 37*result + product.hashCode();
        result = 37*result + quantity;            
        return result;
      }
    }

     

    매핑 파일은 도메인 클래스 명에 .hbm.xml을 붙이고 도메인 클래스와 동일한 클래스 경로에 위치시키는 것이 관례이다. Order OrderLineItem의 매핑 파일은 Order.hbm.xml이며 org/eternity/customer 클래스 패스에 위치한다.

     

    <?xml version="1.0" encoding="UTF-8"?>
    
    <!DOCTYPE hibernate-mapping PUBLIC
      "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
      "http://hibernate.sourceforge.net/hibernate-mapping-3.0.dtd" >
     
    <hibernate-mapping package="org.eternity.customer" default-access="field">
      <class name="Order" table="ORDERS">
        <id name="id" column="ID" type="long">
          <generator class="native"/>
        </id>           
        <property name="orderId" column="ORDER_ID" type="string" length="10"/>   
        <many-to-one name="customer" class="Customer"
          column="CUSTOMER_ID" not-null="true"/>
        <set name="lineItems" table="ORDER_LINE_ITEMS"
          cascade="all, delete-orphan">
          <key column="ORDER_ID"/>
          <one-to-many class="OrderLineItem"/>
        </set>          
      </class>
     
      <class name="OrderLineItem" table="ORDE_LINE_ITEMS">
        <id name="id" column="ID" type="long">
          <generator class="native"/>
        </id>           
    
        <property name="quantity" column="QUANTITY" type="int"/>
        <many-to-one name="product" class="Product"
          column="PRODUCT_ID" not-null="true"/>        
      </class>
    </hibernate-mapping>

     

    Order 클래스를 ORDERS 테이블에, OrderLineItem 클래스를 ORDER_LINE_ITEMS 테이블에 맵핑한다. <hibernate-mapping> 엘리먼트의 default-access="field"은 테이블의 컬럼이 객체의 속성에 직접 맵핑되도록 한다. Hibernate는 테이블의 컬럼을 도메인 객체의 속성이나 프로퍼티 둘 중 하나에 맵핑되도록 할 수 있다. 개인적으로 속성 맵핑을 선호한다. 프로퍼티 맵핑을 위해 불필요한 gettter/setter 메소드를 추가하는 것은 클래스 추상화의 일관성을 깨기 쉬우며 개발이 번거롭기 때문이다. 프로퍼티 접근에 의한 캡슐화 보장이라는 장점은 ORM이라는 문맥 상에서 볼 때 그다지 설득력이 없어 보인다.

     

    위 맵핑 정보에서 눈여겨볼 부분은 Order 클래스의 lineItems 속성에 OrderLineItem Set으로 맵핑하는 부분이다. Hibernate의 경우 맵핑 파일 내에 cascade 속성을 사용하여 영속성 전이(transitive persistence)를 지원한다. OrderLineItem의 생명주기는 Order에 종속적이므로 Order OrderRepository를 통해 데이터베이스에 저장되거나 삭제되었을 때 함께 저장되어야 한다. 또한 Order Set으로부터 제거되었을 때 데이터베이스에서 삭제되어야 한다. cascade="all, delete-orphan"을 명시함으로써 AGGREGATE의 생명주기 제약 조건을 만족시킬 수 있다.

    <set name="lineItems" table="ORDER_LINE_ITEMS" 
      cascade="all, delete-orphan">
      <key column="ORDER_ID"/>
      <one-to-many class="OrderLineItem"/>
    </set>

     

    이제 Produc 클래스의 변경 사항을 살펴보자.데이터베이스의 주 키를 추적하기 위한 id 속성과 함께 기본 생성자가 추가되었다. 이 역시 하부 인프라스트럭처의 제약 사항이 도메인 클래스의 구현에 영향을 미치는 예로 Hibernate의 경우 객체를 생성할 때 newInstance()를 호출하기 때문에 기본 생성자가 존재해야 한다. 기본 생성자는 public일 필요는 없다.

    public class Product {
      private Long id;
      private Money price;
      private String name;
    
      Product() {           
      }
    
      public Product(String name, long price) { 
        this.name = name;
        this.price = new Money(price);
      }
    }

     

    Product 값 객체 Money를 속성으로 포함한다. 앞에서 설명한 바와 같이 값 객체는 별도의 테이블로 맵핑되지 않고 의존하는 엔티티가 맵핑되는 테이블의 칼럼으로 맵핑된다. Hibernate component를 사용하여 값 객체의 개념을 지원한다. 다음 맵핑 파일을 살펴보자. <component> 엘리먼트는 PRODUCTS 테이블의 PRICE 컬럼의 값을 Mony 클래스의 amount 속성에 맵핑항 후 이를 Product 클래스의 price 속성에 설정한다.

    <?xml version="1.0" encoding="UTF-8"?>
    
    <!DOCTYPE hibernate-mapping PUBLIC
      "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
      "http://hibernate.sourceforge.net/hibernate-mapping-3.0.dtd" >
    
    <hibernate-mapping package="org.eternity.customer" default-access="field">
       <class name="Product" table="PRODUCTS">
         <id name="id" column="ID" type="long">
           <generator class="native"/>
         </id>
         <property name="name" column="NAME" type="string"
           length="10" not-null="true"/>
         <component name="price" class="Money">
           <property name="amount" column="PRICE" type="big_decimal"/>
         </component>
       </class>
    </hibernate-mapping>

     

    다음글 : 도메인 주도 설계의 적용-4.ORM과 투명한 영속성 5부[끝]

    728x90
Designed by Tistory.