 |
|
객체 규약(Object contracts)
Commons Lang 라이브러리에는 통칭해 빌더(builder)라고 알려진 유용한 클래스들이 포함되어 있다. 이 절에서는 java.lang.Object equals 메서드를 작성하고, 이 때 작성할 코드 양을 줄이기 위해 이 클래스 중 하나를 사용하는 법을 배워 본다.
메서드 구현에서 과제
모든 자바 클래스는 특별히 지정하지 않아도 java.lang.Object에서 상속 받는다. 또 알겠지만 Object 클래스에는 통상 재정의(override)되어야 하는 다음 세 개의 메서드가 정의되어 있다.
이 중 equals와 hashCode 메서드는 제대로 작성됐는지 여부가 컬렉션과 심지어 (하이버네이트를 포함한) 영속성 프레임워크(persistence framework) 같은 자바 플랫폼의 다른 측면에 영향을 끼친다는 점에서 특별하다.
equals와 hashCode를 구현해 본 적이 없는 사람은 별 거 아니라고 생각하겠지만 사실 그렇지 않을 수도 있다. Joshua Bloch가 지은 Effective Java(참고자료)를 보면 equals 메서드 구현 상세만 해도 10페이지가 넘는다. 그리고 equals 메서드를 구현하고 나면 반드시 hashCode 메서드도 구현해야 한다(equals에 대한 규약에 따르면 값이 같은 객체 두 개는 해시 코드(hash code)가 같아야 하기 때문이다). Bloch는 hashCode 메서드를 설명하는 데 6페이지를 더 쓴다. 즉, 명백히 단순한 두 메서드를 제대로 구현하는 법에 대한 상세 정보가 최소 16페이지다.
이 equals 메서드를 구현하는 데 있어 과제는 이 메서드가 따라야 하는 규약에 있다.
- 반사적(reflexive)이어야 한다.
- 어떤
null이 아닌 객체 foo에 대해 foo.equals(foo)가 true이어야 한다.
- 대칭적(symmetric)이어야 한다.
null이 아닌 두 객체 foo와 bar에 대해 foo.equals(bar)가 true면, bar.equals(foo)도 true여야 한다.
- 전이적(transitive)이어야 한다.
null이 아닌 세 객체 foo, bar, baz에 대해 foo.equals(bar)가 true고 bar.equals(baz)가 true면 foo.equals(baz)도 true여야 한다.
- 일관성이 있어야 한다.
- 두 객체
foo와 bar에 대해 foo.equals(bar)가 true면 equals 메서드는 (두 객체가 실제 변경되지 않는다는 전제 하에) 몇 번을 호출하든 항상 true여야 한다.
null 값을 제대로 처리해야 한다.
foo.equals(null)은 false를 돌려줘야 한다.
위 조건을 읽고 Effective Java 책을 공부한 뒤라도 여러분의 Account 객체의 equals 메서드를 제대로 구현하는 것이 만만치 않다고 느낄지도 모른다. 하지만 앞서 생산성과 활동에 대해 언급했던 것을 명심하자.
자신의 사업을 위해 온라인 웹 애플리케이션을 만든다고 가정해 보자. 이 애플리케이션을 빨리 완성할수록 사업에서 더 빨리 돈을 벌 수 있다. 이 단순한 사실을 생각할 때 이제 객체들에 equals 규약을 제대로 구현하고 테스트하는 데 몇 시간(또는 며칠?)을 쓸 것인가? 아니면 다른 사람의 코드를 재사용하는 게 타당할까?
equals 구현하기
equals 메서드를 구현할 때는 Commons Lang EqualsBuilder가 유용하다. 이 클래스는 파악하기 쉽다. 본질적으로 알아야 하는 것은 이 클래스에 정의된 append와 isEquals 메서드 둘뿐이다. 이 append 메서드는 프로퍼티 두 개를 받는데, 하나는 equals가 정의된 객체의 프로퍼티고 다른 하나는 비교할 객체 상의 동일한 프로퍼티다. 이 append 메서드는 EqualsBuilder 객체를 돌려주기 때문에 연속된 호출을 사슬 형태로 엮어 객체의 모든 필요한 프로퍼티를 비교할 수 있다. 그리고 이 호출 사슬은 isEquals 메서드를 호출해서 마무리할 수 있다.
예를 들어 Listing 1에서 보는 것처럼 Account 객체를 만들어 보자.
Listing 1. 간단한 Account 객체
import org.apache.commons.lang.builder.CompareToBuilder;
import org.apache.commons.lang.builder.EqualsBuilder;
import org.apache.commons.lang.builder.HashCodeBuilder;
import org.apache.commons.lang.builder.ToStringBuilder;
import java.util.Date;
public class Account implements Comparable {
private long id;
private String firstName;
private String lastName;
private String emailAddress;
private Date creationDate;
public Account(long id, String firstName, String lastName,
String emailAddress, Date creationDate) {
this.id = id;
this.firstName = firstName;
this.lastName = lastName;
this.emailAddress = emailAddress;
this.creationDate = creationDate;
}
public long getId() {
return id;
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
public String getEmailAddress() {
return emailAddress;
}
public Date getCreationDate() {
return creationDate;
}
}
|
이 Account 객체는 예제이기 때문에 아주 단순하고 다른 클래스와 연계 없이 독립적이다. 여기서 기본 equals 구현을 그대로 사용할 수 있는지 보기 위해 Listing 2에 보인 것처럼 간단한 테스트를 돌려볼 수 있다.
Listing 2. Account 객체의 기본 equals 메서드 테스트
import org.junit.Test;
import org.junit.Assert;
import com.acme.app.Person;
import java.util.Date;
public class AccountTest {
@Test
public void verifyEquals(){
Date now = new Date();
Account acct1 = new Account(1, "Andrew", "Glover", "ajg@me.com", now);
Account acct2 = new Account(1, "Andrew", "Glover", "ajg@me.com", now);
Assert.assertTrue(acct1.equals(acct2));
}
}
|
Listing 2에서 보는 것처럼 각자 별도의 참조 값(reference)을 가지는 (즉, 두 객체를 ==로 비교하면 false다) 똑 같은 Account 객체를 두 개 생성했다. 이 두 객체의 값이 같은지 비교하면 JUnit이 친절하게 ‘의도했던 것과 달리 false가 나왔다’고 알려준다.
앞서 equals 메서드가 자바 언어의 컬렉션 클래스를 포함한 자바 플랫폼의 다양한 측면에서 활용된다고 했음을 기억하자. 따라서 이 메서드가 제대로 동작하도록 구현하는 게 당연하다. 그래서 equals 메서드를 재정의해 보겠다.
equals 규약은 null 객체에는 해당하지 않음을 기억하자. 또 객체 두 개가 다른 타입(예를 들어 Account와 Person 객체)이면 같을 수 없다. 마지막으로 자바 코드에서 equals 메서드는 명백히 == 연산자와 다르다(기억할지 모르겠는데 두 객체가 같은 참조 값을 가지면 true를 돌려준다. 이 경우 결과적으로 그 두 객체는 같아야 한다). 두 객체는 같지만(그래서 equals에서 true를 돌려주지만) 참조 값은 다를 수도 있다.
따라서 equals 메서드의 첫 번째 측면은 Listing 3처럼 작성할 수 있다.
Listing 3. equals 메서드 내에 포함될 간단한 조건문들
if (this == obj) {
return true;
}
if (obj == null || this.getClass() != obj.getClass()) {
return false;
}
|
Listing 3에는 조건문이 두 개 있는데 이는 equals가 정의된 객체와 인자로 넘어온 obj 객체의 프로퍼티들을 비교하기 전에 확인해야 하는 것들이다.
다음으로 equals 메서드는 Object 타입을 인자로 받으므로 Listing 4처럼 obj를 Account로 타입 변환해야 한다.
Listing 4. obj 인자를 타입 변환하기
Account account = (Account) obj;
|
이 equals 내 논리가 지금까지 제대로 됐다고 가정하고 이제 EqualsBuilder 객체를 이용해 보자. 이 객체는 equals가 정의된 객체(this)와 equals로 넘어온 타입의 비슷한 프로퍼티들을 append 메서드로 비교하도록 설계됐다는 점을 상기하자. 이 메서드는 사슬 형태로 연결될 수 있으므로 마지막에 true나 false를 돌려주는 isEquals 메서드를 호출할 수 있다. 결과적으로 Listing 5처럼 한 줄짜리 코드를 작성할 수 있다.
Listing 5. EqualsBuilder 재사용하기
return new EqualsBuilder().append(this.id, account.id)
.append(this.firstName, account.firstName)
.append(this.lastName, account.lastName)
.append(this.emailAddress, account.emailAddress)
.append(this.creationDate, account.creationDate)
.isEquals();
|
지금까지 것을 모두 모으면 Listing 6의 equals 메서드와 같다.
Listing 6. 지금까지 작성한 equals 전체 코드
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || this.getClass() != obj.getClass()) {
return false;
}
Account account = (Account) obj;
return new EqualsBuilder().append(this.id, account.id)
.append(this.firstName, account.firstName)
.append(this.lastName, account.lastName)
.append(this.emailAddress, account.emailAddress)
.append(this.creationDate, account.creationDate)
.isEquals();
}
|
이제 아까 실패했던 테스트를 다시 돌려 보자(Listing 2를 본다). 이번에는 테스트가 성공할 것이다.
여기까지 스스로 equals 메서드를 구현해 보는 데 시간을 소모하지 않았다. 하지만 제대로 된 equals 메서드를 어떻게 작성할지 여전히 궁금하다면, 많은 조건문이 필요하다고 말하는 것만으로도 충분하다. 예를 들어 EqualsBuilder를 사용하지 않고 작성된 equals 메서드에서는 creationDate 프로퍼티를 Listing 7처럼 비교할 수 있다.
Listing 7. 직접 작성한 equals 메서드의 일부
if (creationDate != null ? !creationDate.equals(
person.creationDate) : person.creationDate != null){
return false;
}
|
이 경우 삼항 연산자(ternary)를 사용해 코드가 약간 더 정확해졌다. 하지만 이론의 여지가 있기는 해도 대신 이해하기가 어려워졌다. 그럼에도 불구하고 여기서 중요한 것은 각 객체 프로퍼티의 다양한 측면을 비교하는 일련의 조건문을 작성하거나 (정확히 같은 일을 하도록) EqualsBuilder를 활용하는 두 가지 길이 있다는 것이다. 여러분이라면 어느 쪽을 택하겠는가?
equals 메서드를 잘 정리하고 가능한 코딩을 적게 하길 (이는 유지 보수할 코드가 더 적다는 의미다) 정말 원한다면 리플렉션(reflection)의 위력을 활용해 Listing 8과 같은 코드를 작성할 수 있다.
Listing 8. EqualsBuilder의 리플렉션 API 사용하기
public boolean equals(Object obj) {
return EqualsBuilder.reflectionEquals(this, obj);
}
|
코드를 줄이는 데 어때 보이는가?
Listing 8은 단점이 있다. EqualsBuilder는 (private 필드를 비교하기 위해) 비교할 객체에 대한 접근 제어를 몰래 무력화해야 한다. 만약 VM이 보안을 염두에 두고 구성된 경우 그런 시도가 실패할지도 모른다. 그리고 Listing 8처럼 리플렉션을 과하게 사용하면 equals 메서드의 실행 성능에 영향을 줄 수 있다. 하지만 새로운 프로퍼티가 추가됐을 때 equals 메서드를 갱신할 필요가 없다는 장점은 있다(리플렉션을 안 쓰는 경우는 갱신을 해 줘야 한다).
EqualsBuilder를 사용하면 재사용의 위력을 느낄 수 있다. 이 클래스는 equals 메서드를 구현하는 데 두 가지 선택을 제시한다. 어느 쪽을 선택할지는 자기 몫이고 여러분이 처한 특정 상황에 영향을 받는다. 한 줄 스타일은 단순하고 매혹적이다. 하지만 이제 알겠지만 안전하기까지 하다.
|