개요
이전 포스팅에서 자바의 동등성(Equality)과 동일성(Identity), 그리고 equals()와 hashCode() 메서드에 대해 정리했다. 일반 자바 객체에서는 객체의 논리적 동등성을 판단하는 데 필요한 핵심 필드들을 선택하여 이 두 메서드를 오버라이딩한다.
하지만 JPA 엔티티는 일반 객체와 다른 특성을 가지고 있어 주의가 필요하다:
- 영속성 컨텍스트 관리: 같은 엔티티라도 다른 시점에 조회하면 다른 인스턴스일 수 있다
- 프록시 객체: 지연 로딩 시 실제 엔티티가 프록시로 감싸진다
- ID 할당 시점: @GeneratedValue 사용 시 엔티티 생성 시점에는 ID가 null이고, persist 호출 후에야 ID가 할당된다
이러한 특성으로 인해 JPA 엔티티에서는 equals()와 hashCode() 구현에 특별한 전략이 필요하다. 잘못 구현하면 HashSet이나 HashMap 같은 컬렉션에서 엔티티를 제대로 식별하지 못하거나, 예상치 못한 동작이 발생할 수 있다.
예시 엔티티
다음과 같은 간단한 Member 엔티티를 예시로 사용한다:
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
@Column(unique = true, nullable = false)
private String email;
private String name;
protected Member() {}
public Member(String email, String name) {
this.email = email;
this.name = name;
}
}
방식 1: 오버라이딩 하지 않을 경우
가장 단순한 방법은 equals()와 hashCode()를 오버라이딩하지 않고 Object 클래스의 기본 구현을 사용하는 것이다.
Object 클래스의 기본 구현은 객체의 참조(reference)를 기반으로 동작한다:
// Object 클래스의 기본 구현
public boolean equals(Object obj) {
return (this == obj); // 참조 비교
}
public native int hashCode(); // 객체마다 고유한 해시코드
문제점
@Test
void withoutOverriding() {
Member m1 = new Member("kim@email.com", "Kim");
Member m2 = new Member("kim@email.com", "Kim");
// 같은 email을 가진 논리적으로 같은 회원이지만
assertThat(m1.equals(m2)).isFalse(); // 다른 객체로 판단한다
Set<Member> members = new HashSet<>();
members.add(m1);
members.add(m2);
assertThat(members.size()).isEqualTo(2); // Set에 중복 저장된다
// 영속성 컨텍스트에서도 문제 발생
entityManager.persist(m1);
entityManager.flush();
entityManager.clear();
Member m3 = entityManager.find(Member.class, m1.getId());
members.clear();
members.add(m1);
assertThat(members.contains(m3)).isFalse(); // 같은 엔티티인데 찾지 못함
}
문제점:
- 논리적으로 같은 회원(같은 email)인데도 다른 객체로 판단
- HashSet이나 HashMap에 중복으로 저장
- 영속성 컨텍스트에서 조회한 같은 엔티티를 컬렉션에서 찾을 수 없다.
이 방식은 엔티티의 논리적 동등성을 전혀 고려하지 못하기 때문에 적절하지 않다.
방식 2: @Id 필드만 사용
엔티티의 고유 식별자인 @Id 필드를 사용하는 방법을 생각해볼 수 있다.
@Entity
public class Member {
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Member)) return false;
Member member = (Member) o;
return Objects.equals(id, member.id);
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
}
equals/hashCode 규약 위반
이 방식은 equals()와 hashCode() 규약을 심각하게 위반한다. Object 클래스의 주석에 아래 내용을 확인할 수 있다.
Java 애플리케이션 실행 중에 동일한 객체에 대해 여러 번 호출될 때마다, equals 비교에 사용되는 정보가 수정되지 않는 한, hashCode 메서드는 일관되게 동일한 정수를 반환해야 합니다.
간단히 요약하면 같은 객체에 대해 hashCode를 여러 번 호출하더라도, equals()에 사용하는 값이 변경되지 않는 한 항상 같은 값을 반환해야한다는 것이다.
가령 @GeneratedValue를 사용하면 엔티티가 데이터베이스에 저장되기 전까지 id의 값이 null이다. 이로 인해 다음 문제가 발생한다:
@Test
void idBasedProblems() {
Member m1 = new Member("kim@email.com", "Kim");
Member m2 = new Member("lee@email.com", "Lee");
// persist 전 모든 엔티티가 같다고 판단한다.
assertThat(m1.getId()).isNull();
assertThat(m2.getId()).isNull();
assertThat(m1.equals(m2)).isTrue();
// persist 전후 hashCode가 변경됨
Set<Member> members = new HashSet<>();
int hashBefore = m1.hashCode(); // Objects.hash(null) = 0
members.add(m1);
entityManager.persist(m1); // id = 1로 할당
int hashAfter = m1.hashCode(); // Objects.hash(1L) = 32
assertThat(hashBefore).isNotEqualTo(hashAfter);
assertThat(members.contains(m1)).isFalse(); // set에서 찾을 수 없다
}
문제점:
- persist 전에는 서로 다른 엔티티가 같다고 판단한다.(모두 id=null)
- persist 전후로 hashCode가 변경되어 해시 기반 컬렉션에서 찾을 수 없다.
- equals/hashCode 규약 위반: 객체의 수명 동안 hashCode는 일관되게 유지되어야 한다.
방식 3: 모든 필드 사용
그렇다면 ID를 포함한 모든 필드를 사용한다면 어떨까?
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
// 생성자 생략
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Member)) return false;
Member member = (Member) o;
return Objects.equals(id, member.id) &&
Objects.equals(email, member.email) &&
Objects.equals(name, member.name) &&
Objects.equals(team, member.team);
}
@Override
public int hashCode() {
return Objects.hash(id, email, name, team);
}
}
문제점
@Test
void allFieldsProblems() {
// 문제 1: 지연 로딩 필드 접근 시 불필요한 쿼리 발생
Member m1 = new Member("kim@email.com", "Kim");
Team team = new Team("TeamA");
m1.setTeam(team);
entityManager.persist(team);
entityManager.persist(m1);
entityManager.flush();
entityManager.clear();
Member m2 = entityManager.find(Member.class, m1.getId());
// team은 LAZY 로딩으로 프록시 상태
Set<Member> members = new HashSet<>();
members.add(m2); // equals/hashCode 호출 시 team에 접근해 SELECT 쿼리가 발생
// 문제 2: 변경 가능한 필드 사용 시 hashCode 변경
Member m3 = new Member("park@email.com", "Park");
members.clear();
members.add(m3);
m3.setName("Choi"); // name 변경
assertThat(members.contains(m3)).isFalse(); // hashCode 변경으로 찾을 수 없다.
// 문제 3: 엔티티 식별과 관련 없는 필드까지 포함
Member m4 = new Member("kim@email.com", "Kim");
Member m5 = new Member("kim@email.com", "Park");
assertThat(m4.equals(m5)).isFalse(); // 같은 email인데 name이 다르다고 다른 객체로 판단
}
문제점:
- 성능 이슈: 지연 로딩 필드 접근 시 불필요한 DB 쿼리 발생한다.
- 규약 위반: 변경 가능한 필드 사용 시 hashCode가 변경되어 컬렉션에서 객체를 찾을 수 없게 된다.
- 부적절한 비교: 엔티티 식별과 관련 없는 필드까지 포함되어 논리적 동등성을 제대로 판단하지 못한다.
불변 비즈니스 키 사용
앞선 방식들의 문제점을 정리하면:
- 방식 1은 논리적 동등성을 무시
- 방식 2는 ID가 null일 때 문제 발생 및 hashCode 불일치
- 방식 3은 변경 가능한 필드로 인한 hashCode 불일치 및 성능 이슈(SELECT 쿼리 발생)
이 문제들을 해결하려면 다음 조건을 모두 만족하는 필드를 사용해야 한다:
핵심 조건
- 비즈니스 식별자: 해당 엔티티를 유일하게 구분할 수 있는 값
- 불변성(Immutable): 한 번 설정되면 절대 변경되지 않는 값
- 생성 시점 존재: DB 저장 전부터 값이 존재 (생성자에서 초기화)
- Not Null: null이 아닌 값
Member 엔티티의 경우 email이 이러한 조건을 만족한다:
- 회원을 유일하게 식별할 수 있다.
- 가입 후 변경되지 않는다.
- 생성자에서 초기화된다.
- @Column(unique = true, nullable = false)로 제약 조건을 보장한다.
구현 코드
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
private String name;
protected Member() {}
public Member(String email, String name) {
this.email = email;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Member)) return false;
Member member = (Member) o;
return email != null && Objects.equals(email, member.email);
}
@Override
public int hashCode() {
return Objects.hashCode(email);
}
}
검증 테스트
@Test
void correctImplementation() {
// 논리적 동등성 검증
Member m1 = new Member("kim@email.com", "Kim");
Member m2 = new Member("kim@email.com", "Lee");
assertThat(m1.equals(m2)).isTrue(); // 같은 email = 같은 회원
// persist 전후 일관성
int hashBefore = m1.hashCode();
Set<Member> members = new HashSet<>();
members.add(m1);
entityManager.persist(m1);
int hashAfter = m1.hashCode();
assertThat(hashBefore).isEqualTo(hashAfter); // hashCode 일관성 유지
assertThat(members.contains(m1)).isTrue(); // 여전히 찾을 수 있다.
// 프록시 객체와의 비교
entityManager.flush();
entityManager.clear();
Member proxy = entityManager.getReference(Member.class, m1.getId());
assertThat(members.contains(proxy)).isTrue(); // 프록시도 올바르게 비교
assertThat(m1.equals(proxy)).isTrue();
// 변경 가능한 필드는 영향 없다
m1.setName("Park");
assertThat(members.contains(m1)).isTrue(); // name 변경해도 찾을 수 있다
}
불변 비즈니스 키가 없다면
@GeneratedValue를 사용하는 엔티티에서 모든 엔티티가 적절한 비즈니스 키를 가지는 것은 아니다. 이런 경우에는 어떻게 해야 할까?
IntelliJ에서 사용할 수 있는 JPA 플러그인인 JPA Buddy에서 제공하는 구현 방식을 살펴보자.
JPA Buddy의 구현 방식
@Entity
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
public Member(String name) {
this.name = name;
}
@Override
public final boolean equals(Object o) {
if (this == o) return true;
if (o == null) return false;
Class<?> oEffectiveClass = o instanceof HibernateProxy
? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass()
: o.getClass();
Class<?> thisEffectiveClass = this instanceof HibernateProxy
? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass()
: this.getClass();
if (thisEffectiveClass != oEffectiveClass) return false;
Member member = (Member) o;
return getId() != null && Objects.equals(getId(), member.getId());
}
@Override
public final int hashCode() {
return this instanceof HibernateProxy
? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode()
: getClass().hashCode();
}
}
코드 상세 분석
HibernateProxy 처리
Class<?> oEffectiveClass = o instanceof HibernateProxy
? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass()
: o.getClass();
JPA의 지연 로딩을 사용하면 실제 엔티티 대신 프록시 객체가 반환된다. 프록시 객체는 실제 엔티티 클래스를 상속받은 동적 생성 클래스이므로, 단순히 getClass()로 비교하면 같은 엔티티더라도 다른 클래스로 판단한다.
Member actual = new Member("Kim"); // class: Member
Member proxy = entityManager.getReference(Member.class, 1L); // class: Member$HibernateProxy$...
assertThat(actual.getClass() == proxy.getClass()).isFalse();
JPA Buddy의 방식은 HibernateProxy 인터페이스를 체크하여 프록시인 경우 getPersistentClass()로 원본 엔티티의 클래스를 가져온다. 이렇게 하면 프록시와 실제 엔티티를 올바르게 비교할 수 있다.
고정된 hashCode
@Override
public final int hashCode() {
return this instanceof HibernateProxy
? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode()
: getClass().hashCode();
}
이 방식의 핵심은 ID 값을 hashCode에 사용하지 않는다는 것이다. 대신 엔티티 클래스 자체의 hashCode를 반환한다. 이렇게 할 경우 아래의 이점이 있다.
- ID 기반 hashCode의 문제: persist 전후로 ID가 null → 1L로 변경되면 hashCode도 변경되는 문제가 발생하지 않는다.
- 클래스 기반 hashCode: 객체의 수명 동안 절대 변경되지 않기 때문에 equals/hashCode 규약을 준수하면서 컬렉션에서 안전하게 사용 가능하다.
다만 같은 엔티티 타입의 모든 인스턴스가 동일한 hashCode를 가지게 되어 해시 충돌이 많이 발생한다. 하지만 일반적으로 대량의 엔티티를 해시 컬렉션에 저장해 사용하는 경우는 거의 없다고 여겨서 JPA Buddy에서는 이렇게 구현한 듯 하다.
ID 기반 equals
@Override
public final boolean equals(Object o) {
return getId() != null && Objects.equals(getId(), member.getId());
}
방식 2에서 본 단순 ID 기반 equals와 달리, null 체크를 먼저 수행한다.
- getId() != null: persist 전(ID가 null인 상태)에는 항상 false를 반환한다.
- persist 후에만 ID로 비교해 persist 전에 서로 다른 엔티티가 같다고 판단되는 문제를 방지한다.
정리
JPA 엔티티에서 equals()와 hashCode()를 구현할 때는 단순한 자바 객체와 달리 영속성 컨텍스트, 프록시 객체, ID 할당 시점을 고려해야 한다.
가장 이상적인 방법은 불변 비즈니스 키를 사용하는 것이다. 이메일, 주문번호처럼 엔티티를 유일하게 식별하고 변경되지 않는 필드가 있다면 이를 활용한다. persist 전후로 일관된 동작을 보장하고 해시 컬렉션에서도 안전하게 사용할 수 있다.비즈니스 키를 사용할 수 없다면 엔티티의 특성과 equals/hashCode에 대한 몇몇 규칙을 인지하고 있어야 의도하지 않는 동작을 막을 수 있다.
- equals/hashCode에 사용하는 필드는 불변이어야 한다.
- 프록시 객체를 고려하여 instanceof를 사용한다.
- 객체의 수명 동안 hashCode는 일관되게 유지되어야 한다.