Kotiln으로 객체를 생성할 때, equals(), hashcode(), toString() 등의 오버라이딩 메서드를 기본적으로 제공해주는 data class를 많이 활용해왔다. 기존 개발에서도 엔티티를 구성하며 data class로 편리하게 필드를 만들어 썼었다. 하지만 최근 JPA에서 이를 권장하지 않는다는 사실을 알게되었다.
Hibernate 공식문서의 Entity 개발 시 requirement는 다음과 같다.
•
@Entity annotation이 달려야 한다.
•
public or protected no-argument constructor가 필요하다.
•
final이면 안된다. (이는 java 기준으로 작성된 문서이며, java에서의 final은 불변의 의미를 갖는다.)
•
Identifier를 제공해야 하며, nullabe/non-primitive 인 것이 권고된다.
•
equals와 hashcode를 구현해야한다.
그럼 data class를 활용했을 때 어떤 문제가 생길까?
1. Lazy Loading의 무력화
JPA는 성능 최적화를 위해 객체가 실제로 필요할 때까지 DB 조회를 미루는 Proxy 객체를 사용한다.
data class 가 생성하는 toString(), hashCode() 등은 객체 내부의 모든 필드에 접근한다. 때문에 로그를 찍기 위해 toString()을 호출하는 순간, 프록시 객체의 모든 필드를 건드리게 되어 불필요한 DB 조회가 즉시 발생한다. 이 부분에서 Lazy 강제 초기화가 일어나고, 성능 저하가 생긴다.
2. equals()와 hashCode()로 인한 무한 루프
data class는 모든 프로퍼티 기반으로 equals/hashCode를 생성한다. 이러한 기본 제공 메서드가 양방향 연관관계에 있게될 때 equals()나 hashCode() 호출을 시도하면, 서로를 계속 참조하며 호출하는 무한 루프에 빠져서 StackOverflowError가 발생한다.
JPA의 관점에서 엔티티는 모든 필드가 아닌 PK(@Id)만으로 동일성을 판단해야 하는데, data class는 모든 필드를 계산에 넣기 때문에 엔티티의 논리와 맞지 않게 된다.
3. 기본 생성자 및 가변성 문제
JPA 엔진은 리플렉션을 통해 엔티티를 생성하기 때문에, 위의 공식문서에도 나와있듯 매개변수가 없는 기본 생성자가 반드시 필요하다.
data class는 기본적으로 모든 필드를 포함하는 주 생성자를 만든다. 모든 필드에 기본값을 주면 기본 생성자가 생기긴 하지만, 당연히 깔끔한 구조는 아니다.
JPA 프록시는 엔티티를 상속받아 생성되는데, data class는 final 클래스라 상속이 불가능하다.
또 hibernate의 문서를 보면 아래의 문장이 있는데,
You can still persist final classes that do not implement such an interface with Hibernate, but you will not be able to use proxies for fetching lazy associations, therefore limiting your options for performance tuning.
final class로도 코드는 돌아가지만, proxy를 통한 Lazy Loading은 할 수 없으므로, 성능 튜닝 도구의 사용이 제한된다는 것.
Kotlin의 클래스와 프로퍼티, 함수는 기본적으로 final이라 공식 문서에서는 all-open 플러그인을 쓰라고 하지만, 그건 일반 클래스의 경우이지 data class는 언어 규칙상 절대 open이 될수 없기에, hibernate의 성능 최적화 기능은 사실상 포기해야한다.
위의 3가지 경우에 대한 예시로,
@Entity
data class Member(
@Id @GeneratedValue val id: Long? = null,
val name: String,
@OneToMany(mappedBy = "member")
val orders: List<Order> = mutableListOf()
)
@Entity
data class Order(
@Id @GeneratedValue val id: Long? = null,
@ManyToOne
val member: Member
)
Kotlin
복사
만약 로그를 찍기 위해 println(member) 를 실행하면 다음과 같은 일이 벌어진다.
1.
member.toString() 호출 → 내부의 orders.toString() 호출
2.
order.toString() 호출 → 내부의 member.toString() 호출
3.
무한 반복… → 결국 StackOverflowError가 발생한다.
@Entity
data class Post(
@Id @GeneratedValue val id: Long? = null,
val title: String,
@OneToMany(fetch = FetchType.LAZY) // 지연 로딩 설정
val comments: List<Comment> = mutableListOf()
)
Kotlin
복사
data class는 모든 필드를 포함하는 equals()와 hashCode()를 가진다고 했다. 만약 이 엔티티를 HashSet에 담거나 비교 연산(==)을 수행하면,
1.
hashCode()가 실행되면서 모든 필드값을 읽으려고 시도한다.
2.
comments 필드에 접근하는 순간, JPA는 데이터가 필요하구나! 라고 판단해서 DB에서 모든 댓글을 즉시 Select 해버린다.
3.
결과적으로 Lazy Loading이 무시되고, Eager 처럼 동작해서 성능 튜닝이 불가능해진다.
때문에 일반 class를 사용하여 필요 메서드만 구현하는 방법이 권장된다.
@Entity
class Member(
@Id @GeneratedValue
val id: Long? = null,
var name: String
) {
@OneToMany(mappedBy = "member")
val orders: MutableList<Order> = mutableListOf()
// equals/hashCode는 ID값만 사용하도록 직접 구현
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Member) return false
return id != null && id == other.id
}
override fun hashCode(): Int = id?.hashCode() ?: 0
// toString에서 연관관계 필드(orders)는 제외하여 무한 루프 방지
override fun toString(): String = "Member(id=$id, name='$name')"
}
Kotlin
복사
정리
JPA 엔티티는 값 객체가 아니라 식별자와 생명주기를 가진 상태 객체이므로, Kotlin에서는 data class가 아닌 상속 가능한 class로 작성해야 한다.

