ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 도메인 주도 설계의 적용 - 1. 값 객체와 참조 객체 3부
    [옛날 글들] 도메인 주도 설계 2024. 4. 10. 17:48
    728x90

    이전글 : 도메인 주도 설계의 적용 - 1. 값 객체와 참조 객체 2부

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

    생명 주기 제어

    객체 지향 시스템은 거대한 객체들의 네트워크로 구성되어 있다. 객체는 상호 연결된 객체들 간의 협력을 통해 할당된 책임을 완수한다. 일반적으로 한 객체에서 다른 객체로 이동하기 위해 객체 간의 연관 관계를 이용한다. 따라서 특정한 작업을 수행하기 위해서는 얽히고설킨 수많은 객체들 중 어떤 객체에서 항해를 시작할 것인지를 결정해야 한다.

     

    SQL 쿼리를 통해 어떤 결과 목록에라도 접근이 가능한 관계형 데이터베이스와 달리 객체 지향 시스템은 임의의 결과 목록에 자동으로 접근할 수 있는 메커니즘을 제공하지 않는다. 모든 객체가 메모리 상에 존재한다고 가정하고 객체와 객체 간의 관계를 항해함으로써 목적 객체로 이동한다. 따라서 어떤 객체 그룹을 사용할 필요가 있다면 해당 객체 그룹 간의 관계를 항해하기 위한 시작 지점을 선정해야 한다. 이와 같이 객체 그래프 상에서 항해를 시작하기 위한 시작 객체를 엔트로 포인트(ENTRY POINT)라고 한다. 객체 그룹의 엔트리 포인트는 항상 참조 객체여야 한다. 값 객체 엔트리 포인트가 될 수 없다.

     

    사용자 요청이 시스템 내에 도착하면 시스템은 요청을 처리할 객체 그룹을 찾는다. 이 객체 그룹 중 엔트리 포인트에 해당하는 참조 객체가 그룹을 대표하여 요청을 전달받고 작업을 수행하기 위해 필요한 객체들과의 협력을 통해 요청을 완수한다.

     

    따라서 시스템은 임의의 엔트리 포인트에 접근 가능해야 한다. 또한 엔트리 포인트 참조 객체이므로 엔트리 포인트에 접근할 때마다 동일한 객체 인스턴스를 반환 받아야 한다. 이것은 동일한 엔트리 포인트의 요청에 대해 항상 동일한 식별자를 지닌 객체가 반환된다는 것을 의미한다. 따라서 동일한 엔트리 포인트에 대한 요청 결과로 반환 받은 객체들은 “==” 테스트를 통과해야만 할 것이다.

     

    이처럼 엔트리 포인트의 유일성과 추적성을 유지하기 위해서는 엔트리 포인트를 관리하는 특별한 객체가 필요하다. 이 특별한 객체는 특정한 엔트리 포인트의 목록을 유지하고 클라이언트에 엔트리 포인트에 대한 관리 인터페이스를 제공한다. , 엔트리 포인트와 관련된 추가, 수정, 삭제, 조회 등의 컬렉션 처리를 수행한다. 엔트리 포인트가 필요한 경우 관리 객체에게 해당 엔트리 포인트를 찾아 줄 것을 요청한다. 모든 엔트리 포인트에 대한 검색이 해당 관리 객체를 통해 이루어지기 때문에 시스템의 모든 부분은 항상 동일하고 유일한 엔트리 포인트를 대상으로 작업을 수행할 수 있다.

     

    엔트리 포인트에 대한 관리 인터페이스를 구성하는 방법에는 두 가지가 존재한다.

    • 각각의 엔트리 포인트가 스스로 관리 인터페이스를 제공한다.
    • 별도의 객체가 엔트리 포인트에 대한 관리 인터페이스를 제공한다.

     두 방법 모두 생성된 엔트리 포인트를 메모리 내에서 검색하기 위한 메커니즘을 필요로 한다.   이를 처리하기 위해 엔트리 포인트는 메모리 내에서 자신을 손쉽게 검색할 수 있도록 검색 키를 제공해야 한다. 우선 모든 엔트리 포인트에 대한 레이어 슈퍼타입(LAYER SUPERTYPE) EntryPoint 클래스를 만들고 검색 키를 반환하는 getIdentity 메소드를 추가한다.

    package org.eternity.common;
    
    public class EntryPoint { 
      private final String identity;
      
      public EntryPoint (String identity) {
        this.identity = identity;
      }
         
      public String getIdentity() {
        return identity;
      }
      
      public EntryPoint persist() {
        Registrar.add(this.getClass(), this);
        
        return this;
      }
    }

     

    EntryPoint 엔트리 포인트 검색에 사용될 검색 키인 identity를 생성자의 인자로 전달받는다. 따라서 EntryPoint를 상속 받게 될 모든 엔트리 포인트는 객체 생성 시 자신의 identity를 제공하도록 강제된다. 객체가 생성된 후에는 persist 메소드를 통해 엔트리 포인트 관리 객체를 사용하여 자기 자신을 등록한다. 등록된 엔트리 포인트는 검색 키를 사용하여 다시 조회할 수 있다.

     

    이제 메모리 내의 엔트리 포인트 컬렉션을 관리할 Registrar 클래스를 작성한다. Registrar 클래스는 싱글턴(SINGLETON)이며 EntryPoint들의 Class identity를 사용하여 각 엔트리 포인트들을 관리한다.

    package org.eternity.common;
    
    import java.util.Collection;
    import java.util.Collections;
    import java.util.HashMap;
    import java.util.Map;
    
    public class Registrar {
      private static Registrar soleInstance = new Registrar();
      private Map<Class<?>,Map<String,EntryPoint>> entryPoints = 
        new HashMap<Class<?>, Map<String, EntryPoint>>();
    
      public static void init() {
        soleInstance.entryPoints = 
          new HashMap<Class<?>, Map<String, EntryPoint>>();
      }
    
      public static void add(Class<?> entryPointClass, EntryPoint newObject){
        soleInstance.addObj(entryPointClass, newObject);
      }
    
      public static EntryPoint get(Class<?> entryPointClass, String objectName) {
        return soleInstance.getObj(entryPointClass, objectName);
      }
    
      public static Collection<? extends EntryPoint> getAll(Class<?> entryPointClass) {
        return soleInstance.getAllObjects(entryPointClass);
      }
    
      private void addObj(Class<?> entryPointClass, EntryPoint newObject) {
        Map<String,EntryPoint> theEntryPoint = entryPoints.get(entryPointClass);
      
        if (theEntryPoint == null) {
          theEntryPoint = new HashMap<String,EntryPoint>();
          entryPoints.put(entryPointClass, theEntryPoint);
        }
    
        theEntryPoint.put(newObject.getIdentity(), newObject);
      }
      
      private EntryPoint getObj(Class<?> entryPointClass, String objectName) {
        Map<String,EntryPoint> theEntryPoint = entryPoints.get(entryPointClass);
        
        return theEntryPoint.get(objectName);
      }
    
      @SuppressWarnings("unchecked")
      private Collection<? extends EntryPoint> getAllObjects(Class<?> entryPointClass) {
        Map<String,EntryPoint> foundEntryPoints = entryPoints.get(entryPointClass);
    
        return (Collection<? extends EntryPoint>)Collections.unmodifiableCollection(
          foundEntryPoints != null ? entryPoints.get(entryPointClass).values() : Collections.EMPTY_SET);
      }
    }

     

    이제 Customer 객체가 EntryPoint를 상속 받도록 수정한다. 고객에 대한 검색 키로는 고객 번호인 customerNumber를 사용한다.

    public class Customer extends EntryPoint {
      public Customer(String customerNumber, String name, String address) {
        super(customerNumber);
        this.customerNumber = customerNumber;
        this.name = name;
        this.address = address;
      }
    }

     

    이제 Registrar 클래스를 사용하여 고객의 유일성을 유지할 수 있다. 엔트리 포인트 관리를 위한 두 가지 방법 중에서 먼저 Customer 클래스 자체에 컬렉션 관리 인터페이스를 추가하는 방식을 살펴보자. 우선 Customer 클래스의 검색을 위한 테스트 케이스를 작성하자.

    public class CustomerTest extends TestCase {
      public void setUp() {
        Registrar.init();
      }
    
      public void testCustomerIdentical() {
        Customer customer = new Customer("CUST-01", "홍길동", "경기도 안양시").persist();
        Customer anotherCustomer = Customer.find("CUST-01");
      
        assertSame(customer, anotherCustomer);
      }
    }

     

    모든 테스트 케이스가 독립적이어야 한다는 테스트의 기본 원칙을 지키기 위해 setUp 메소드 안에서 Registrar를 초기화시켰다. 테스트 메서드인 testCustomerIdentical 안에서는 Customer 클래스의 인스턴스를 생성한 후 persist 메서드를 사용하여 Registrar에 등록한다. Customer 클래스의 검색 키인 고객 번호를 find 메서드의 인자로 전달하여 Customer 객체를 조회한 후 반환된 anotherCustomer가 이미 등록된 Customer 클래스와 동일한 식별자를 가지고 있는지 검사한다. 동일성 식별에 “==” 연산자가 사용되었음에 주목하자.

     

    이제 실패하는 테스트를 가지게 되었다. , 내가 이 이야기를 했던가? 코드를 작성하기 전에 테스트를 작성하는 것은 좋은 습관이다. 테스트 우선 접근(Test-First Approach) 또는 테스트 주도 개발(Test-Driven Development)라고 불리는 이 방법은 우선 실패하는 테스트를 작성한 후 테스트를 성공시키는 방법으로 코드를 작성한다. 이 테스트를 통과하도록 Customer 클래스를 수정하자.

    public class Customer extends EntryPoint {
      public static Customer find(String customerName) {
        return (Customer)Registrar.get(Customer.class, customerName);
      }
    
      public Customer persist() {
        return (Customer)super.persist();
      }
    }

     

    EntryPoint persist를 오버라이딩 한 이유는 persist 메서드의 반환형을 Customer로 수정하고 싶기 때문이다. EntryPoint 클래스의 persist 메소드의 경우 EntryPoint 타입을 반환하기 때문에 persist 메서드를 호출하는 클라이언트 측에서 매번 형변환을 해야 한다. 따라서 EntryPoint 클래스의 persist 메소드를 오버라이드하여 각 엔트리 포인트가 자신의 타입을 반환하도록 하여 형변환을 할 필요가 없도록 만드는 것이 더 사용하기 편리한 인터페이스를 만드는 방법이다.

     

    다음은 수정된 Customer 클래스의 코드를 나타낸 것이다.

    package org.eternity.customer;
    
    import org.eternity.common.EntryPoint;
    import org.eternity.common.Registrar;
    
    public class Customer extends EntryPoint {
      private String customerNumber;
      private String name;
      private String address;
      private long mileage;
    
      public Customer(String customerNumber, String name, String address) {
        super(customerNumber);
        this.customerNumber = customerNumber;
        this.name = name;
        this.address = address;
      }   
    
      public static Customer find(String customerName) {
        return (Customer)Registrar.get(Customer.class, customerName);
      }
        
      public Customer persist() {
        return (Customer)super.persist();
      }
    
      public void purchase(long price) {
        mileage += price * 0.01;
      }
    
      public boolean isPossibleToPayWithMileage(long price) {
        return mileage > price;
      }
    
      public boolean payWithMileage(long price) {
        if (!isPossibleToPayWithMileage(price)) {
          return false;
        }
        
        mileage -= price;
    
        return true;
      }
    
      public long getMileage() {
        return mileage;
      }
    }

     

    다음글 : 도메인 주도 설계의 적용 - 1. 값 객체와 참조 객체 4부

     

    728x90
Designed by Tistory.