Hibernate Equals와 HashCode
잡동사니
자바의 컬렉션이나 관계형 데이터베이스(요컨데 하이버네이트)는 객체를 구분하는 통일된 방법이 매우 중요합니다. 이는 관계형 데이터베이스에서는 기본키로 이뤄지고, 자바에는 각 객체의 equals()와 hashCode() 메서드가 있습니다. 이 문서는 영속 클래스의 equals()와 hashCode() 메서드를 구현하는 최고의 전략에 대해 논의합니다.
목차 |
왜 equals()와 hashCode()가 중요할까
일반적으로 자바 객체는 객체의 동일성에 기반하여 equals()와 hashCode()를 내장하여 제공합니다. 그리하여 새로 만들어진 객체를 다른 모든 객체와 구분합니다.
이는 전형적인 자바 프로그래밍에서 보통 필요한 것이며, 객체가 모두 메모리 안에 있을 경우 괜찮은 모형입니다. 당연한 얘기로 하이버네이트의 모든 작업은 객체를 메모리 밖으로 옮기는 것이지만, 하이버네이트는 이에 대해 걱정할 필요가 없게 동작합니다.
하이버네이트는 유일성을 관리하기 위해 하이버네이트 세션을 사용합니다. new 키워드로 객체를 만들고 세션에 저장하면, 이 때부터 하이버네이트는 여러분이 어떤 객체를 질의하는지 알아차리고, 그 특정 객체를 찾아냅니다. 하이버네이트는 반드시 객체의 바로 그 인스턴스를 반환합니다. 하이버네이트는 바로 그렇게 합니다.
그러나 당신이 하이버네이트 세션을 종료하면 모든 게 백지가 됩니다. 직접 생성한 객체건, 아니면 이제 종료해버린 하이버네이트 세션에서 불러온 객체건 간에 여러분이 가지고 있는 객체(의 변화)에 대해 하이버네이트에서 알 도리가 없습니다. 따라서 다른 세션은 열고 "동일한" 객체를 질의하면 하이버네이트는 새 인스턴스를 반환할 것입니다. 그리하여 여러 세션 사이에서 객체 컬렉션을 사용한다면 컬렉션 내에 객체가 중복되는 것을 비롯한 뜻밖의 결과를 경험하게 될 것입니다.
일반적인 규약: 리스트나 맵, 혹은 셋에 객체를 저장하고 싶다면 equals()와 hashCode() 메서드의 구현이 필수이며 이는 문서에 명시된 표준 규칙을 따르게 됩니다.
결국 뭐가 문제일까
세션과 세션사이에서, 예를 들면 특정한 애플리케이션 이용자와 관련된 셋이나, 여러 하이버네이트 세션에 걸쳐진 다른 범위에서 객체를 유지하려고 하는 경우를 살펴봅시다.
머릿 속에 가장 자연스럽게 떠오르는 equals()와 hasCode()의 구현은 데이터베이스의 식별자(즉, 기본키 속성)로 매핑한 항목을 비교하는 것입니다. 그러나 이건 새로 생성된 객체에서 문제가 발생합니다. 왜냐하면 하이버네이트는 식별자 값을 데이터베이스에 저장한 다음에야 설정하기 때문입니다. 따라서 모든 새 인스턴스는 null (혹은 0) 값을 같은 식별자로 가지게 됩니다. 새 객체를 셋에 추가하는 예를 보겠습니다.
// UserManager와 User는 하이버네이트에 매핑된 자바 빈이라고 가정
UserManager u = session.load(UserManager.class, id);
u.getUserSet().add(new User("newUsername1")); // id = null 혹은 id = 0인 새 요소을 추가
u.getUserSet().add(new User("newUsername2")); // id = null인 요소를 가지고 있기 때문에 마지막에 추가된 객체를 덮어씀
// 그리하여 u.getUserSet()은 두 번째 User만 가지고 있음
보시는 바와 같이 하이버네이트가 생성한 식별자를 이용하면 영속 클래스의 비교를 위해 데이터베이스 식별자 비교를 이용하기 때문에 여러분을 곤경에 빠뜨립니다. 왜냐하면 식별자 값은 그 객체가 저장되기 전까지는 설정되지 않기 때문입니다. 이 식별자 값은 비영속 객체를 영속 상태로 만드는 session.save()를 호출할 때 설정됩니다.
만일 여러분이 직접 식별자를 할당(예를 들어 "할당된" 생성자)한다면 이런 곤경을 피할 수 있습니다. 다만, 그 객체를 셋에 추가하기 전에 식별자 값를 설정하려면, 그게 올바른지 확신할 수 있어야만 합니다. 다른 측면에서 보면 대부분의 애플리케이션에서 이를 보증하기가 매우 어렵습니다.
객체 식별자와 비즈니스 키를 분리하기
> 이 문제를 피하기 위해서 equals() (그리고 hashCode())를 구현할 때 영속 클래스의 "부분적으로" 유일한 속성을 사용하기를 추천합니다. 기본적으로 데이터베이스의 식별자가 비즈니스적 의미를 전혀 가지지 않는 것에 대해 생각해 봐야 합니다. (대표 식별자 항목과 자동적으로 생성된 값을 이와 별개로 추천합니다) 데이터베이스 식별자 항목은 단지 객체의 식별자여야만 하며, 이는 기본적으로 하이버네이트에서만 쓰여야 합니다. 물론 데이터베이스 식별자를 웹애플리케이션의 링크를 만들기 위한 것처럼 읽기 전용으로 편리하게 사용할 수 있습니다.
동등성 비교를 위해 데이터베이스 식별자를 사용하는 것보다 객체 각각을 식별할 수 있는 항목의 집합을 equals()에 사용하십시오. 예를 들어 "Item" 클래스가 있고 "name" 문자열과 "created" 날짜가 있다면, 좋은 equals() 메서드를 구현할 때 이 둘을 사용할 수 있습니다. 이는 영속 식별자를 필요로 하지 않기 때문에 "비즈니스 키"라고 불리는 게 낫습니다. 이는 자연스러운 키이며 이 때 이 키를 사용하는 데 아무런 문제도 없습니다!
> 두 필드의 조합은 Item을 Set에 저장하는 동안 충분히 안정적입니다. 이는 기본키만큼 좋지는 않지만 대안 키가 되기에는 충분합니다. 이는 여러분의 객체의 "관계형 식별성"을 정의하는 것으로 생각할 수 있습니다. 이 키 필드들은 여러분의 관계형 모델에서 유일한 필드처럼 되거나, 아니면 최소한 영속 클래스의 불변 항목이 됩니다. ("created" Date는 변경되지 않습니다)
위의 예제에서 "name" 항목이 사용될 것입니다.
위 내용이 대부분의 경우에서 equals()/hashCode()에 대해 알아야 할 모든 것임에 주의하시기 바랍니다. 다음 내용은 제대로 작동하지 않는 방안이나 큰 도움이 되지 않는 제안이 포함되어 있습니다. 아래 내용은 여러분이 그 위험성을 잘 숙지하고 사용하시기 바랍니다.
save/flush를 강제하여 회피하기
만일 여러분이 equals()/hashCode()를 위해 영속 식별자를 사용할 수 없는데, 세션과 세션 사이에서 객체를 유지해야만 한다면 (따라서 기본 equals()/hashCode()를 바로 사용할 수 없다면), 객체를 생성하고 셋에 추가하기 전에 save()/flush()를 강제하여 문제를 해결할 수 있습니다.
// Suppose UserManager and User are Beans mapped with Hibernate
UserManager u = session.load(UserManager.class, id);
User newUser = new User("newUsername1");
// u.getUserSet().add(newUser); // DO NOT ADD TO SET YET!
session.save(newUser);
session.flush(); // The id is now assigned to the new User object
u.getUserSet().add(newUser); // Now OK to add to set.
newUser = new User("newUsername2");
session.save(newUser);
session.flush();
u.getUserSet().add(newUser); // Now userSet contains both users.
> 이는 매우 비효율적이고 따라서 추천하는 바는 아니라는 점에 주의하십시오. 또한 이는 얇은 클라이언트에서 접속이 끊긴 객체 그래프를 사용할 때 깨지기 쉬움을 명심하십시오.
// on client, let's assume the UserManager is empty:
UserManager u = userManagerSessionBean.load(UserManager.class, id);
User newUser = new User("newUsername1");
u.getUserSet().add(newUser); // have to add it to set now since client cannot save it
userManagerSessionBean.updateUserManager(u);
// on server:
UserManagerSessionBean updateUserManager (UserManager u) {
// get the first user (this example assumes there's only one)
User newUser = (User)u.getUserSet().iterator().next();
session.saveOrUpdate(u);
if (!u.getUserSet().contains(newUser)) System.err.println("User set corrupted.");
}
이는 실제로는 newUser의 hashCode가 saveOrUpdate로 인해 변경되기 때문에 "User set corrupted."를 출력합니다.
이늠 자바 객체의 식별자가 하이버네이트가 할당한 데이터베이스 식별자와 매핑될 것처럼 보이기 때문에 실망스러운 결과입니다만, 실제로는 둘이 다릅니다. 게다가 데이터베이스 식별자는 객체가 저장될 때까지 존재하지도 않습니다. 갹체의 식별자는 저장 여부에 의존해서는 안되며, 만일 equals()와 hashCode()가 하이버네이트 식별자를 사용한다면 객체 식별자는 저장할 때 변경될 것입니다.
이 메서드를 작성하는 것은 귀찮은데 하이버네이트가 도와줄 수는 없을까
좋은 질문입니다만, 하이버네이트가 제공할 수 있는 도움은 hbm2java 뿐입니다.
hbm2java는 (더이상) 이 페이지에서 설명한 이슈 때문에 식별자에 근거한 equals/hashCode를 생성하지 않습니다.
그렇지만 필요한 항목을 <meta attribute="use-in-equals">true</meta>로 표시하여 hbm2java에게 적절한 equals/hashCode룰 생성하게 할 수 있습니다.
요약
다음은 위 내용을 모두 정리한, equals/hashCode가 어떤 경우에는 동작하고 어떤 경우에는 동작하지 않는지에 대한 목록입니다.
| eq/hC 구현 안함 | 식별자로 eq/hC 구현 | 비즈니스키로 eq/hC 구현 | |
|---|---|---|---|
| 복합식별자로 사용 | No | Yes | Yes |
| 셋에 저장된 여러 새 인스턴스 | Yes | No | Yes |
| 다른 세션의 같은 객체와 동등 | No | Yes | Yes |
| 저장 이후에도 컬렉션이 유지 | Yes | No | Yes |
각 문제가 발생하는 경우는 다음과 같습니다.
복합식별자로 사용
객체를 복합 식별자로 사용하기 위해선 equals/hashCode를 구현해야햐 합니다. 이 경우에 식별자의 == 비교는 충분하지 않습니다.
셋에 저장된 여러 새 인스턴스
다음은 동작하거나, 하지 않는 예입니다:
HashSet someSet = new HashSet(); someSet.add(new PersistentClass()); someSet.add(new PersistentClass()); assert(someSet.size() == 2);
다른 세션의 같은 객체와 동등
다음은 동작하거나, 하지 않는 예입니다:
PersistentClass p1 = sessionOne.load(PersistentClass.class, new Integer(1)); PersistentClass p2 = sessionTwo.load(PersistentClass.class, new Integer(1)); assert(p1.equals(p2));
저장 이후에도 컬렉션이 유지
다음은 동작하거나, 하지 않는 예입니다:
HashSet set = new HashSet(); User u = new User(); set.add(u); session.save(u); assert(set.contains(u));
equals와 hashCode의 최고 사례
'배경 자료'에 링크된 문서와 API 문서를 읽어보십시오. 시시콜콜한 내용까지 포함되어 있습니다.
더하여 equals와 hashCode 구현에 관한 정보나 팁을 가진 분들이 나서서 "패턴"을 보여주시길 바랍니다. 저는 그것들을 hbm2java에 포함시켜 더 도움이 되도록 할 것입니다. ;)
배경 자료
Effective Java Programming Language Guide, equals() and hashCode()에 관한 샘플 챕터 (영문)
Java theory and practice: Hashing it out, IBM 기사 (영문)
Sam Pullara(BEA)의 객체 식별자에 대한 블로그의 언급, (영문, 원 URL은 접속이 불가능하며 Web Archive에서 확인 가능)
Manish Hatwalne의 어떻게 equals와 hashCode를 바르게 구현할 것인가에 관한 기사: Equals and HashCode (영문)
비즈니스 식별자를 정의하지 않고 구현할 수 있는지에 대한 포럼 글타래: Equals and hashCode: Is there *any* non-broken approach? (영문)
java.lang.Object 문서에 따르면 hashCode()가 항상 0을 반환하는 건 아무런 문제가 없습니다. 유일한 객체가 유일한 hashCode() 값을 반환하도록 구현했을 때 긍정적인 효과는 실행속도를 향상시키는 것입니다. 주의해야 할 점은 hashCod()의 작동 방식은 equals()와 동일해야 한다는 것입니다. 객체 a와 b를 놓고 봤을 때 a.equals(b)가 참이라면, a.hashCode() == b.hashCode() 역시 참이어야 합니다. 반면 a.equals(b)가 거짓이라도, a.hashCode() == b.hashCode()는 참일 수 있습니다. hashCode()를 'return 0'으로 구현하는 것은 이 조건과 맞아떨어지긴 하지만, HashSet이나 HashMap처럼 Hash 기반의 컬렉션의 효율성을 극단적으로 떨어뜨립니다.

