equals와 hashCode를 재정의하는 이유
equals를 재정의하지 않으면 기본적으로 Object 클래스의 equals를 사용하게 되는데, Object의 equals는 같은 인스턴스인 경우에만 동일하다고 판단한다. 하지만 DB에서 PK를 unique함을 보장하기 때문에, 영속 상태에 따라 조금의 차이가 있을 수는 있지만 Entity에서는 동일한 PK를 가지는 두 엔티티 인스턴스를 비교했을 때 같다고 보는게 맞다는 생각을 했다.
equals를 재정의하지 않고 기본 Object의 equals를 사용하면 위와 같이 서로 같은 Id를 가지고 있는 두 인스턴스를 비교했을 때, 아래처럼 논리적으로 동일하다고 판단하지 못하게 된다.
이런 문제점 때문에 equals를 재정의하기로 결정하였고, equals를 재정의함에 따라 논리적으로 같은 인스턴스가 같은 해시코드를 반환하여 HashMap과 같은 컬렉션을 사용할 때 오동작을 방지하기 위하여 hashCode도 재정의하기로 결정하였다.(Effective Java 아이템 11, 12 참조)
equals 재정의
equals를 재정의할 때 핵심 필드들을 비교하여 논리적 동치성을 확인해야 한다. 그럼 가장 먼저 생각해볼 수 있는 것은 DB에서 기본 키(PK)로 적용되어 있는 id만 비교하여 두 인스턴스를 비교하는 것이다.
위와 같이 id를 비교하도록 equals를 재정의하고 전의 테스트를 다시 돌려보면, 테스트를 통과하게 된다.
그렇다면 이걸로 끝난 걸까? 새로 생성된 객체의 경우 영속성 컨텍스트에 등록하기 전까지 Id는 null을 가진다.
id.equals()로 비교하게되면 Long 타입의 equals를 호출하여 비교하는데, 비영속 엔티티를 비교하여 id값에 null이 들어가는 경우 위와 같이 NullPointerException이 발생한다.
이런 문제점을 해결하기 위해 null에 대한 처리를 해주던가, 아래와 같이 Objects.equals(o1, o2)를 사용하여 비교하면 된다.
Objects의 equals 메서드는 기본적으로 인자 중 하나에 null을 받아 NPE가 발생하면 false를 반환한다.
이와 같이 equals를 재정의하면 오직 영속화된 엔티티에 대해서만 비교하고, 영속화 되어 있지 않다면 동등성 비교를 하지 못한다.
영속화 되지 않은 엔티티를 비교하는 것이 의미가 없다고 보는 관점이라면, 비영속이나 준영속 인스턴스와 비교하지 않는 것은 타당하다. 이처럼 PK만 비교하여 엔티티 equals를 구현하는 것으로 충분할 수 있다.
비영속 엔티티의 equals와 비즈니스 키
사실 위에서 적용한 방법에도 문제가 있는데, 아래처럼 영속화 되지 않은 두 엔티티를 비교했을 때 서로 같다고 나온다는 것이다.
Objects의 equals는 비교하는 두 매개변수가 전부 null로 들어오면 같다고 판단하기 때문이다.
또한 비영속 객체를 Set의 원소로 사용하거나 Map의 키로 사용하게 되는 경우, equals가 항상 false를 뱉기 때문에 오동작을 초래할 수 있다.
이런 단점들을 해결하기 위해서는 equals에 PK를 제외하고 엔티티의 다른 필드들을 비교하는 로직을 추가해주어야 한다.
이와 같이 null을 확인하여 비영속 인스턴스인 경우 나머지 모든 필드를 비교하도록 작성한다면, 영속화되지 않은 두 객체를 비교할 수 있게 된다.
이런 방식은 검사 로직에 포함된 필드들 중 하나라도 값이 변경되면 다른 영속성 컨텍스트와 더 이상 일치하지 않게 된다는 것과, 검사 로직에 포함된 필드들의 조합이 유일성(unique)를 보장하지 않는다면 전혀 다른 인스턴스를 동일하다고 판단할 수 있다.
또한 위의 예시는 간단하게 작성하느라 클래스의 멤버 인수가 2개 밖에 없지만, 멤버 인수가 훨씬 많아진다면 수많은 필드들을 모두 검사하는 로직으로 인해 불필요한 성능 저하가 생길 수 있다.
그래서 실무에서는 비즈니스 키라는 개념을 많이 사용한다. 비즈니스 키란 데이터베이스의 키가 아닌 어플리케이션 전반의 비즈니스 로직에서 사용자를 식별할 수 있는 고유한 키를 의미한다. 회원의 아이디나 이메일 주소와 같이 DB의 제약조건(unique)으로 중복을 허용하지 않는 필드는 전부 비즈니스 키가 될 수 있고, 변경이 거의 발생하지 않는 필드일수록 비즈니스 키로 사용하기에 더 좋다.
참고한 블로그에서는 아래와 같은 사용 기준을 제시했었다. 제시한 기준들을 읽어보니 충분히 합리적이고 프로젝트에 적용해도 괜찮겠다 생각하여 가져와봤다.
1.
정의한 Entity를 Map의 키나 Set과 같은 컬렉션에 담아 관리할 가능성이 있는가? 앞으로의 확장과 리팩토링에서도 계속 없음을 보장할 수 있는가?
→ PK를 사용해 equals와 hashCode를 재정의하자.
2.
영속과 비영속 상태의 Entity를 비교할 가능성이 있는가? 비교할 가능성이 있다면 PK를 제외한 나머지 필드 조합으로 식별성을 갖출 수 있는가?
→ 식별성을 갖출 수 있는 필드 조합으로 equals와 hashCode를 재정의하자.
3.
PK를 제외하고 DB에서 unique함을 보장하거나 식별성을 확보할 수 있는 필드가 있는가?
→ 식별성을 가지는 필드를 비즈니스 키로 사용하여 equals와 hashCode를 재정의하자.
equals와 hashCode 재정의하기
아래와 같은 컬럼들을 가지는 Member 엔티티에 equals와 hashCode를 재정의했다.
Member는 컬렉션에 담길 가능성이 있고 필드가 너무 많기 때문에, 고유성을 보장하는 필드를 비즈니스 키로 사용해 equals와 hashCode를 작성하기로 하였다. oauthId와 oauthName의 경우에는 42 서울의 Oauth2 인증 서버에서 고유성을 보장하기 때문에, oauthType까지 포함하여 OauthProfile이라는 Embeddable 클래스로 묶고 equals와 hashCode를 따로 구현했다.
Member에서는 아래와 같이 재정의하였다.