[JPA] 엔티티의 equals와 hashCode에 관하여
JPA 자체만으로는서 동일한 키 값을 가지는 두 엔티티를 조회했을때 동일성을 보장해주지 않는다. 따라서 로직상 동일한 키를 가지는 두 엔티티를 비교하기 위해선 반드시 equals와 hashCode를 재정의해야한다.
# 단일키일 경우
다음과 같은 PK로 하나의 값을 가지는 엔티티를 가정해보자.
@Entity
data class OneKeyEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L
)
이제 이 코드를 실행해서 리포지토리에서 가져온 동일한 두 ID 엔티티가 동일한지 확인해보면,
val oneKeyFirst = oneKeyEntityRepository.findById(1).orElseThrow()
val oneKeySecond = oneKeyEntityRepository.findById(1).orElseThrow()
println("단일키 두 엔티티가 동일한가? : ${oneKeyFirst == oneKeySecond}")
동일하지 않다는 결과가 나온다. 이는 객체를 비교하는 equals와 hashCode가 오버라이딩 되지 않았기 때문이다.
@Entity
class OneKeyEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L
) {
override fun equals(other: Any?): Boolean {
return other is OneKeyEntity && id == other.id
}
}
이제 equals만 id가 동일하면 같은 객체로 리턴하게끔 오버라이딩하고 재실행 해보자.
하지만 IDE에서 hashCode도 오버라이드 하라고 경고를 띄워준다.
equals만 재정의해도 두 엔티티가 동일함이 확인됐는데, 왜 hashCode도 재정의를 해야할까?
이는 컬렉션 프레임워크를 사용할 때 hashCode를 기반으로 동작하는 stdlib들이 많기 때문에 여기서 제대로 동작하지 않을 수 있기 때문이다. (ex. Map)
해당 엔티티들을 엔티티 그 자체 객체를 키로하고, 아이디 값을 밸류로 하는 Map 자료형에 저장해보자.
그리고 여기서 oneKeyFirst를 조회하는 코드를 작성하고 결과를 확인하면,
println("단일키 1번 엔티티를 맵에서 조회한 결과 : ${oneMap[oneKeyFirst]}")
이 처럼 null이 찍히는 걸 확인할 수 있다.
이제 id를 hashCode 하도록 재정의하고 수행해보자.
@Entity
class OneKeyEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L
) {
override fun equals(other: Any?): Boolean {
return other is OneKeyEntity && id == other.id
}
override fun hashCode(): Int {
return id.hashCode()
}
}
정상적으로 값이 Map에서 조회되는걸 확인할 수 있다.
코틀린에서는 클래스 앞에 data 키워드를 붙혀서 equals와 hashCode를 자동으로 생성되게 할 수도 있다.
하지만 엔티티 객체에 data class를 사용하는건 충분히 고민 후 사용해야한다.
왜냐하면 data 키워드는 toString도 재정의해주는데, 연관관계가 포함된 엔티티는 이를 호출하였을 때 무한재귀가 빠질 수 있기 때문이다.
따라서 data 키워드를 엔티티 객체에 선언하는걸 지양해야하지만, 필자는 연관관계가 존재하지 않는 아일랜드 테이블이라면 data class를 사용해도 무방하다고 생각한다.
@Entity
data class OneKeyEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L
)
# 복합키일 경우
복합키를 갖는 엔티티를 가정해보자.
참고로 MySQL에서 복합키 엔티티를 정의하면 GeneratedValue의 auto increment를 사용할 수 없다.
@Embeddable
data class CompositeKey(
val id1: Long = 0L,
val id2: String
) : Serializable
@Entity
class CompositeKeyEntity(
@EmbeddedId
val id: CompositeKey,
val value: String,
)
엔티티를 비교할 땐 key값만 비교하므로,
엔티티 자체 말고, key 클래스에 data 키워드를 붙혀서 equals와 hashCode가 재정의 됐으니, 동일 객체를 반환할 것이라 예상된다.
val compositeMap = compositeKeyEntityRepository.findAll().associateWith { it.value }
val compositeKeyFirst = compositeKeyEntityRepository.findById(CompositeKey(1, "A")).orElseThrow()
val compositeKeySecond = compositeKeyEntityRepository.findById(CompositeKey(1, "A")).orElseThrow()
println("복합키 두 엔티티가 동일한가? : ${compositeKeyFirst == compositeKeySecond}")
println("복합키 1번 엔티티를 맵에서 조회한 결과 : ${compositeMap[compositeKeyFirst]}")
하지만 결과는 false를 반환한다.
이는 엔티티 key 클래스에만 equals와 hashCode가 재정의 되어서(data 키워드로) 그렇다.
따라서 복합키를 사용하는 엔티티일 경우 엔티티 클래스 자체에 eqauls와 hashCode를 재정의 해줘야한다.
@Entity
data class CompositeKeyEntity(
@EmbeddedId
val id: CompositeKey,
val value: String,
)
(엔티티 클래스 자체를 비교하므로, 엔티티 클래스에 data 클래스를 붙혀서 equals와 hashCode를 생성하게 했다.)
key class는 data 키워드를 사용해도 무방하지만, 엔티티가 연관관계가 많다면 직접 equals와 hashCode를 재정의해야한다.
@Entity
class CompositeKeyEntity(
@EmbeddedId
val id: CompositeKey,
val value: String,
) {
override fun hashCode(): Int {
return id.id1.hashCode() + id.id2.hashCode()
}
override fun equals(other: Any?): Boolean {
return other is CompositeKeyEntity && id.id1 == other.id.id1 && id.id2 == other.id.id2
}
}
# 결론
일반적인 경우라도 JPA 엔티티를 설계할 때 equals와 hashCode를 재정의하는게 안전하겠지만, 이는 중복되는 코드를 증가시킬 것이다.
따라서 단일키던, 복합키던 비즈니스 로직상 엔티티 자체를 비교할 일이 많고, 이를 컬렉션 프레임워크에서 사용할 가능성이 있다면 반드시 equals와 hashCode를 재정의해야함을 명심하자.