2012. 2. 7. 17:35
본 포스트는 이터너티님의 DDD(Domain-Driven Design) 해설을 학습한 과정을 정리한 것입니다.

PART 1. VALUE OBJECT와 REFERENCE OBJECT

어플리케이션을 구성하는 객체는 Value Object와 Reference Object로 나뉜다. VO와 RO를 구분하는 기준에 대해서 이터니티 님은 아래와 같은 가이드를 제시한다.

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

Reference Object는 유일하게 존재해야 하는 것이라고 말한다. 어떤 것들이 해당할까?

- 유일하게 존재한다는 것은 객체들이 같거다 혹은 다르다는 점을 명확히 구분할 수 있어야 한다. 달리 말해, ID를 가지는 객체들을 말한다.
- 물리적인 장치(DB, file system)에 저장되거나, 네트워크를 통해 전송되는 객체들이다. 잠시 만들었다가 사라지는 데이터에 추적할 필요가 없기 때문이다.

이러한 두가지 특징을 가지는 객체들은 무엇이 있을까? 가장 좋은 사례는 DB에 저장되며, 기본 키(primary key)를 가지는데이터(혹은 엔티티)일 것이다. Reference Object로 정의해야 것들은 최종적으로 DB에 저장되는 객체들이라고 간주하는 것이 무방하다.
 
동일함의 의미 

앞서, Reference Object들은 유일하게 존재해야 한다고 정의했다. 그렇다면, Reference Object를 만들어 쓴다는 것은  유일한 객체임을 보장할 수 있는 수단이 제공되어야 한다는 말이다. 이너니티님의 글을 다시 인용한다.

모든 객체 지향 시스템은 생성된 객체에게 고유한 식별자(identity)를 부여한다. 대부분의 객체 지향 언어는 객체가 위치하고 있는 메모리 상의 주소를 객체의 식별자로 할당하고 이 주소 값을 사용하여 객체를 구별한다.
 
그렇다면 자바에서 객체의 고유 식별자를 알아내는 방법이 있을까? 그리고, 고유 식별자를 이용해 유일성(uniqueness)을 보장받을 수 있을까?

In java there is no any specific method that provides us the object's ID. But each object has its own unique hash value which can be treated as unique Id for that object.
- Rose India 인용

자바에서는 해시 코드(hash code)를 객체의 고유 식별자로 사용할 수 있다고 한다. 그런데, 유일성을 확실히 보장해주지 않는다고 말한다.이게 대체 무슨 말인가? 아래와 같이 설명할 수 있다.

- 동일한 객체는 항상 동일한 해시 코드를 반환한다.
- 하지만, 서로 다른 객체가 동일한 해시 코드를 반환 할 수도 있다.

달리 말해서, 동명이인(同名異人)이 존재할 수도 있다는 말이다. 즉, 해시 코드는 우리가 특정 사람을 지칭할 때 쓰는 이름과 같은 것이다. 그래서, 이너니티님은 아래와 같이 가이드하고 있다.

각 언어는 객체의 식별자를 비교할 수 있는 연산자를 제공하는데 Java의 경우 “==”와 “!=” 연산자를 사용한다. 두 참조가 가리키는 객체가 동일한 식별자를 가지는 경우, 즉 동일한 주소에 위치하는 경우 “==” 연산자는 true를 반환한다.

“==” 연산자를 사용하여 동일성을 판단하기 보다는 equals() 메소드를 오버라이딩하여 금액의 동등성을 테스트해야 한다.

한 발 더 나아가, 동일성(identity)와 동등성(equality)의 차이에 대해서 얘기해 보자.

- 동일성(identity)은 비교하려는 두 개의 객체가 동일한 식별자를 반환할 경우 같다고 판단하는 것이다.
- 동등성(equality)는 두 객체의 내용 (혹은 데이터)이 일치할 경우, 같다고 판단하는 것이다.

대부분의 상황에서는 '동일성 비교'를 기본으로 적용한다. 그러나, DDD(Domain Driven Design)에서는 동등 비교를 원칙으로 한다. (DDD에서는 데이터를 안전하고, 정확하게 사용하는 것이 주된 관심사이다.) 그러니 개발자들은 Reference Object 및 Value Object를 정의함에 있어서 어떻게 동등 비교를 실현할 수 있는지 그 방법론을 정확히 이해해야 한다.

동일 비교 시에 발생하는 문제

일반적인 동일 비교 방식을 적용했을 때, 발생하는 문제에 대해서 간단한 코드를 작성해서 테스트해 보도록 하자. 먼저, 금액을 나타내는 클래스를 작성해 보았다.

 
package org.eternity.customer;

public class Money {
	private long amount;
	
	public Money(long amount) {
		this.amount = amount;
	}
	
	public Money(int amount) {
		this.amount = amount;
	}

	public String toString() {
		return String.valueOf(amount);
	}
}
같은 데이터를 가지고 있지만, 다른 객체로 인식되는 경우를 확인해 보자. 단위 테스트 코드를 아래와 같이 작성했다. 서로 다른 객체이지만, 분명 동일한 금액을 가지고 있다.

package test.eternity.customer;

import static org.junit.Assert.*;

import org.eternity.customer.Money;
import org.junit.Test;

public class MoneyTest {

	@Test
	public void testAmount() {
		Money money1 = new Money(1000);
		Money money2 = new Money(1000);
	
		System.out.println( "money1's hash code = " + money1.hashCode() );
		System.out.println( "money2's hash code = " + money2.hashCode() );

		// money1 == money2 (?)
		assertEquals(money1, money2);
	}

}
테스트는 실패하고 만다. 서로 다른 객체로 판정하는 것이다. 왜 이런 결과가 나오는지 확인하기 위해, 해시 코드의 출력 값을 확인해 보면 아래와 같다. (해시 코드 값은 실행할 때마다 달라질 수 있다.)

money1's hash code = 4565111
money2's hash code = 20392474
이제 동등 비교를 하기 위해 Money 클래스를 아래와 같이 수정했다.

package org.eternity.customer;

public class Money {
	private long amount;
	
	public Money(long amount) {
		this.amount = amount;
	}
	
	public Money(int amount) {
		this.amount = amount;
	}

	public String toString() {
		return String.valueOf(amount);
	}
	
	public boolean equals(Object other) {
		if(this == other) 
			return true;
		
		if(!(other instanceof Money))
			return false;
		
		if( this.amount == ((Money)other).amount )
			return true;
		
		return false;
		
	}
}
이제 단위 테스트를 통과했다. 그런데, 뭔가 문제가 남아 있다. 테스트를 통과했는데 뭐가 문제냐고? 두 개의 객체가 동일하다는 결과가 나오는데 해시 코드는 서로 다르게 출력되는 것이다. 자바의 기본 원칙을 위배한 것이다.

Equal objects must produce the same hash code as long as they are equal, however unequal objects need not produce distinct hash codes. - Equals and Hash Code 참조 

자바의 원칙을 준수하면서, 동등 비교를 구현하기 위해서는 equals() 메소드와 hashCode() 메소드를 함께 오버라이드(override) 해야만 한다.


package org.eternity.customer;

public class Money {
	private long amount;
	
	public Money(long amount) {
		this.amount = amount;
	}
	
	public Money(int amount) {
		this.amount = amount;
	}

	public String toString() {
		return String.valueOf(amount);
	}
	
	public boolean equals(Object other) {
		if(this == other) 
			return true;
		
		if(!(other instanceof Money))
			return false;
		
		if( this.amount == ((Money)other).amount )
			return true;
		
		return false;
		
	}
	
	public int hashCode()
	{
		return (int)(amount ^ (amount >>> 32));
	}
}
이제 테스트를 수행하면 두 객체가 동일하다고 판정되고, 해시 코드 역시 동일한 값을 출력한다.
money1's hash code = 1000
money2's hash code = 1000
hashCode() 메소드는 자바의 컬렉션 클래스들(List, Map 등) 에서 보이지 않게(?) 호출되며, 다양한 API, 라이브러리에서 객체를 관리하기 위한 목적으로 해시 코드 반환 값을 사용하기 때문에 equals() 함수를 오버라이드(override)할 경우에는 잊지 말고, 함께 구현해야 한다. 혹여 잊어 버린다고 해서 컴파일 오류는 나지 않는다. 하지만, 예기치 않은 문제가 발생할 경우에는 원인을 추적하기 매우 어렵게 된다. 어떤 문제가 발생할 수 있는지, hashCode() 함수를 오버라이드 하지 않고 아래와 같은 테스트 코드를 작성했더니, 역시나 문제가 발생했다. 길지 않은 코드이니 직접 컴파일 한 후 확인해 보시는 것을 권장한다.
package test.eternity.customer;

import static org.junit.Assert.*;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.eternity.customer.Money;
import org.junit.Test;

public class MoneyTest {

	@Test
	public void testAmount() {
		Money money1 = new Money(1000);
		Money money2 = new Money(1000);
		
		System.out.println( "money1's hash code = " + money1.hashCode() );
		System.out.println( "money2's hash code = " + money2.hashCode() );
	
		// money1 == money2
		assertEquals(money1, money2);
	}
	
	@Test
	public void setSet() {
		Money money1 = new Money(1000);
		Money money2 = new Money(1000);
		
		Set moneyPocket = new HashSet();
		moneyPocket.add(money1);
		assertEquals(1, moneyPocket.size());
		moneyPocket.add(money2);
		assertEquals(2, moneyPocket.size());
		
		Map moneyMap = new HashMap();
		moneyMap.put(money1, 1);
		moneyMap.put(money2, 2);
		
		assertEquals(1, moneyMap.size());
	}
}
Posted by 곽중선