ABOUT ME

-

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

     

    프로그램을 가장 훌륭하게 작성하는 방법은 상태가 변경되는
    오브젝트들과 수학적인 값을 나타내는 오브젝트들의 조합으로 표현하는 것이다.
    - Kent Beck

     

     

    애플리케이션을 구성하는 객체들을 참조 객체(REFERENCE OBJECT) 값 객체(VALUE OBJECT)로 분류할 수 있다. 참조 객체는 고객, 주문과 같이 실 세계의 추적 가능한 개념을 표현한다. 시스템 사용자는 오늘 상품을 주문한 “홍길동”라는 고객과 한 달 전에 상품을 구입한 “홍길동”이라는 고객이 동일 인물인지를 알기 원한다. 따라서 고객 객체는 시스템 내에서 유일하게 식별 가능해야 한다. 값 객체는 날짜, 금액과 같은 것들이다. 단지 객체를 구성하는 속성들의 값에만 초점을 맞출 뿐 객체의 추적성에는 관심을 두지 않는다. 오늘 출금한10,000원짜리 지폐가 한 달 전에 입금한 10,000원짜리10,000 지폐와 동일한 지폐일 필요는 없다. 단지10,000원이라는 금액이 정확하게 인출됐는지 여부가 중요할 뿐이다.

                                                                           

    시스템 내에서 해당 객체를 계속 추적해야 하는가? 객체가 표현하는 개념이 유일하게 하나만 존재해야 하는가? 그렇다면 참조 객체로 만든다. 단지 객체가 추적할 필요가 없는 단순한 값인가? 속성값이 동일하면 동일한 객체로 간주해도 무방한가? 고민할 필요 없다. 그냥 값 객체로 만든다.

     

    참조 객체 값 객체의 개념은 단순하다. 그러나 추적성의 진정한 의미를 이해하기 위해서는 다양한 문맥 내에서 이들의 차이점을 살펴볼 필요가 있다. 우선 참조 객체와 값 객체의 동일성에 관해 살펴보기로 하자.

    동일함(identical)의 의미

    모든 객체 지향 시스템은 생성된 객체에게 고유한 식별자(identity)를 부여한다. 대부분의 객체 지향 언어는 객체가 위치하고 있는 메모리 상의 주소를 객체의 식별자로 할당하고 이 주소 값을 사용하여 객체를 구별한다. 각 언어는 객체의 식별자를 비교할 수 있는 연산자를 제공하는데 Java의 경우 “==”와 “!=” 연산자를 사용한다. 두 참조가 가리키는 객체가 동일한 식별자를 가지는 경우, 즉 동일한 주소에 위치하는 경우 “==” 연산자는 true를 반환한다.

     

    다음은 실세계의 고객을 표현하는 Customer 클래스를 나타낸 것이다. 고객이 상품을 구매할 때마다 구매액의 1%가 마일리지로 적립된다. 적립된 마일리지는 다음 상품 구매 시 현금과 동일하게 사용할 수 있다.

    package org.eternity.customer;
    
    public class Customer {
      private String customerNumber;
      private String name;
      private String address;
      private long mileage;
    
      public Customer(String customerNumber, String name, String address) {
        this.customerNumber = customerNumber;
        this.name = name;
        this.address = address;
      }
    
      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;
      }
    }

     

    고객 개개인은 시스템 내에서 유일해야 하며 시스템은 고객의 구매 기록이나 마일리지 적립 상태를 지속적으로 추적할 수 있어야 한다. 각 고객이 유일하기 때문에 고객이 동일한 지를 판단하기 위해 메모리 주소를 비교하는 “==” 연산자를 사용한다.

     

    반면 10,000원이라는 금액은 시스템 내에 유일하게 존재할 필요가 없다. 내 계좌의 입금 내역에 찍힌 10,000원이라는 금액과, 카드 영수증에 출력된 10,000원은 동등한 금액이지만 이들이 반드시 동일한 객체일 필요는 없다. , 금액의 경우 객체의 동일성(identity) 보다는 속성 값의 동등성(equality)을 더 중요하게 생각한다.

     

    따라서 “==” 연산자를 사용하여 동일성을 판단하기보다는 equals() 메서드를 오버라이딩하여 금액의 동등성을 테스트해야 한다. equals() 메소드를 오버라이딩 할 경우에는 hashCode() 메서드도 함께 오버라이딩해 주는 것이 좋다. 다음은 금액을 클래스로 작성한 것이다.

    package org.eternity.customer;
    
    import java.math.BigDecimal;
    
    public class Money {
      private BigDecimal amount;
    
      public Money(BigDecimal amount) {
        this.amount = amount;
      }
    
      public Money(long amount) {
        this(new BigDecimal(amount));
      }
    
      public boolean equals(Object object) {
        if (this == object) {
          return true;
        }
    
        if (!(object instanceof Money)) {
          return false;
        }           
    
        return amount.equals(((Money)object).amount);
      }
    
      public int hashCode() {
        return amount.hashCode();
      }
    
      public Money add(Money added) {
        this.amount = this.amount.add(added.amount);
        
        return this;
      }
    
      public String toString() {
        return amount.toString();
      }
    }

     

    고객은 참조 객체의 일반적인 예이며, 금액은 값 객체의 일반적인 예이다.  참조 객체는 유일하기 때문에 동일성 확인 시에 식별자를 사용하는 “==” 연산자를 사용할 수 있다. 값 객체의 경우 equals() 메서드를 사용하여 속성 값의 동등성을 비교해야 한다.

     

    아마 이 시점에 이르면 자연스럽게 다음과 같은 질문이 떠오를 것이다. 금액과 같은 값 객체 참조 객체처럼 하나의 인스턴스만 유지하고 “==” 연산자를 사용하여 동일성을 비교할 수 없을까? 왜 객체를 비교할 때 “==” 연산자와 equals() 메소드를 구별하여 적용해야 하는가? 근본적으로 참조 객체 값 객체를 구별하는 이유가 무엇인가? 이에 대한 해답은 참조 객체 대신 값 객체를 사용함으로써 악명 높은 별칭(aliasing) 문제를 피할 수 있기 때문이다.

    별칭(aliasing) 문제

    java에서는 하나의 객체를 서로 다른 변수가 참조할 수 있다. 이처럼 동일한 객체를 서로 다른 변수가 참조하는 것을 별칭(aliasing)이라고 한다. 별칭을 가진 객체의 상태를 변경할 경우 골치 아픈 버그가 발생할 수 있다. 만약 다른 참조를 통해 객체에 접근하는 쪽에서 객체가 변경되었다는 사실을 예상하지 못한다면 어떻게 될까?

     

    별칭 문제를 확인하기 위해 우선 Customer 클래스에 대한 테스트 케이스를 작성하자.

    public void testAliasing() {
      Customer customer = new Customer("CUST-01", "홍길동", "경기도 안양시");
      Customer anotherCustomer = customer;
    
      long price = 1000;
      customer.purchase(price);
    
      assertEquals(price*0.01, anotherCustomer.getMileage(), 0.1);
      assertEquals(0, anotherCustomer.getMileage());
    }

     

    이름이 홍길동인 고객 객체를 생성하고 customer anotherCustomer 두 참조 변수를 사용해서 별칭을 만들었다. 따라서 홍길동이라는 고객은 customer anotherCustomer라는 두 개의 참조를 통해 접근 가능하다. 별칭 생성 후 customer 1,000원짜리 상품을 구매하여 마일리지리 1%를 적립한다.

     

    customer anotherCustomer가 동일한 고객 객체를 참조한다는 사실을 알지 못하는 불행한 프로그래머는 anotherCustomer의 마일리지가 초기값 그대로 유지될 것이라고 예상할 것이다. 그러나 이런 예상은 모니터를 가로지르는 악명 높은 빨간 막대를 보는 순간 산산이 부서지고 만다. 그렇다. 테스트가 실패하고 만 것이다.

     

    이유는 별칭 때문이다. 세부적인 구현 내용을 알지 못한다면 customer anotherCustomer가 동일한 고객 객체를 참조한다는 사실을 알 수 없을 것이다. 따라서 anotherCustomer가 참조하고 있는 객체의 상태가 변경될 것이라는 사실을 예상하지 못할 경우 위와 같이 미묘하고도 발견하기 어려운 버그에 직면하게 된다. 물론 위 코드처럼 동일한 메서드 내에서 서로 다른 참조 변수를 사용하는 경우에는 원인을 발견하고 문제를 해결하는 것이 비교적 간단하다. 그러나 만약 customer anotherCustomer가 거리적으로 멀리 떨어진 프로그램의 서로 다른 위치에서 사용된다면 어떻게 될까? 아마도 충혈된 눈으로 끔찍한 버그를 찾기 위해 코드 이곳저곳을 뒤지고 다니는 자신을 발견하게 될 것이다.

     

    따라서 고객 객체를 다루는 가장 효과적인 방법은 별칭을 만들지 않는 것이다. 이야기가 여기서 끝난다면 프로그래머 생활도 그렇게 험난하지만은 않았을 것이다. 별칭을 만들지 않는 정책의 가장 큰 문제는 별칭이 만들어지는 것을 막을 수 없다는 점이다.

     

    동일한 메서드,동일한 클래스 내에서라면 의식적으로 별칭을 만들지 않을 수 있다. 그러나 해당 객체를 다른 메서드의 인자로 전달하는 순간 별칭 문제는 다시 끔찍한 고개를 쳐들고 코드 언저리를 스멀거리며 돌아다니기 시작한다. 메소드의 인자로 객체를 전달한다는 것은 자동으로 객체의 별칭을 만든다는 것을 의미한다. 이것이 값에 의한 전달(pass-by-value)인가, 참조에 의한 전달(call-by-reference)인가에 관한 논쟁은 중요하지 않다. 여기에서 중요한 것은 메서드를 호출하는 순간 전달된 인자에 대한 별칭이 자동으로 생성된다는 것이다.

     

    다음은 Money 클래스를 사용할 경우의 자동 별칭 문제를 검증하는 테스트 케이스를 나타낸 것이다. 2,000원이라는 금액을 생성하고 이를 doSomethingWithMoney() 메서드의 인자로 전달한다. 호출한 쪽에서는 메서드가 종료된 후 금액이 변경되지 않았을 것이라고 가정한다. 그러나 별칭을 통해 금액을 변경하는 것이 가능하기 때문에 이런 예상은 보기 좋게 빗나가고 만다. 다시 빨간 막대다.

    public void testMehodAliasing() {
      Money money = new Money(2000);
      doSomethingWithMoney(money);
      assertEquals(new Money(2000), money);
    }
    
    private void doSomethingWithMoney(final Money money) {
      money.add(new Money(2000));
    }

     

    메소드 인자에 final 사용한다고 해도 별칭 문제를 막을 수 없다. java final C++ const와 달리 단지 메소드 내부에서 다른 객체를 참조하지 않도록 막아 주는 역할만을 할 뿐이다. 객체가 final로 전달되더라도 전달된 객체 자체의 상태를 바꾸는 것이 가능하다는 사실에 주의하자.

     

    Bruce Eckel은 그의 저서 “Thiniking in Java 3rd Edition”에서 객체를 메소드 인자로 전달할 경우 다음 사항을 주의해야 한다고 충고한다.

    • 인자를 전달하는 동안 별칭(aliasing)이 자동으로 생성된다.
    • 지역 객체란 존재하지 않는다. 다만 지역 참조만이 존재할 뿐이다.
    • 참조는 범위(scope)를 가지지만 객체는 그렇지 않다.
    • 객체의 생명주기는 java에서 이슈가 아니다.
    • [java에는] 오브젝트의 수정과 별칭의 부정적인 영향을 막을(const와 같은) 언어적인 지원 메커니즘이 존재하지 않는다. 인자 목록에 final을 사용할 수는 있지만 이것은 단순히 참조가 다른 객체와 다시 묶이는 것을 막아줄 뿐이다.

    좋은 객체 지향 습관을 따른다면 인자로 전달된 객체의 상태를 바꾸는 메서드는 작성하지 않을 것이다. 그러나 세상은 그리 녹녹지 않다. 신뢰할 수 있는 코드보다는 신뢰할 수 없는 코드가 더 많은 것이 각박한 현실이다. 따라서 우리는 가능한 방어적으로 프로그래밍할 필요가 있다.

     

    별칭 문제를 해결하기 위한 가장 좋은 방법은 객체를 변경할 수 없는 불변 상태로 만드는 것이다. 전달된 객체가 변경될 수 없다면 메서드에 객체를 전달한다고 하더라도 별칭을 통한 부작용을 막을 수 있다.

     

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

    728x90
Designed by Tistory.