이전에 Java에서 equals()와 hashCode() 메서드를 재정의 해야 하는 이유에 대해 알아보았다.
그렇다면 JPA Entity에서는 equals()와 hashCode()를 어떻게 재정의 해야 할까?
🌲 JPA Entity에서는 뭐가 달라?
JPA의 영속성 컨텍스트는 데이터베이스에서 가져온 Entity의 식별자가 이미 1차 캐시에 존재하면 해당 Entity를 반환하는 방법으로 영속 상태인 엔티티의 동일성을 보장해 준다. 즉, 같은 영속성 컨텍스트라면 equals()와 hashcode() 메서드를 재정의해줄 필요 없이 올바른 엔티티 간의 비교가 가능하다.
하지만 모든 엔티티의 비교가 항상 같은 영속성 컨텍스트 안에서 이루어진다는 보장을 할 수 없기 때문에 JPA Entity에서 equals(), hashcode() 메서드를 재정의해주어야 한다.
🌲 @EqualsAndHashCode 어노테이션을 쓰면 안 돼??
@Getter
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
@Builder
public Account(Long id, String username, String password) {
this.id = id;
this.username = username;
this.password = password;
}
}
@EqualsAndHashCode lombok 어노테이션을 사용하면 id 필드를 사용해 equals, hashCode 메서드를 오버라이딩해준다.
public boolean equals(final Object o) {
if (o == this) {
return true;
} else if (!(o instanceof Account)) {
return false;
} else {
Account other = (Account)o;
if (!other.canEqual(this)) {
return false;
} else {
Object this$id = this.getId();
Object other$id = other.getId();
if (this$id == null) {
if (other$id != null) {
return false;
}
} else if (!this$id.equals(other$id)) {
return false;
}
return true;
}
}
}
protected boolean canEqual(final Object other) {
return other instanceof Account;
}
public int hashCode() {
int PRIME = true;
int result = 1;
Object $id = this.getId();
result = result * 59 + ($id == null ? 43 : $id.hashCode());
return result;
}
컴파일 후 생성되는 바이트코드를 보면 id 필드를 사용하는 equals, hashCode 메서드가 생성된 것을 확인할 수 있다.
따라서 id 필드 값이 동일한 객체는 equals와 hashCode의 리턴값이 동일하다.
하지만 id 필드값이 다른 객체는 equals 리턴 값은 다르지만 hashCode 리턴값은 동일할 수도 다를 수도 있다.
🫧 id가 null인 경우
@Test
@DisplayName("ID가 null일 때 비교")
void compare_when_id_is_null() {
Account hanni = new Account("hanni@newjeans.com");
Account danielle = new Account("danielle@newjeans.com");
assertSame(hanni.hashCode(), danielle.hashCode()); // true
assertEquals(hanni, danielle); // true
}
id가 null인 경우 equals, hashCode 메서드는 동등하다는 결과를 리턴한다.
🫧 id가 null이 아닌 경우
@Test
@DisplayName("엔티티가 영속화되어 id 필드가 다른 경우 비교")
void comparing_persist_account() {
Account hanni = new Account("hanni@newjeans.com");
Account danielle = new Account("danielle@newjeans.com");
accountRepository.saveAll(List.of(hanni, danielle));
assertNotEquals(hanni, danielle); // true
}
id가 null이 아닌 경우 equals 메서드는 두 객체가 동등하지 않다고 반환하고 hashCode 메서드의 결과는 보장할 수 없다.
🫧 엔티티가 영속화되기 전과 후의 hashcode 값
@Test
@DisplayName("엔티티가 영속화되기 전과 후의 hashcode 값 비교")
void comparing_hashcode_before_and_after_persistence() {
Account hanni = new Account("hanni@newjeans.com");
Account danielle = new Account("danielle@newjeans.com");
Set<Account> accounts = new HashSet<>();
accounts.add(hanni);
accounts.add(danielle);
assertTrue(accounts.contains(hanni));
assertTrue(accounts.contains(danielle));
accountRepository.saveAll(List.of(hanni, danielle)); // 엔티티 영속화
assertFalse(accounts.contains(hanni));
assertFalse(accounts.contains(danielle));
}
엔티티의 기본키 생성전략을 identity로 설정했기 때문에 엔티티가 영속화되는 시점에 INSERT 쿼리가 발생하고 id 필드에 값이 주입된다.
엔티티를 영속화한 후에 해시 자료구조에 해당 값 존재 여부를 확인할 경우, 동일 객체가 존재하더라도 해시 자료구조에 객체를 삽입할 때와의 hashcode 값이 다르기 때문에 찾을 수 없다.
🫧 @OneToMany 연관관계로 해시 자료구조를 사용할 경우
@Getter
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Notice {
// 생략
@OneToMany(fetch = LAZY, mappedBy = "notice")
private Set<noticeImage> noticeImages;
public Notice(String content) {
this.content = content;
this.noticeImages = new HashSet<>();
}
public void addNoticeImage(NoticeImage image) {
this.noticeImages.add(image);
}
}
@Getter
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class NoticeImage {
// 생략
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "notice_id")
private Notice notice;
public NoticeImage(String imageUrl, Notice notice) {
this.imageUrl = imageUrl;
this.notice = notice;
}
}
@Test
@DisplayName("엔티티가 영속화되기 전과 후의 hashcode 값 비교")
void comparing_hashcode_before_and_after_persistence() {
Notice notice = new Notice("공지사항");
NoticeImage image1 = new NoticeImage("url1", notice);
NoticeImage image2 = new NoticeImage("url2", notice);
notice.addNoticeImage(image1);
notice.addNoticeImage(image2);
assertTrue(notice.getNoticeImages().contains(image1));
assertTrue(notice.getNoticeImages().contains(image2));
// 엔티티 영속화
noticeRepository.save(notice);
noticeImageRepository.saveAll(List.of(image1, image2));
assertFalse(notice.getNoticeImages().contains(image1));
assertFalse(notice.getNoticeImages().contains(image2));
}
이 경우도 마찬가지로 영속화하기 전과 후의 hashCode 값이 다르기 때문에 해시 자료구조에 삽입한 객체를 찾을 수 없다.
따라서 영속화 전과 후의 객체 동등성 보장을 위해서는 @EqualsAndHashCode 어노테이션이 아닌 직접 equals(), hashCode() 메서드를 오버라이딩해야 한다.
🌲 equals(), hashCode() 재정의
도메인 규칙에 의해 같은 객체라고 판단하는 기준이 다르기 때문에 정해진 정답은 없지만 보통 아래 세 가지로 구현하는 것 같다.
- 기본키로 구현
모든 데이터베이스 레코드, 즉 엔티티는 각자 고유한 기본키를 가져 유일성이 보장되기 때문에 equals 메서드 작성 가능 - PK를 제외하고 구현
엔티티 클래스의 모든 필드에 Objects.equals 메서드를 적용하여 비교하는 방식으로 구현 가능 - 비즈니스 키를 사용하여 구현
변경이 불가능한 필드는 아니지만, 변경의 횟수가 적고 데이터베이스의 제약조건을 통해 유일성이 보장되는 비즈니스 키
나의 경우 아래의 가정을 토대로 Account의 equals와 hashCode 메서드를 재정의 해보려고 한다.
- @Id 값이 같으면 같은 엔티티이다.
- 엔티티의 @Id 값이 null인 경우, 다른 값들이 같아도 다른 엔티티로 본다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
@Builder
public Account(Long id, String username, String password) {
this.id = id;
this.username = username;
this.password = password;
}
@Override
public final boolean equals(Object o) {
// 재정의
}
@Override
public final int hashCode() {
// 재정의
}
}
@Test
@DisplayName("ID가 같을 때 영속 객체 & 프록시 객체 동등성 비교")
void comparing_account_when_ids_are_the_same() {
Account account = getPersistAccount("user@gmail.com"); // ID가 있는 account 객체
Account proxyAccount = getProxyAccount(account.getId()); // account ID로 가져온 프록시 객체
assertThat(account).isEqualTo(proxyAccount);
assertThat(proxyAccount).isEqualTo(account);
}
private Account getPersistAccount(final String username) {
Account account = new Account(username);
entityManager.persist(account);
entityManager.flush();
entityManager.clear();
return account;
}
private Account getProxyAccount(final Long accountId) {
return entityManager.getReference(Account.class, accountId);
}
첫 번째 시도 - 실패
@Override
public final boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || this.getClass() != o.getClass()) {
return false;
}
Account account = (Account) o;
return Objects.equals(id, account.id);
}
이 설정에 의하면 Id가 같으면 항상 같은 Entity여야 한다.
따라서 테스트 코드에서 두 Account 객체의 Id가 같기 때문에 엔티티가 프록시 객체인지의 여부와 관계없이 성공해야 하지만 위 테스트는 실패한다.
그 이유는 프록시로 조회한 엔티티 타입은 원본 타입을 상속받아 구현한 HibernateProxy 타입이기 때문에 타입 간 동등 비교에서 실패하는 것이다. JPA Entity에서는 지연로딩을 위한 연관관계에서 프록시 엔티티를 가지고 있을 수 있기 때문에 equals를 아래와 같이 재정의해보았다.
두 번째 시도 - 실패
@Override
public final boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Account account)) {
return false;
}
return Objects.equals(id, account.id);
}
하지만 이 테스트도 실패하게 되는데, 일반적으로 equals()를 구현할 때는 멤버 변수를 직접 비교하지만 프록시의 경우 실제 데이터를 필드로 가지고 있지 않아 직접 접근 시 아무 값도 조회할 수가 없기 때문이다.
이처럼 euqls 메서드의 비교 대상이 프록시인 경우 id값이 null이다.
세 번째 시도 - 성공
@Override
public final boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Account account)) {
return false;
}
return Objects.equals(getId(), account.getId());
}
이번에는 접근자 getId() 메서드를 사용해 필드에 접근해 id 값을 비교했다. 이 경우 프록시 객체가 영속성 컨텍스트의 1차 캐시에 있는 실제 엔티티에 접근하여 데이터베이스에 있는 값을 제대로 가져올 수 있다.
최종 수정
@Override
public final boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Account account)) {
return false;
}
return getId() != null && Objects.equals(getId(), account.getId());
}
@Override
public final int hashCode() {
return Objects.hash(getId);
}
접근자를 통해 id값에 접근한 hashCode와 "@Id 값이 null인 경우 다른 값이 같아도 다른 엔티티로 본다"는 도메인 규칙을 추가한 equals 메서드이다.
📚 참고
[JPA] Entity에서 equals, hashcode 사용시 발생할 수 있는 문제점
'Back-end' 카테고리의 다른 글
[Gradle] 자바 플러그인, implementation과 api의 차이 (1) | 2024.05.23 |
---|---|
[Spring Boot] 멀티 모듈 참고 영상 및 정리 (0) | 2024.05.17 |
Postman 환경변수 자동 세팅(토큰값 자동 세팅) (0) | 2024.05.06 |
[Spring Security] 인증 및 권한 부여 구성 요소 살펴보기 (0) | 2024.05.02 |
Java Record로 DTO를 만들어봅시다 (0) | 2024.05.01 |