코틀린에서 Entity 클래스를 어떻게 사용해야 할까?

date
Apr 4, 2023
thumbnail
slug
kotlin-jpa-entity
author
status
Published
tags
Kotlin
JPA
summary
코틀린에서 JPA Entity를 어떻게 정의하는 게 좋을까
type
Post
updatedAt
Apr 13, 2023 05:47 AM
 
최근 kotlin과 JPA를 공부하면서, 사내의 코드나 인터넷에서 찾을 수 있는 많은 자료들이 일반 클래스, 데이터 클래스, val, var, nullable 등 다양한 방법으로 JPA Entity 클래스를 정의하는 것을 볼 수 있었다.
모든 상황에 맞는 완벽한 정답은 없겠지만 kotlin으로 Entity 클래스를 정의할 때 어떻게 하는 게 제일 best practice에 가까운 지 찾아보았다.

데이터 클래스 사용 시 문제점들

코틀린의 데이터 클래스(Data Class)는 데이터를 다루는데 최적화된 클래스로 equals(), hashCode(), toString(), copy(), componentN() 5가지 유용한 메소드들을 내부적으로 자동으로 생성해준다.
비대한 보일러 플레이트 코드를 줄일 수 있기 때문에 많은 사랑을 받지만 JPA의 Entity 클래스엔 어울리지 않는 듯 하다. 그 이유를 하나씩 살펴보자.

1. 주 생성자 매개변수로 정의된 프로퍼티들만 메소드 구현에 포함된다

데이터 클래스는 주 생성자로 정의한 프로퍼티들만 자동으로 생성되는 메소드의 구현에 포함된다. 예를 들면 아래와 같이 주 생성자에 넣지 않은 프로퍼티에 대해서 예상치 못한 동작이 일어날 수 있다.
data class Member(var name: String) {
	var age: Int = 0
}

val member1 = Member("leo")
member1.age = 10

val member2 = Member("leo")
member2.age = 20

println(member1.toString() == member2.toString()) // true
println(member1.hashCode() == member2.hashCode()) // true
println(member1 == member2) // true
위와 같은 동작을 피하기 위해선 모든 프로퍼티를 주 생성자를 통해 초기화 해야한다. 하지만 모든 프로퍼티를 주 생성자에 정의하면 특정 프로퍼티에 대한 초기값을 제한할 수 없다.
enum class OrderState {
	INIT, CANCELED

}
@Entity
data class Order(
	@Id var id: UUID,
	@Column var orderAt: LocalDateTime,
	@Column var state: OrderState, // Order 생성 시 처음 state는 항상 INIT
)


Order(
	id = UUID.randomUUID(),
	orderAt = LocalDateTime.now(),
	state = OrderState.INIT, // 하지만 이와 같이 상태를 다른 값으로 넣어도 막을 방법이 없다
)
@Entity
data class Order(
	@Id var id: UUID,
	@Column var orderAt: LocalDateTime,
) {
	@Column var state: OrderState = OrderState.INIT	
}
결국 이와 같이 주생성생자의 매개변수에서 프로퍼티를 빼는 방법 뿐인데, 이러면 데이터 클래스가 구현해주는 메서드들의 동작이 복잡해지므로 결국 일반 클래스를 사용하는 편이 더 낫다 는 생각이 들기 시작한다.

2. 데이터 클래스는 final 이다

먼저 JSR 338 Persistence API hibernate 가이드 문서가 말하는 Entity 클래스를 살펴보자.
JSR 338 Persistance API • The entity class must have a no-arg constructor. The entity class may have other constructors as well. The no-arg constructor must be public or protected. • The entity class must be a top-level class. An enum or interface must not be designated as an entity. • The entity class must not be final. No methods or persistent instance variables of the entity class may be final. Hibernate Guide Docs • The entity class must not be final. No methods or persistent instance variables of the entity class may be final. • Technically Hibernate can persist final classes or classes with final persistent state accessor (getter/setter) methods. However, it is generally not a good idea as doing so will stop Hibernate from being able to generate proxies for lazy-loading the entity.
Entity 클래스는 final이면 안된다고 명시되어 있다. (Hibernate는 Entity 클래스가 final 이어도 동작은 하지만, lazy-loading을 위한 프록시 객체를 생성할 수 없다고 되어있다.)
하지만 코틀린 Docs를 보면 데이터 클래스에 대해 다음과 같은 설명이 있다.
• Data classes cannot be abstract, open, sealed, or inner.
즉 데이터 클래스는 open이 불가능, final만 가능하다는 얘기다. 이런 스펙적인 부분을 감안하면 일반 클래스를 사용하는 편이 더 낫다 는 생각이 더 커진다.

데이터 클래스 + allOpen 플러그인

코틀린은 클래스와 모든 프로퍼티들이 기본적으로 final 이다. 따라서 올바른 Entity 클래스를 정의하기 위해선 클래스와 프로퍼티들에 open 을 명시해야 한다. 이런 보일러플레이트적인 코드를 해결하기 위해 코틀린은 allOpen 플러그인을 제공한다.
그럼 데이터 클래스에 allOpen 플러그인을 적용하면 어떻게 될까?
// Order Data 클래스
@Entity
data class Order(
    @Id var id: UUID,
    @Column var orderAt: LocalDateTime,
    @Column var state: OrderState,
)

// allOpen플러그인을 적용한 컴파일 결과
@javax.persistence.Entity public open data class Order public constructor(id: java.util.UUID, orderAt: java.time.LocalDateTime, state: member.entity.OrderState) {
    @field:javax.persistence.Id public open var id: java.util.UUID /* compiled code */
    @field:javax.persistence.Column public open var orderAt: java.time.LocalDateTime /* compiled code */
    @field:javax.persistence.Column public open var state: member.entity.OrderState /* compiled code */

    public final operator fun component1(): java.util.UUID { /* compiled code */ }
    public final operator fun component2(): java.time.LocalDateTime { /* compiled code */ }
    private final operator fun component3(): member.entity.OrderState { /* compiled code */ }
}
컴파일 결과 데이터 클래스가 open 되었다. 실제로 이렇게 open된 데이터 클래스를 Entity 클래스로 이용해서 테스트해보면 lazy-loading을 위한 프록시 객체까지 올바르게 동작한다.
하지만 언어 자체에서 정해놓은 디자인을 컴파일러 플러그인을 통해 비정상적으로 뚫어버린다는 느낌을 지우기 힘들어서 관련 글들을 찾아보니 다음 글을 볼 수 있었다.
• I think it's not OK because it breaks the consistency of language design for data  classes. • breaking data class contracts will eventually bring more harm than good.
권위에 현혹되는 건 옳지 않지만, 코틀린 엔지니어들의 의견은 언어 디자인의 일관성을 깨뜨리기때문에 쓰지 않는게 좋다 이고, 나 역시 그렇게 생각한다.

3. 스프링 부트 가이드에서 말하는 데이터 클래스

코틀린 스프링 부트 가이드에서 Data 클래스에 대해 다음과 같이 얘기한다.
Here we don’t use data classes with val properties because JPA is not designed to work with immutable classes or the methods generated automatically by data classes. If you are using other Spring Data flavor, most of them are designed to support such constructs so you should use classes like data class User(val login: String, …) when using Spring Data MongoDB, Spring Data JDBC, etc.
JPA는 불변 클래스와 함께하도록 설계되지 않았으니 val 프로퍼티를 가진 Data 클래스를 사용하지 말라고 되어있다. 이 때문에 가이드 문서에서는 아래와 같이 프로퍼티들을 var 로 정의하는 것을 볼 수 있다.
@Entity
class Article(
    @Id @GeneratedValue var id: Long? = 0
    var title: String,
    var headline: String,
    var content: String,
    @ManyToOne var author: User,
    var slug: String = title.toSlug(),
    var addedAt: LocalDateTime = LocalDateTime.now(),
)
사실 위보다 중요한 부분은 데이터 클래스가 자동 생성하는 메소드를 사용하도록 JPA가 디자인되지 않았다는 내용이다.

데이터 클래스가 자동 생성하는 메서드는 Entity 클래스에 어울리지 않는다

아래 코드를 살펴보면서 데이터 클래스가 자동으로 구현해주는 메서드가 일으키는 문제를 살펴보자.
@Entity
data class Member(
    @Id @GeneratedValue var id: Long,
    var name: String,
    @ManyToOne(fetch = FetchType.LAZY) var team: Team
) {
	// data 클래스에서 자동으로 생성되는 toString은 아래와 같은 형태이다.
	overide fun toString(): String {
		return this::class.simpleName + 
			"(id = $id , name = $name , age = $age , team = $team)" // team에 접근
	}
}

val member = memberRepo.findById(1L)
member.toString() // team의 특정 프로퍼티에 접근하지 않아도 team을 가져오는 쿼리가 실행된다
member.toString() 만 호출했을 뿐인데 Lazy하게 가져와야 할 Team까지 추가적인 쿼리를 통해 가져오게 된다. (프록시 객체가 아닌 원본 team 객체의 주소가 필요해지므로 Team이 일반 클래스여도 쿼리는 실행)
이렇게 데이터 클래스에서 기본 구현되는 toString() , hashCode() 같은 메소드들에 lazy-loading해야할 프로퍼티가 포함되면 의도치않게 쿼리가 발생한다. 또한 이렇게 데이터 클래스가 자동적으로 구현하는 메소드들이 Entity 클래스에서 사용되면, 순환 참조가 되면서 stack overlow가 발생할 가능성도 있다.
데이터 클래스가 제공하는 copy() 역시 JPA와 어울리지 않는다. 이를 통해 새로운 객체를 복사한다면, 엔티티의 변화를 DB에 반영하는 dirty checking 이 올바르게 동작하지 않을 것이다.
 
결과적으로 불변을 지향하는 데이터 클래스가 제공하는 copy()가 JPA와 어울리지 않기도 하고 toString() , equals() , hashCode() 들 직접 오버라이딩해야 할 필요성이 있다.
더 이상 데이터 클래스를 Entity 클래스로 사용할 이유가 없어보인다. 오히려 사용하지 말아야겠다는 생각까지 든다.

일반 클래스를 사용하자

결국 데이터 클래스가 Entity 클래스에 어울리지 않는 듯하다. 따라서 더 자유롭게 설계 가능한 일반 클래스를 사용해보자.
TODO : var, val, nullable
추가적으로 JSR 338의 JPA 스펙을 찾아보니 Entity 클래스에 대해 아래와 같은 내용들이 나온다.
The value of its primary key uniquely identifies an entity instance within a persistence context and to EntityManager operations as described in Chapter 3. The application must not change the value of the primary key[10]. The behavior is undefined if this occurs.[11]
The state of persistent entities is synchronized to the database at transaction commit. This synchronization involves writing to the database any updates to persistent entities and their relationships as specified above.
PK는 변하지 않는게 예상치 못한 동작을 막을 수 있다는 내용이 있으므로, @Id에 해당하는 프로퍼티는 val로 정의하는 게 좋아보인다. 아마 영속성 컨텍스트에서 캐시가 PK를 기준으로 동작하기 때문에 이렇게 적혀있는 것 같다.
또한 Entity의 상태 변화를 통해 자동으로 update가 동기화될 수 있도록 상태가 변할 수 있는 프로퍼티는 var로 정의해서 mutable하게 가져가는 게 좋아보인다.

References