개요
코틀린 + 스프링으로 서버를 개발하는 사례가 많아지면서 자연스럽게 JPA와 코틀린을 함께 사용하는 사례도 증가하고 있다. 하지만 JPA (Java Persistence API)는 이름에서도 알 수 있듯이 자바를 기준으로 한 ORM이기 때문에 코틀린으로 엔티티를 정의하다보면 서로 궁합이 좋지 않다는 것을 알 수 있다.
이 글에서는 JPA를 사용하여 코틀린 기반의 도메인 모델을 만들 때 발생하는 여러 문제점들 및 해결법, 엔티티의 컨셉을 해치지 않으며 모델링하는 고민을 다룬다.
- 자주 볼 수 있는 안티 패턴 사례
- @GeneratedValue 사용시 이슈 및 대안책
- equals() 구현시 이슈 및 해결
- 연관관계 collection 불변 설정시 이슈 및 해결
- 유용한 플러그인 및 라이브러리
- 엔티티 정의 팁
자주 볼 수 있는 안티 패턴 사례
mutable property 사용
아래는 코틀린으로 엔티티를 설계한 코드, 그 코드와 같은 동작을 하는 코드이다.
@Entity
class Product(
@ID
var id: Long,
@Column
var name: String
)
@Entity
@Getter @Setter
class Product {
@ID
long id; // 실제로 코틀린에서 이 필드 자체가 노출되지는 않는다. getter, setter로 노출됨.
@Column
String name;
public Product(long id, String name) {
this.id = id;
this.name = name;
}
}
위의 예시의 엔티티는 캡슐화가 되어 있지 않기 때문에 엔티티 내부의 상태를 엔티티 외부에서 변경할 수 있다.
따라서 변경될 일이 없는 pk는 val로 선언하고, 나머지 프로퍼티는 변경이 필요하기 때문에 var로 선언하되 setter를 protected로 막아야 한다. 이 내용은 이후 추가적으로 설명할 예정이다.
엔티티 생성에 data class 이용
코틀린에는 class를 만들때 data class라는 키워드 이용이 가능하다. data class를 이용하면 copy, equals, hashCode, toString 함수를 기본 제공하기 때문에 엔티티를 생성할 때 사용하는 사례가 종종 발생한다. 하지만 기본 생성자에 정의한 프로퍼티만 위 함수들에 적용된다.
data class Product(val url: String) {
var name: String = ""
}
val product1 = Product("example.com")
val product2 = Product("example.com")
product1.name = "macbook"
product2.name = "banana"
product1 == product2 // true
이렇게 될 경우 필드의 위치가 기본 생성자로 강제되며 양방향 연관관계가 있을 경우 순환 참조가 생길 수도 있다.
스프링 공식 문서에도 JPA는 data class를 고려하지 않았기 때문에 사용에 주의 하라고 한다.

또다른 문제점으로는 data class는 open을 사용할 수 없기 때문에 Lazy 로딩 설정을 사용할 수 없다.
(allOpen에 관해서는 아래에서 추가 설명을 진행한다.)

따라서 class로 엔티티를 구현하고 equals와 같은 함수들을 오버라이딩하는 것을 권장한다.
유용한 도메인 모델링 예제

@MappedSuperclass
abstract class PrimaryKeyEntity : Persistable<UUID> {
@Id
@Column(columnDefinition = "uuid")
private val id: UUID = UlidCreator.getMonotonicUlid().toUuid()
@Transient
private var _isNew = true
override fun getId(): UUID = id
override fun isNew(): Boolean = _isNew
override fun equals(other: Any?): Boolean {
if (other == null) return false
if (other !is HibernateProxy && this::class != other::class) return false
return id == getIdentifier(other)
}
private fun getIdentifier(obj: Any): Serializable {
return when (obj) {
is HibernateProxy -> obj.hibernateLazyInitializer.identifier as? UUID ?: throw IllegalArgumentException("Cannot determine identifier")
is PrimaryKeyEntity -> obj.id
else -> throw IllegalArgumentException("Unsupported entity type: ${obj::class.simpleName}")
}
}
override fun hashCode(): Int = Objects.hashCode(id)
@PostPersist
@PostLoad
protected fun load() {
_isNew = false
}
}
@Entity
class Product(
name: String,
description: String,
user: User,
tags: Set<Tag>
): PrimaryKeyEntity() {
@Column(nullable = false)
var name: String = name
protected set
var description: String = description
protected set
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(nullable = false)
var user: User = user
protected set
@ManyToMany(fetch = FetchType.LAZY, cascade = [CascadeType.PERSIST, CascadeType.MERGE])
@JoinTable(
name = "product_tag_assoc",
joinColumns = [JoinColumn(name = "product_id")],
inverseJoinColumns = [JoinColumn(name = "tag_id")]
)
protected val mutableTags: MutableSet<Tag> = tags.toMutableSet()
val tags: Set<Tag> get() = mutableTags.toSet()
@ElementCollection
@CollectionTable(name = "product_review")
private val mutableReviews: MutableList<Review> = mutableListOf()
val reviews: List<Review> get() = mutableReviews.toList()
fun update(data: ProductUpdateData) {
name = data.name
description = data.description
}
fun addTag(tag: Tag) {
mutableTags.add(tag)
}
fun removeTag(tagId: UUID) {
mutableTags.removeIf { it.id == tagId }
}
fun addReview(review: Review) {
mutableReviews.add(review)
}
init {
user.createProduct(this)
}
}
@Embeddable
data class Review(
@Column(length = 3000)
val comment: String,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(nullable = false)
val user: User
)
@Entity
@Table(name = " `user`")
class User (
name: String
) : PrimaryKeyEntity() {
@Column(nullable = false, unique = true)
var name: String = name
protected set
@OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], mappedBy = "user")
protected val mutableProducts: MutableList<Product> = mutableListOf()
val products: List<Product> get() = mutableProducts.toList()
fun createProduct(product: Product) {
mutableProducts.add(product)
}
}
@Entity
@Table(uniqueConstraints = [UniqueConstraint(name = "tag_key_value_uk", columnNames = ["`key`", "`value`"])])
class Tag(
key: String,
value: String
): PrimaryKeyEntity() {
@Column(name = "`key`", nullable = false)
var key: String = key
protected set
@Column(name = "`value`", nullable = false)
var value: String = value
protected set
}
모델링시 이슈 및 해결
@GeneratedValue
@GeneratedValue 사용시 이슈
JPA는 PK를 자동 생성해주는 @GeneratedValue를 기본으로 제공한다. 하지만 이 방법을 사용할 때 몇가지 단점(혹은 엔티티의 컨셉을 해치는 부분)이 있어 살펴본다.
문제점 1. nullable 타입 유도

JPA의 save 메소드에서 entityInformation.isNew()가 참이면(엔티티가 새롭게 생성되었다면) persist, 이미 존재하면 merge 함수를 실행한다.

isNew의 구현체 중 AbstractEntityInformation.isNew 메소드를 살펴보면 위와 같다.
ID가 primitive 타입이 아닐때 null이면 새롭게 생성된 엔티티로 판단한다. 즉, PK가 nullable 하다는 것을 의미한다. 하지만 대부분의 DB에서 PK를 Not Null로 강제하고 있어 JPA와 DB에서의 PK 개념에 불일치가 발생한다. 또한 엔티티의 식별자가 null이 될 수 있다는 건 도메인 개념에서 생각해도 자연스럽지 않다.
문제점 2. 생성한 값과 영속화 된 PK 값이 다름
PK가 Number 타입일 때 0의 값을 갖고 있으면 새롭게 생성된 엔티티라고 판단한다.
영속화하기 전까지 모든 엔티티의 PK는 0이다. 그렇다면 영속화하지 않은 엔티티는 모두 같은 엔티티라고 봐야 하는지 의문이 생긴다. 다른 엔티티라고 판단하기 위해서는 equals 메소드 구현에서 PK가 0인 케이스를 제외해야 하는데, 이렇게 상황에 따라 변경하는 것은 옳지 않다.
문제점 3. DB에 채번을 전가시켜 부하 발생시킴
@GeneratedValue의 auto_increment나 시퀀스 등 방법들은 모두 DB에 PK 할당을 전가해 부하를 유발한다.
@GeneratedValue 대안책
이때 Long 타입으로 할당하면 PK가 랜덤한 값이라 보장할 수 없다.
ULID 타입은 생성 순서를 밀리세컨 단위로 기록할 수 있어서 생성 순서대로 정렬을 할 때 편리하다. 같은 밀리세컨 단위까지 같은 시간에 만들어지는 확률은 극히 적다. (1ms에 2^80개 생성가능; 분석글) 따라서 엔티티 생성시 ULID가 PK를 생성하도록 하여 (엔티티 생성시 PK도 함께 생성) 위의 문제를 극복할 수 있다.
대안책 구현시 이슈 및 해결
영속화 전에 ULID로 PK를 만들 경우 위의 문제점들을 해결할 수 있지만 또다른 이슈가 발생한다.
위의 isNew 구현체에서 살펴 보았듯이 PK가 primitive 타입이 아닐 경우 null일때만 신규 엔티티로 간주한다.
@Entity
class Foo(
name: String
) {
@Id
val id: UUID = UlidCreator.getMonotonicUlid().toUuid()
@Column
var name: String = name
protected set
}
@DataJpaTest(showSql = true)
class Test {
@Autowired private lateinit var fooRepository: FooRepository
@Autowired private lateinit var em: TestEntityManager
@Test
fun test() {
val foo = Foo("foo")
fooRepository.save(foo)
em.flush()
}
}
실제로 PK를 먼저 생성한 후 save를 했을 때 merge 메소드가 실행되기 때문에 해당 PK를 갖는 엔티티를 DB에서 조회 후 저장하는 것을 볼 수 있다.
spring data는 이런 문제를 해결하기 위해 persistable 인터페이스를 제공한다. 이 인터페이스에는 getID, isNew와 같은 메소드가 있다. 엔티티에서 이를 구현하면 영속화시 isNew 메소드를 호출하도록 만드는 것이 가능하다.
@Entity
class Foo(
name: String
): Persistable<UUID> {
@Id
val fooId: UUID = UlidCreator.getMonotonicUlid().toUuid()
@Column
var name: String = name
protected set
override fun getId(): UUID = id
override fun isNew(): Boolean = true
}
위 방법으로 save 호출시 이슈를 해결할 수 있지만 delete 호출시 또다시 이슈가 발생한다.

delete 메소드의 구현을 살펴보면 isNew를 호출했을 때 참일 경우 삭제를 진행하지 않는다.
isNew가 참이어야 하는 구간은 엔티티 생성후 ~ 영속화 전까지이다. 즉, 영속화 한 이후와 엔티티 조회시 isNew는 거짓이 되어야 한다.
이를 해결하기 위해 @PostPersist와 @PostLoad를 사용한다. 각각 영속화 이후와 조회시 특정 메소드를 실행할 수 있도록 해준다.
@Entity
class Foo(
name: String
): Persistable<UUID> {
@Id
val fooId: UUID = UlidCreator.getMonotonicUlid().toUuid()
@Column
var name: String = name
protected set
@Transient
private var _isNew = true
override fun getId(): UUID = id
override fun isNew(): Boolean = _isNew
@PostPersist
@PostLoad
protected fun load() {
_isNew = false
}
}
equals 구현시 이슈와 해결
equals 구현시 이슈
모든 엔티티마다 equals, hashCode를 재정의하는 것은 번거롭기 때문에 미리 구현하는 방법을 택한다.
@MappedSuperclass
abstract class PrimaryKeyEntity : Persistable<UUID> {
@Id
@Column(columnDefinition = "uuid")
private val id: UUID = UlidCreator.getMonotonicUlid().toUuid()
// 생략...
override fun equals(other: Any?): Boolean {
if (other == null) {
return false
}
if (this::class != other::class) {
return false
}
return id == (obj as PrimaryKeyEntity).id
}
override fun hashCode() = Objects.hashCode(id)
}
위처럼 equals와 hashCode를 구현해서 사용할 수 있으나 아래의 테스트를 확인해보면 문제점이 있음을 알 수 있다.
@Test
fun `동일성 테스트`() {
val user = User("홍길동")
val product = Product("어떤 물건", "내용1", user, setOf())
em.persist(user)
em.persist(product)
em.flush()
em.clear()
val foundProduct = em.find(Product::class.java, product.id)
assertFalse(user == foundProduct.user)
println(user::class.java) // class com.example.kotlinjpa.domain.User
println(foundProduct.user::class.java) // class com.example.kotlinjpa.domain.User$HibernateProxy$ikhW5qrI
}
연관관계를 가진 두 엔티티가 Lazy 로딩 설정이 되어 있을 때, 엔티티를 조회하면 일단 연관된 다른 엔티티에 프록시 객체를 넣어두고 꼭 필요할 때까지 지연 조회를 한다. 그 때문에 위 테스트에서 user와 foundProduct.user를 다른 객체로 판단한다.
(HibernateProxy 인터페이스를 넣어두는데 실제 연관된 엔티티를 사용할 때 조회하더라도 HibernateProxy 인터페이스 타입을 유지한다. 인터페이스 내부에 실제 객체가 초기화되어 매핑되어 있는지 여부는 Hibernate.isInitialized(Object proxy) 함수를 통해 알 수 있다.)
이슈 해결
위의 문제점을 해결하기 위해서는 equals를 아래와 같이 프록시 객체도 고려하여 재정의 해야한다.
override fun equals(other: Any?): Boolean {
if (other == null) {
return false
}
if (other !is HibernateProxy && this::class != other::class) {
return false
}
return id == getIdentifier(other)
}
private fun getIdentifier(obj: Any): Serializable {
return when (obj) {
is HibernateProxy -> obj.hibernateLazyInitializer.identifier as? UUID ?: throw IllegalArgumentException("Cannot determine identifier")
is PrimaryKeyEntity -> obj.id
else -> throw IllegalArgumentException("Unsupported entity type: ${obj::class.simpleName}")
}
}
연관관계 collection
연관관계 collection 불변 설정시 이슈
연관관계 프로퍼티를 val로 선언하더라도 외부에서 쉽게 변경할 수 있다.
@Entity
@Table(name = "`user`")
class User(
name: String,
) : PrimaryKeyEntity() {
@Column(nullable = false, unique = true)
var name: String = name
protected set
@OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], mappedBy = "writer")
val mutableBoards: MutableList<Board> = mutableListOf()
}
@Test
fun `연관관계 프로퍼티 val로 선언 후 외부에서 변경`() {
val user = User("홍길동")
val product1 = Product("상품1", "내용", user, setOf())
val product2 = Product("상품2", "내용", user, setOf())
em.persist(user)
em.persist(product1)
em.persist(product2)
em.flush()
em.clear()
var foundUser = userRepository.findById(user.id).get()
val product3 = Product("상품3", "내용", user, setOf())
foundUser.mutableProducts.add(product3)
em.merge(foundUser)
em.flush()
em.clear()
foundUser = userRepository.findById(user.id).get()
assertEquals(3, foundUser.mutableProducts.size)
}
collection을 val로 선언하더라도 그 객체의 주소에 대한 불변을 보장하는 것 뿐이라 collection 내부의 변경까지 막지는 못한다. 따라서 엔티티 외부에서 collection에 직접 요소를 추가할 수 있다.
이슈 해결
이를 막기 위해 MutableList는 엔티티 내부에서만 사용하고 외부 노출은 ImmutableList을 이용한다.
@Entity
@Table(name = " `user`")
class User (
name: String
) : PrimaryKeyEntity() {
@Column(nullable = false, unique = true)
var name: String = name
protected set
@OneToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], mappedBy = "user")
protected val mutableProducts: MutableList<Product> = mutableListOf()
val products: List<Product> get() = mutableProducts.toList()
fun createProduct(product: Product) {
mutableProducts.add(product)
}
}
products의 getter 값을 반환할 때 toList()로 mutableProducts를 복제하지 않고 그대로 반환할 경우 반환값과 mutableProducts가 동기화되므로 꼭 복제하여 반환한다.
아래는 복제하지 않을 경우 문제점을 테스트한 것이다.
@Test
fun `연관관계 프로퍼티 val로 선언 후 외부에서 변경`() {
val user = User("홍길동")
val product1 = Product("상품1", "내용", user, setOf())
val product2 = Product("상품2", "내용", user, setOf())
em.persist(user)
em.persist(product1)
em.persist(product2)
em.flush()
em.clear()
var foundUser = userRepository.findById(user.id).get()
val products = foundUser.products
assertEquals(2, products.size)
val product3 = Product("상품3", "내용", user, setOf())
foundUser.createProduct(product3)
assertEquals(2, products.size) // assert error : expected: <2> but was: <3>
}
엔티티 정의 팁
유용한 플러그인 및 라이브러리
allOpen 플러그인
코틀린의 클래스와 프로퍼티, 메소드는 기본적으로 final이다. 하지만 Hibernate 문서에는 엔티티와 그 인스턴스 변수는 final이 아니어야 한다고 명시되어 있다. (더 정확하게는 동작은 하지만 Lazy loading을 위한 프록시 객체 생성 불가) 따라서 상속을 하는데 문제가 없도록 open 시켜주어야 한다.
이때 클래스에 open 키워드를 붙이는 것에서 끝나는 것이 아니라 모든 프로퍼티 및 함수에도 추가해야 하는 번거로움이 있다. 그래서 allOpen 플러그인을 사용하여 특정 어노테이션이 있는 컴포넌트는 open 되도록 한다.
(spring boot kotlin tutorial에서도 allOpen 플러그인을 권장)
plugins {
...
kotlin("plugin.spring") version "1.7.22" // allOpen 포함
kotlin("plugin.jpa") version "1.7.22" // noArg 포함
}
...
// 스프링 3 버전 이상은 javax 대신 jakarta 사용!
allOpen {
annotation("javax.persistence.Entity")
annotation("javax.persistence.MappedSuperclass")
annotation("javax.persistence.Embeddable")
}
Lazy 로딩을 위한 프록시 객체 생성을 위해서는 기본 생성자도 필요한데 이는 plugin.jpa에 포함되어 있으므로 따로 작성하지 않아도 된다.
Kassava
kassava는 코틀린의 toString, equals, hashCode와 같은 보일러 플레이트 코드를 구현한 라이브러리이다. 필요한 메소드를 오버라이드하여 사용할 수 있다.
repositories {
...
maven("https://jitpack.io")
}
dependencies {
implementation("com.github.consoleau:kassava:2.1.0")
}
// 1. Import extension functions
import au.com.console.kassava.kotlinEquals
import au.com.console.kassava.kotlinHashCode
import au.com.console.kassava.kotlinToString
import java.util.Objects
class Employee(val name: String, val age: Int? = null) {
// 2. Optionally define your properties for equals/toString in a companion object
// (Kotlin will generate less KProperty classes, and you won't have array creation for every method call)
companion object {
private val properties = arrayOf(Employee::name, Employee::age)
}
// 3. Implement equals() by supplying the list of properties used to test equality
override fun equals(other: Any?) = kotlinEquals(other = other, properties = properties)
// 4. Implement toString() by supplying the list of properties to be included
override fun toString() = kotlinToString(properties = properties)
// 5. Implement hashCode() by supplying the list of properties to be included
override fun hashCode() = kotlinHashCode(properties = properties)
}
protected setter
엔티티 외부에서 프로퍼티 변경을 막는 것이 유지, 보수를 쉽게 하고 애플리케이션을 더욱 쉽게 만든다. 하지만 프로퍼티들은 생명주기동안 변경이 가능하기 때문에 immutable로 선언하는 것은 옳지 않다. 따라서 pk를 제외한 프로퍼티들은 mutable로 선언해야한다.
주의할 점은 생성자 내부에서 mutable 프로퍼티로 선언할 경우 getter, setter가 자동으로 만들어지기 때문에 setter를 protected로 막아주어야 한다. (뒤에서 설명할 내용 중 엔티티 클래스 및 프로퍼티는 open 해야 한다는 내용이 있다. open 프로퍼티의 경우 private setter는 허용되지 않기 때문에 protected로 막는다.)
nullable 필드 명시
자바로 되어 있는 코드를 코틀린으로 전환하거나 두 언어를 함께 사용할 때 데이터베이스의 스키마와 엔티티의 스키마가 불일치하는 이슈가 종종 발생한다. nullable한 컬럼을 엔티티에서 non-nullable하게 선언할 경우, 컬럼에 그 값이 null일 때 런타임 에러가 발생한다.
자바에서는 언어적으로 모든 프로퍼티가 nullable하기 때문에 큰 문제가 없지만 코틀린은 nullable과 non-nullable을 구별하기 때문에 @Column(nullable = false) 속성은 가급적 명시하는 것이 좋다.
Value Object
Value Object는 식별자 없이 속성값으로 식별하는 객체를 말한다. Value Object에는 크게 Embedded와 ElementCollection이 있다.
Embedded
Embedded는 엔티티가 가진 프로퍼티들의 공통 특성을 하나의 객체로 묶을 때 사용한다. 예를 들어 ProductInformation 클래스를 만들고 Product 와 각각 @Embeddable, @Embedded로 연결해주면 Product 엔티티에 ProductInformation 프로퍼티들이 컬럼으로 정의된다.
@Embeddable 클래스는 Value Object, 즉 값이 중요한 객체이기 때문에 data class를 활용하기 알맞은 객체이다.
ElementCollection
복수의 Value Object를 표현할 때 ElementCollection을 사용한다. @OneToMany와 비슷하지만 엔티티와 엔티티 간의 관계가 아닌, 엔티티와 Value Object 관계를 나타낼 때 사용한다는 차이점이 있다.
ElementCollection은 그 요소가 변경될 때마다 모든 데이터를 지웠다가 새롭게 등록한다는 특징이 있다. 식별자가 없다는 점 때문에 이렇게 동작하게 된다. 데이터베이스 관점에서 문제가 될 수 있기 때문에 설계시 고려해야 한다.
아래는 요소를 추가할 때 삭제가 발생하는지를 테스트한 코드이댜.
@Test
fun `ElementCollection 테스트`() {
val user = User("홍길동")
val product = Product("상품", "내용", user, setOf())
product.addReview(Review("내용", user))
em.persist(user)
em.persist(product)
em.flush()
em.clear()
val foundProduct = productRepository.getReferenceById(product.id)
foundProduct.addReview(Review("내용2", user))
em.merge(foundProduct)
em.flush()
}
Hibernate:
insert
into
"user"
(name, id)
values
(?, ?)
Hibernate:
insert
into
product
(description, name, "user_id", id)
values
(?, ?, ?, ?)
Hibernate:
insert
into
product_review
(product_id, comment, "user_id")
values
(?, ?, ?)
Hibernate:
select
p1_0.id,
p1_0.description,
p1_0.name,
p1_0."user_id"
from
product p1_0
where
p1_0.id=?
Hibernate:
select
m1_0.product_id,
m1_0.comment,
m1_0."user_id"
from
product_review m1_0
where
m1_0.product_id=?
Hibernate:
delete
from
product_review
where
product_id=?
Hibernate:
insert
into
product_review
(product_id, comment, "user_id")
values
(?, ?, ?)
Hibernate:
insert
into
product_review
(product_id, comment, "user_id")
values
(?, ?, ?)
Reference
https://spoqa.github.io/2022/08/16/kotlin-jpa-entity.html
스포카에서 Kotlin으로 JPA Entity를 정의하는 방법
도도카트 서비스를 개발할 때 JPA를 사용하면서 좀더 Entity를 잘 정의하는 방법에 대한 고민거리를 소개합니다.
spoqa.github.io
'Kotlin' 카테고리의 다른 글
[kotlin] 타입 (0) | 2024.01.04 |
---|---|
[kotlin] 클래스 (0) | 2024.01.04 |
[kotlin] 기본 문법 (0) | 2024.01.04 |
Kotlin Multi-platform (0) | 2024.01.04 |
Exposed 기초 (0) | 2024.01.04 |