스프링 프로젝트에서 ORM 기술로 JPA를 활용하던 도중 equals, hashCode 메서드를 재정의할 경우가 있었는데 이에 대한 고민 과정을 적어보고자 한다.
일단 왜 재정의해야 하냐라는 의문이 들 수 있겠다. 기본적으로 Object 클래스의 equals 메서드는 같은 객체 즉 인스턴스일 경우에만 동일하다고 판단한다. 공식 문서에서는 이를 다음과 같은 4가지 특성으로 표현하고 있다.
x.equals(x)
는 항상 참이어야 한다.x.equals(y)
가 참이라면 y.equals(x)
역시 참이어야 한다.x.equals(y)
가 참이고 y.equals(z)
가 참일 때 x.equals(z)
역시 참이어야 한다.x.equals(y)
가 참일 때 equals 메서드에 사용된 값이 변하지 않는 이상 몇 번을 호출해도 같은 결과가 나와야 한다.x.equals(null)
은 항상 거짓이어야 한다.일반적인 자바 객체에서는 위의 조건을 만족할 때 같은 객체라도 판단하는 것이 맞다. 하지만 JPA의 엔티티를 다룰 때는 그렇지 않다.
먼저 다음과 같은 간단한 엔티티가 있다고 하자.
@Entity
public class Item {
@Id @GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "item_id")
private Long id;
@Column(name = "name")
private String name;
@Column(name = "price")
private int price;
@Column(name = "stock_quantity")
private int stockQuantity;
...
}
이 객체는 별다른 equals, hashCode 메서드를 재정의하지 않았다. 그리고 다음과 같은 테스트 코드를 작성해서 두 Item 객체를 비교해 보았다.
@Test
void equalsTest() {
EntityManager entityManager = entityManagerFactory.createEntityManager();
EntityTransaction transaction = entityManager.getTransaction();
transaction.begin();
Item cake = Item.builder()
.name("Cake")
.price(2500)
.stockQuantity(10).build();
entityManager.persist(cake);
transaction.commit();
entityManager.clear();
Item findCake = entityManager.find(Item.class, cake.getId());
assertEquals(cake, findCake);
}
그 결과는 당연히 아래처럼 실패하는 것을 알 수 있다.
일반적으로는 한 엔티티 매니저의 영속성 컨텍스트에서 1차 캐시를 이용해 같은 ID의 엔티티를 항상 같은 객체로 가져올 수 있다. 하지만 위처럼 1차 캐시를 초기화한 후 다시 데이터베이스에서 동일한 엔티티를 읽어오는 경우 초기화 전에 얻었던 cake(2a984952)와 이후에 얻은 findCake(5366575d) 객체가 서로 다른 객체로 생성된 것을 볼 수 있다.
이는 위에서 언급한 equals 메서드의 consistent 원칙을 위반하게 된다. 엔티티는 그 본질이 자바 객체라기보단 데이터베이스 테이블의 레코드에 가깝기 때문에 이 Item 엔티티 객체의 필드(id, name, price, stockQuantity)가 동일하다면 같은 레코드, 즉 객체라고 판단해야 하는 것이다. 이 경우 Object의 equals 메서드로는 해결할 수 없기 때문에 equals 메서드 그리고 관례에 따라 hashCode 메서드를 재정의해야 한다.
가장 먼저 생각나는 방법은 PK, 즉 기본키를 활용하는 방법이다. 모든 데이터베이스 레코드, 즉 엔티티는 각자 고유한 기본키를 가진다. 이는 데이터베이스에 의해 유일성이 보장되기 때문에 위의 equals 원칙을 역시 만족한다.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o))
return false;
Item item = (Item) o;
return Objects.equals(id, item.id);
}
@Override
public int hashCode() {
return id.intValue();
}
위의 equals 메서드는 IntelliJ 플러그인 JPA Buddy에 의해 자동 생성된 코드다. hashCode 메서드는 자동 생성해주지 않고 0으로 반환하기 때문에 직접 id 값을 반환하도록 변경해주었다. 위처럼 작성하고 다시 테스트를 돌려보면 이번에는 성공하는 것을 볼 수 있다.
그러면 된 것일까? 하지만 이는 NPE가 발생할 수 있는 부분을 내재하고 있다. 즉 엔티티가 아직 영속성 컨텍스트에 의해 관리되지 않는 transient 한 상태일 경우다. 지금은 JPA Buddy에 의해 안전하게 구현되었지만 만약 기본키를 이용하여 비교하는 로직을 직접 작성했을 때 다음처럼 작성할 수 있다.
@Override
public boolean equals(Object o) {
return this.id.equals(((Item)o).id);
}
...
Item cake2 = Item.builder()
.name("Cake2")
.price(3500)
.stockQuantity(5).build();
...
assertEquals(cake2, cake);
그리고 영속성 컨텍스트에 등록되지 않은 엔티티 객체와 비교하면 어떤 일이 일어날까? 아래 사진처럼 NullPointerException이 발생한 것을 볼 수 있다.
이는 cake2를 첫 번째 인자로 넘겼기 때문에 cake2의 id를 cake의 id와 비교하는 과정에서 존재하지 않는 id, 즉 null 값의 Long 래퍼 클래스의 equals 메서드를 호출하여 비교했기 때문에 발생한 것이다.
이 뿐만이 아니라 transient 상태의 두 객체를 비교한다면 둘 다 id 필드가 null이 되기 때문에 같다고 판단된다는 추가적인 문제가 있다. 다시 equals 메서드를 JPA Buddy가 생성한 대로 복구하고 이번에는 transient 한 다른 객체를 작성해서 비교해보았다.
...
Item bread = Item.builder()
.name("Bread")
.price(1000)
.stockQuantity(25).build();
...
assertNotEquals(bread, cake2);
...
이전에 생성한 cake2 객체는 "Cake2"라는 이름으로 3500원이라는 가격에 5개가 있다는 것을 나타내고 있다. 그리고 새로 생성한 bread 객체는 "Bread"라는 이름으로 1000원이라는 가격에 25개가 있다는 것을 나타내고 있다. 누가 봐도 다른 객체기 때문에 assertNotEquals
로 비교하면 통과해야 할 것 같지만 실제로 돌려보면 실패하는 것을 볼 수 있다.
반대로 assertEquals(bread, cake2)
를 실행해보면 다음처럼 통과하는 것을 볼 수 있다.
왜 그런 것일까? 이는 JPA Buddy가 구현해준 equals 메서드가 활용하는 Objects.equals
메서드의 정의를 살펴볼 필요가 있다.
Returns true if the arguments are equal to each other and false otherwise. Consequently, if both arguments are null, true is returned and if exactly one argument is null, false is returned. Otherwise, equality is determined by using the equals method of the first argument.
비교할 인자가 둘 다 null일 경우 같다고 판단한다는 것을 볼 수 있다. 위의 로직에서는 transient 상태의 객체를 equals 메서드로 비교하게 되는데 id 필드, 즉 기본키는 아직 데이터베이스에서 생성된 값을 받아오지 않아 null 상태다. 즉 두 id 필드를 비교하는 로직은 두 null을 비교하는 형태가 되어 같다고 판단되기 때문에 assertNotEquals
가 통과하지 못했던 것이다.
이 외에도 객체를 컬렉션에 저장할 때 기본키를 활용하게 되는 경우(hashCode를 활용하는 HashMap 등) 역시 추가적인 문제가 발생할 수 있을 것이다. 그렇기 때문에 nullable한 기본키 필드를 활용할 때는 hashCode 메서드를 다음처럼 Objects.hash
메서드를 활용하여 정의하는 것이 좋다.
@Override
public int hashCode() {
return Objects.hash(id, memo, orderDatetime, orderStatus, member, orderItems);
}
만약 준영속 상태의 엔티티를 이용하여 여러가지 비즈니스 로직을 수행한다면 다룰 때 주의하고 equals 메서드를 엔티티 클래스의 모든 필드에 Objects.equals
메서드를 적용하여 비교하는 방식으로 꼼꼼하게 구현할 수도 있을 것이다.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
Item item = (Item) o;
return Objects.equals(id, item.id) &&
Objects.equals(name, item.name) &&
Objects.equals(price, item.price) &&
Objects.equals(stockQuantity, item.stockQuantity);
}
위의 메서드로 아래와 같은 테스트 코드를 돌려보면 문제없이 성공하는 것을 볼 수 있다.
assertEquals(cake, findCake);
assertNotEquals(bread, cake2);
assertNotEquals(cake, cake2);
assertNotEquals(cake, bread);
개인적으로 마지막 방법은 약간 오버킬이라고 생각되지만 나중에 실무를 겪게 된다면 생각이 바뀔지도 모르겠다.
비즈니스 키는 이번에 학습하면서 알게 된 개념이다. 이전에 기본 키가 데이터베이스에서 생성하여 제공해주는 키라면 비즈니스 키는 애플리케이션 비즈니스 로직 전반에 걸쳐서 활용할 수 있는 사용자를 식별하는 고유한 키라고 할 수 있다. 대표적으로 회원의 아이디나 이메일을 예로 들 수 있다.
그리고 엔티티 객체를 비교할 때 equals, hashCode 메서드에서 이 비즈니스 키를 비교하는 방식으로 활용하는 것이다. 이 경우 데이터베이스의 테이블에 별도의 컬럼이 추가되고 모든 레코드가 고유한 값을 갖도록 보장해야 한다는 특징이 있다.
애플리케이션의 크기가 커지면서 단순히 데이터베이스의 기본키 만으로 비교하기 까다로워질 경우(잘 모르지만 분산 시스템이라던지)를 생각하면 회원이라면 이메일이나 아이디, 상품이라면 바코드 번호같은 고유 식별값을 미리 구상해두는 것도 좋은 방법일 것이다.
개인적으로는 데이터베이스의 기본 키 만으로도 엔티티(영속 상태라는 가정 하에)를 비교하는 데는 충분하다고 생각한다. 비즈니스 로직 중에 영속 상태의 엔티티와 준영속 상태의 엔티티를 같이 다뤄야 한다면 예외 처리를 해주는 것도 괜찮은 방법이 아닐까 싶다.
아직 그런 적은 없었지만 서비스 메서드에서 엔티티들을 다룰 때 기본 키만 hashCode
로 활용하는 방식에서는 서로 다른 테이블의 엔티티가 같은 해시값을 가져서 HashMap 등에서 덮어씌워진다거나 할 수도 있을 것이다. 그렇지만 그럴 경우는 거의 없고 일단 hashCode
로 얻은 해시값이 같은 객체가 꼭 동일한 객체인 것은 아니다라는 원칙을 잊지 말고 equals
메서드를 구현하도록 하자.
JPA Entity Equality
Ultimate Guide to Implementing equals() and hashCode() with Hibernate
진짜 Entity 안에 equals 메서드를 왜사용하는가에 대한 근본적인 원인을 찾고 있었는데 이글을 읽고 전부다는 아니지만 어느정도 사용처에 대해서 확실히 이해할 수 있었습니다! 감사합니다 ㅠㅠ