네로개발일기

개발자 네로의 개발 일기, 자바를 좋아합니다 !

반응형

환경: Spring JPA

문제: PK가 같은 엔티티 2개가 각각 준영속, 영속상태일 때 비교가 되지 않음

해결: equals, hashCode 메서드를 재정의

Object 클래스의 equals 특성

1. reflexive (반사성)
x.equals(x)는 항상 참이어야 한다.

2. symmetric (대칭성)
x.equals(y)가 참이라면 y.equals(x) 역시 참이어야 한다.

3. transitive (추이성)
x.equals(y)가 참이고 y.equals(z)가 참일 때, x.equals(z) 역시 참이어야 한다.

4. consistent (일관성)
x.equals(y)가 참일 때 equals 메서드에 사용된 값이 변하지 않는 이상 몇 번을 호출해도 같은 결과가 나와야 한다.

5. x가 null이 아닐 때 x.equals(null)은 항상 거짓이어야 한다.

 

JPA (Hibernate)의 Entity 특징

@Entity가 붙은 클래스의 instance A와 B가 있다고 하자.

이 둘의 instance는 동일한 데이터베이스, 테이블의 같은 row를 instance로 만들었다. 즉, 같은 데이터를 기반하여 만들어진 instance이다.

이 경우 A == B 란 코드에서 true를 반환한다.

이 동작이 가능한 이유는 Persistence Context (영속성 컨텍스트)의 기능 때문이다.

 

재정의해야 하는가?

다음과 같은 엔티티가 있다고 하자.

@Entity
public class Item {
    @Id @GeneratedValue(strategy = GeneratedType.IDENTITY)
    private Long id;

    private String name;
    private Long price;
    private Long stockQuantity;

    ...
}

이 객체는 별다른 equals, hashCode 메서드를 재정의하지 않았다.

다음과 같은 테스트 코드를 작성하여 두 Item을 비교해 보았다.

@Test
void equalsTest() {
    EntityManager entityManager = entityManagerFactory.createEntityManager();
    EntityTransaction transaction = entityManager.getTransaction();
    transaction.begin();
    
    Item item1 = Item.builder()
                    .namer("Cake")
                    .price(6000L)
                    .stockQuantity(10L).build();
    
    entityManager.persist(item1);
    transaction.commit();
    entityManager.clear();
    
    Item item2 = entityManager.find(Item.class, item1.getId()); // PK로 찾는다.
    assertEquals(item1, item2); // org.opentest4j.AssertionFailedError 발생!!!
}

결과는 위와 같이 실패한다.

 

일반적으로 한 엔티티 매니저의 영속성 컨텍스트에서 1차 캐시를 이용해 같은 ID의 엔티티를 항상 같은 객체로 가지고 올 수 있다. 하지만 위처럼 1차 캐시를 초기화한 후, 다시 데이터베이스에서 동일한 엔티티를 읽어오는 경우 초기화 전에 얻었던 item1과 item2 객체가 서로 다르다.

 

이는 위에서 언급한 equals 메서드의 consistent 원칙을 위반하게 된다. 엔티티는 그 본질이 자바 객체라기보단 데이터베이스 테이블의 레코드에 가깝기 때문에 이 Item 엔티티 객체의 필드(id, name, price, stockQuantity)가 동일하다면 같은 레코드, 즉 객체라고 판단해야 하는 것이다. 이 경우 Object의 equals 메서드로는 해결할 수 없기 때문에 equals 메서드 그리고 관례에 따라 hashCode 메서드를 재정의해야 한다.

 

어떻게 재정의해야 하는가?

1. 기본키로 구현하기

2. PK를 제외하고 구현하기

3. 비즈니스 키를 사용하여 구현하기

 

1. 기본키로 구현하기

모든 데이터베이스 레코드, 즉 엔티티는 각자 고유한 기본키를 가진다. 이는 데이터베이스에 의해 유일성이 보장되기 때문에 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 id != null && Objects.equals(id, item.id);
}

@Override
public int hashCode() {
    int result = id != null ? id.intValue() : 0;
    return result;
}

이 구현은 다음과 같은 특징이 있다.

1. id == null 인 경우 (비영속) 아예 동일하지 않다고 명시한다.

즉, repository.save()를 호출하지 않은 instance는 동등성을 아예 사용할 수 없다.

2. 객체가 영속화되기 전까지 Hibernate는 PK를 할당하지 않는다.

준영속 상태의 두 엔티티를 비교하게 되면, 준영속 상태이기 때문에 id 값이 모두 null이다. 두 엔티티가 다른 객체더라도 id 값이 null로 같아 같은 객체로 인식한다.

 

2. PK를 제외하고 구현하기

엔티티 클래스의 모든 필드에 Objects.equals 메서드를 적용하여 비교하는 방식으로 구현할 수 있다.

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
    if (!(o instanceof User)) return false;

    Item item = (Item)o;
    return  Objects.equals(name, item.name) &&
            Objects.equals(price, item.price) &&
            Objects.equals(stockQuantity, item.stockQuantity);
}

@Override
public int hashCode() {
    int result = name != null ? name.hashCode() : 0;
    result = 31 * result + (price != null ? price.hashCode() : 0);
    result = 31 * result + (stockQuantity != null ? stockQuantity.hashCode() : 0);
    return result;
}

이 구현에서는 다음과 같은 문제가 발생할 수 있다.

1. 임의 영속성 컨텍스트의 instance를 수정한다면 다른 영속성 컨텍스트의 instance와 동일하지 않다. (변경되는 값이므로)

2. 속성의 조합이 유일성(unique)을 보장하지 않는다면, 전혀다른 row와 동일한 instance로 판단될 수 있다.

 

2. 비즈니스 키를 사용하여 구현하기

비즈니스 키는 다음과 같은 특성을 갖는다.

1. 변경할 수 없는 것은 아니지만, 변경할 일이 거의 없다.

2. Entity 클래스에는 반드시 비즈니스 키가 있어야 한다.

3. application에서 특정 record를 유일하게 식별하는데 사용한다.

 

비즈니스 키를 식별하는 기준

변경이 불가능한 필드는 아니지만, 변경의 횟수가 다소 적고, DB의 제약조건을 통해 유일성(UNIQUE)을 보장한다.

 

결론

1. 정의한 Entity를 Set과 같은 collection에 담아 관리할 가능성이 있는가? 없다면 application이 확정되는 과정에서도 계속해서 없음을 보장할 수 있는가?

=> PK를 사용하여 구현한다.

2. 영속, 비영속 상태의 entity를 비교할 가능성이 있는가? 있다면 각 속성의 조합으로 식별성을 갖출 수 있는가?

=> PK를 제외한 속성들로 구현한다.

3. 비즈니스 키를 사용하기 적합한 상황인가?

- 예를 들어 개인 식별 정보를 포함한다. 이름 + 핸드폰 / 이름 + 이메일

- 적은 개수의 속성으로도 식별성을 어느정도 보장할 수 있다.

 

 

 참고 

https://blog.yevgnenll.me/posts/jpa-entity-eqauls-and-hashcode-equality

 

Jpa Entity 의 Equals, 객체 동일성과 동등성, Lombok 을 써도 될까?

이전 면접에서 JPA entity 의 equals 와 hashCode 를 어떻게 구현했는지 묻는 질문이 나왔었다. 당시에는 Lombok 을 사용하고 있었고 자연스럽게 @EqualsAndHashCode 를 사용했기 때문에 이 부분에 대해 고민해

blog.yevgnenll.me

https://velog.io/@park2348190/JPA-Entity%EC%9D%98-equals%EC%99%80-hashCode

 

JPA Entity의 equals와 hashCode

스프링 프로젝트에서 ORM 기술로 JPA를 활용하던 도중 equals, hashCode 메서드를 재정의할 경우가 있었는데 이에 대한 고민 과정을 적어보고자 한다.일단 왜 재정의해야 하냐라는 의문이 들 수 있겠

velog.io

 

728x90
반응형
blog image

Written by ner.o

개발자 네로의 개발 일기, 자바를 좋아합니다 !