🐞 JPA 엔티티에서 equals()와 hashCode() 구현 시 반드시 주의할 점 - 직접 필드 접근이 왜 버그를 유발하는가?
✅ 상황 설명: 분명히 필드 값은 같았는데, equals() 비교가 false?
JPA를 사용할 때 다음과 같은 상황을 경험해본 적이 있을 수 있습니다:
Member m1 = memberRepository.findById(1L).get();
Member m2 = new Member(1L);
System.out.println(m1.equals(m2)); // false?!
두 객체는 같은 ID를 가지고 있지만 equals()는 false를 반환합니다.
왜 이런 일이 벌어질까요?
🔍 원인: 프록시(proxy)와 직접 필드 접근의 충돌
JPA에서 엔티티는 일반적인 자바 객체로 생성되는 것이 아니라,
프록시 객체로 래핑되어 반환되는 경우가 많습니다.
예를 들어, @ManyToOne 등 지연 로딩(LAZY)이 걸린 엔티티는 실제 객체가 아니라 하이버네이트가 만든 프록시 객체입니다.
🔎 그런데?
우리가 equals()나 hashCode() 안에서 직접 필드를 비교하면,
이 프록시 객체는 실제 필드에 접근하지 못하거나,
클래스 타입이 달라서 비교가 틀어지는 문제가 발생합니다.
❌ 잘못된 구현 예시
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false; // ⛔ 위험
Member member = (Member) o;
return id != null && id.equals(member.id); // ⛔ 직접 필드 접근
⚠️ 이 방식의 문제
- getClass()를 사용하면 프록시 객체는 통과하지 못함 → equals 실패
- member.id를 직접 접근하면, 아직 초기화되지 않았거나 프록시인 경우 올바르게 비교되지 않음
✅ 올바른 접근 방식: getter 사용
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Member)) return false;
Member other = (Member) o;
return this.getId() != null && this.getId().equals(other.getId()); // ✅ getter 사용
}
📌 이 방식의 장점
- 프록시 객체도 instanceof를 통과할 수 있음
- getter를 통해 필드에 접근하면, 프록시가 내부적으로 위임하여 실제 값에 접근 가능
- 하이버네이트 프록시 내부 동작에 맞게 작동함 → 안정적
📦 실무 팁: equals/hashCode는 오직 식별자(ID)만 기준으로
JPA에서는 equals()와 hashCode()는 반드시 식별자(ID)만을 기준으로 구현해야 합니다.
@Override
public int hashCode() {
return Objects.hash(getId());
}
왜냐하면:
- 아직 persist 되지 않은 엔티티는 ID가 없을 수 있고
- 다른 필드를 비교 기준으로 삼으면 상태 변화에 따라 해시값이 바뀔 수 있어 HashSet 등에서 문제 발생
📌 요약 정리
항목 | 설명 |
문제 원인 | 프록시 객체와 직접 필드 접근의 충돌 |
잘못된 구현 | getClass(), 직접 필드 접근 |
올바른 구현 | instanceof + getter 사용 |
비교 대상 | 항상 식별자(ID) 하나만 비교 |
해시코드 | ID 기반으로만 계산 |
✅ 마무리
Spring Data JPA에서 엔티티는 단순한 자바 객체처럼 보이지만,
실제로는 하이버네이트가 감싸는 프록시 객체일 수 있습니다.
따라서 equals()와 hashCode()를 구현할 때 직접 필드에 접근하는 방식은 매우 위험합니다.
📌 반드시 getter를 통해 접근하고,
비교 기준은 ID 하나만 사용하는 것이 안전한 JPA 실무 전략입니다.